Recreate legacy controls in PySide6 window

This commit is contained in:
lm 2025-10-19 19:18:20 +02:00
parent 9ded332269
commit 825bdcebe0
1 changed files with 376 additions and 74 deletions

View File

@ -1,14 +1,183 @@
"""Main PySide6 window with custom title bar.""" """Main PySide6 window emulating the legacy Tk interface."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Tuple
from PySide6 import QtCore, QtGui, QtWidgets from PySide6 import QtCore, QtGui, QtWidgets
DEFAULT_COLOUR = "#763e92"
PRESET_COLOURS: List[Tuple[str, str]] = [
("Red", "#ff3b30"),
("Orange", "#ff9500"),
("Yellow", "#ffd60a"),
("Green", "#34c759"),
("Teal", "#5ac8fa"),
("Blue", "#0a84ff"),
("Violet", "#af52de"),
("Magenta", "#ff2d55"),
("White", "#ffffff"),
("Grey", "#8e8e93"),
("Black", "#000000"),
]
SLIDER_SPECS: List[Tuple[str, str, int, int, int]] = [
("Hue min", "hue_min", 0, 360, 0),
("Hue max", "hue_max", 0, 360, 360),
("Sat min", "sat_min", 0, 100, 25),
("Value min", "val_min", 0, 100, 15),
("Value max", "val_max", 0, 100, 100),
("Overlay alpha", "alpha", 0, 255, 120),
]
class ToolbarButton(QtWidgets.QPushButton):
"""Rounded toolbar button inspired by the legacy design."""
def __init__(self, icon_text: str, label: str, callback: Callable[[], None], parent: QtWidgets.QWidget | None = None):
super().__init__(f"{icon_text} {label}", parent)
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedHeight(34)
self.setStyleSheet(
"""
QPushButton {
padding: 8px 16px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(255, 255, 255, 0.04);
color: #f7f7fb;
font-weight: 600;
}
QPushButton:hover {
background-color: rgba(255, 255, 255, 0.12);
}
QPushButton:pressed {
background-color: rgba(255, 255, 255, 0.18);
}
"""
)
self.clicked.connect(callback)
class ColourSwatch(QtWidgets.QPushButton):
"""Clickable palette swatch."""
def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None):
super().__init__(parent)
self.hex_code = hex_code
self.name = name
self.setToolTip(f"{name} ({hex_code})")
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedSize(28, 28)
self.setStyleSheet(
f"""
QPushButton {{
background-color: {hex_code};
border: 2px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
}}
QPushButton:hover {{
border-color: rgba(255, 255, 255, 0.45);
}}
"""
)
self.clicked.connect(lambda: callback(hex_code, name))
class SliderControl(QtWidgets.QWidget):
"""Slider with header and live value label."""
value_changed = QtCore.Signal(str, int)
def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int):
super().__init__()
self.key = key
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
header = QtWidgets.QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
title_label = QtWidgets.QLabel(title)
title_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 500;")
header.addWidget(title_label)
header.addStretch(1)
self.value_label = QtWidgets.QLabel(str(initial))
self.value_label.setStyleSheet("color: rgba(255,255,255,0.6);")
header.addWidget(self.value_label)
layout.addLayout(header)
self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.slider.setRange(minimum, maximum)
self.slider.setValue(initial)
self.slider.setCursor(QtCore.Qt.PointingHandCursor)
self.slider.setStyleSheet(
"""
QSlider::groove:horizontal {
border: 1px solid rgba(255,255,255,0.08);
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #9a4dff;
border: 1px solid rgba(255,255,255,0.2);
width: 14px;
margin: -5px 0;
border-radius: 7px;
}
"""
)
self.slider.valueChanged.connect(self._sync_value)
layout.addWidget(self.slider)
def _sync_value(self, value: int) -> None:
self.value_label.setText(str(value))
self.value_changed.emit(self.key, value)
def set_value(self, value: int) -> None:
self.slider.blockSignals(True)
self.slider.setValue(value)
self.slider.blockSignals(False)
self.value_label.setText(str(value))
class ImageView(QtWidgets.QLabel):
"""Aspect-ratio aware image view."""
def __init__(self) -> None:
super().__init__()
self.setAlignment(QtCore.Qt.AlignCenter)
self._pixmap: QtGui.QPixmap | None = None
def set_image(self, pixmap: QtGui.QPixmap | None) -> None:
self._pixmap = pixmap
self._rescale()
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
super().resizeEvent(event)
self._rescale()
def _rescale(self) -> None:
if self._pixmap is None:
self.setPixmap(QtGui.QPixmap())
self.setText("<No image loaded>")
self.setStyleSheet("color: rgba(255,255,255,0.45); font-size: 14px;")
return
target = self._pixmap.scaled(
self.size(),
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation,
)
self.setPixmap(target)
self.setStyleSheet("")
class TitleBar(QtWidgets.QWidget): class TitleBar(QtWidgets.QWidget):
"""Custom title bar mimicking modern Windows applications.""" """Custom title bar with native window controls."""
HEIGHT = 40 HEIGHT = 40
@ -17,8 +186,8 @@ class TitleBar(QtWidgets.QWidget):
self.window = window self.window = window
self.setFixedHeight(self.HEIGHT) self.setFixedHeight(self.HEIGHT)
self.setCursor(QtCore.Qt.ArrowCursor) self.setCursor(QtCore.Qt.ArrowCursor)
self.setAutoFillBackground(True) self.setAutoFillBackground(True)
palette = self.palette() palette = self.palette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#16171d")) palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#16171d"))
self.setPalette(palette) self.setPalette(palette)
@ -31,13 +200,12 @@ class TitleBar(QtWidgets.QWidget):
if logo_path.exists(): if logo_path.exists():
pixmap = QtGui.QPixmap(str(logo_path)) pixmap = QtGui.QPixmap(str(logo_path))
logo_label = QtWidgets.QLabel() logo_label = QtWidgets.QLabel()
logo_label.setPixmap(pixmap.scaled(24, 24, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
layout.addWidget(logo_label) layout.addWidget(logo_label)
title_label = QtWidgets.QLabel("Interactive Color Range Analyzer") title_label = QtWidgets.QLabel("Interactive Color Range Analyzer")
title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;") title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;")
layout.addWidget(title_label) layout.addWidget(title_label)
layout.addStretch(1) layout.addStretch(1)
self.min_btn = self._create_button("", "Minimise window") self.min_btn = self._create_button("", "Minimise window")
@ -52,7 +220,7 @@ class TitleBar(QtWidgets.QWidget):
close_btn.clicked.connect(window.close) close_btn.clicked.connect(window.close)
close_btn.setStyleSheet( close_btn.setStyleSheet(
""" """
QPushButton { background-color: transparent; color: #f7f7fb; border: none; padding: 4px 10px; } QPushButton { background: transparent; color: #f7f7fb; border: none; padding: 4px 10px; }
QPushButton:hover { background-color: #d0342c; } QPushButton:hover { background-color: #d0342c; }
""" """
) )
@ -72,7 +240,7 @@ class TitleBar(QtWidgets.QWidget):
padding: 4px 10px; padding: 4px 10px;
} }
QPushButton:hover { QPushButton:hover {
background-color: rgba(255, 255, 255, 0.12); background-color: rgba(255, 255, 255, 0.1);
} }
""" """
) )
@ -90,46 +258,15 @@ class TitleBar(QtWidgets.QWidget):
super().mousePressEvent(event) super().mousePressEvent(event)
class ImageView(QtWidgets.QLabel):
"""Simple image display widget that keeps aspect ratio."""
def __init__(self) -> None:
super().__init__()
self.setAlignment(QtCore.Qt.AlignCenter)
self._pixmap: QtGui.QPixmap | None = None
def set_image(self, pixmap: QtGui.QPixmap | None) -> None:
self._pixmap = pixmap
self._rescale()
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
super().resizeEvent(event)
self._rescale()
def _rescale(self) -> None:
if self._pixmap is None:
self.clear()
self.setText("<No image loaded>")
self.setStyleSheet("color: rgba(255,255,255,0.5); font-size: 14px;")
return
target = self._pixmap.scaled(
self.size(),
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation,
)
self.setPixmap(target)
self.setStyleSheet("")
class MainWindow(QtWidgets.QMainWindow): class MainWindow(QtWidgets.QMainWindow):
"""Main application window containing custom chrome and content area.""" """Main application window containing all controls."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setWindowFlag(QtCore.Qt.FramelessWindowHint) self.setWindowFlag(QtCore.Qt.FramelessWindowHint)
self.setWindowFlag(QtCore.Qt.Window) self.setWindowFlag(QtCore.Qt.Window)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False) self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False)
self.setMinimumSize(900, 600) self.setMinimumSize(1100, 680)
container = QtWidgets.QWidget() container = QtWidgets.QWidget()
container_layout = QtWidgets.QVBoxLayout(container) container_layout = QtWidgets.QVBoxLayout(container)
@ -145,46 +282,22 @@ class MainWindow(QtWidgets.QMainWindow):
content_layout.setContentsMargins(24, 24, 24, 24) content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setSpacing(18) content_layout.setSpacing(18)
toolbar = QtWidgets.QHBoxLayout() content_layout.addLayout(self._build_toolbar())
toolbar.setSpacing(12) content_layout.addLayout(self._build_palette())
content_layout.addLayout(self._build_sliders())
self.open_button = QtWidgets.QPushButton("Open Image…") content_layout.addWidget(self._build_previews(), 1)
self.open_button.setCursor(QtCore.Qt.PointingHandCursor) content_layout.addLayout(self._build_status_section())
self.open_button.setStyleSheet(
"""
QPushButton {
background: linear-gradient(135deg, #5168ff, #9a4dff);
border: none;
color: #ffffff;
font-weight: 600;
padding: 10px 16px;
border-radius: 10px;
}
QPushButton:hover {
filter: brightness(1.1);
}
"""
)
self.open_button.clicked.connect(self.open_image)
toolbar.addWidget(self.open_button)
toolbar.addStretch(1)
self.status_label = QtWidgets.QLabel("No image loaded")
self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;")
toolbar.addWidget(self.status_label)
content_layout.addLayout(toolbar)
self.image_view = ImageView()
self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
content_layout.addWidget(self.image_view, 1)
container_layout.addWidget(self.content, 1) container_layout.addWidget(self.content, 1)
self.setCentralWidget(container) self.setCentralWidget(container)
self._is_maximised = False self._is_maximised = False
self._current_image_path: Path | None = None self._current_image_path: Path | None = None
self._current_colour = DEFAULT_COLOUR
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
self._slider_controls: Dict[str, SliderControl] = {}
self._register_default_actions()
self._update_colour_display(DEFAULT_COLOUR, "Default colour")
# Window control helpers ------------------------------------------------- # Window control helpers -------------------------------------------------
@ -206,6 +319,166 @@ class MainWindow(QtWidgets.QMainWindow):
if handle: if handle:
handle.startSystemMove() handle.startSystemMove()
# UI builders ------------------------------------------------------------
def _build_toolbar(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(12)
buttons = [
("open_image", "🖼", "Open image…"),
("open_folder", "📂", "Open folder…"),
("choose_color", "🎨", "Choose colour"),
("pick_from_image", "🖱", "Pick from image"),
("save_overlay", "💾", "Save overlay"),
("toggle_free_draw", "", "Toggle free-draw"),
("clear_excludes", "🧹", "Clear exclusions"),
("undo_exclude", "", "Undo last exclusion"),
("reset_sliders", "🔄", "Reset sliders"),
("toggle_theme", "🌓", "Toggle theme"),
]
self._toolbar_buttons: Dict[str, ToolbarButton] = {}
for key, icon_txt, label in buttons:
button = ToolbarButton(icon_txt, label, lambda k=key: self._invoke_action(k))
layout.addWidget(button)
self._toolbar_buttons[key] = button
layout.addStretch(1)
self.status_label = QtWidgets.QLabel("No image loaded")
self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;")
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout
def _build_palette(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
current_group = QtWidgets.QHBoxLayout()
current_group.setSpacing(8)
current_label = QtWidgets.QLabel("Current colour")
current_label.setStyleSheet("color: rgba(255,255,255,0.75); font-weight: 500;")
current_group.addWidget(current_label)
self.current_colour_swatch = QtWidgets.QLabel()
self.current_colour_swatch.setFixedSize(28, 28)
self.current_colour_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOUR}; border-radius: 6px;")
current_group.addWidget(self.current_colour_swatch)
self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})")
self.current_colour_label.setStyleSheet("color: rgba(255,255,255,0.65);")
current_group.addWidget(self.current_colour_label)
layout.addLayout(current_group)
more_label = QtWidgets.QLabel("More colours")
more_label.setStyleSheet("color: rgba(255,255,255,0.65); font-weight: 500;")
layout.addWidget(more_label)
swatch_container = QtWidgets.QHBoxLayout()
swatch_container.setSpacing(8)
for name, hex_code in PRESET_COLOURS:
swatch = ColourSwatch(name, hex_code, self._update_colour_display)
swatch_container.addWidget(swatch)
layout.addLayout(swatch_container)
layout.addStretch(1)
return layout
def _build_sliders(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(16)
self._slider_controls.clear()
for title, key, minimum, maximum, initial in SLIDER_SPECS:
control = SliderControl(title, key, minimum, maximum, initial)
control.value_changed.connect(self._on_slider_change)
layout.addWidget(control)
self._slider_controls[key] = control
return layout
def _build_previews(self) -> QtWidgets.QWidget:
container = QtWidgets.QWidget()
layout = QtWidgets.QGridLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setHorizontalSpacing(16)
layout.setVerticalSpacing(0)
self.prev_button = QtWidgets.QToolButton()
self.prev_button.setText("")
self.prev_button.setCursor(QtCore.Qt.PointingHandCursor)
self.prev_button.setFixedSize(44, 44)
self.prev_button.setStyleSheet(
"""
QToolButton {
border-radius: 22px;
background-color: rgba(255, 255, 255, 0.08);
color: #f7f7fb;
font-size: 18px;
}
QToolButton:hover {
background-color: rgba(255, 255, 255, 0.16);
}
"""
)
self.prev_button.clicked.connect(lambda: self._invoke_action("show_previous_image"))
layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter)
self.image_view = ImageView()
self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
layout.addWidget(self.image_view, 0, 1)
self.overlay_view = ImageView()
self.overlay_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
layout.addWidget(self.overlay_view, 0, 2)
self.next_button = QtWidgets.QToolButton()
self.next_button.setText("")
self.next_button.setCursor(QtCore.Qt.PointingHandCursor)
self.next_button.setFixedSize(44, 44)
self.next_button.setStyleSheet(self.prev_button.styleSheet())
self.next_button.clicked.connect(lambda: self._invoke_action("show_next_image"))
layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter)
layout.setColumnStretch(1, 1)
layout.setColumnStretch(2, 1)
return container
def _build_status_section(self) -> QtWidgets.QVBoxLayout:
layout = QtWidgets.QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(0, 0, 0, 0)
self.filename_label = QtWidgets.QLabel("")
self.filename_label.setAlignment(QtCore.Qt.AlignCenter)
self.filename_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 600;")
layout.addWidget(self.filename_label)
self.ratio_label = QtWidgets.QLabel("Matches with exclusions: —")
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
self.ratio_label.setStyleSheet("color: #e6b84b; font-weight: 600;")
layout.addWidget(self.ratio_label)
return layout
# Action wiring ----------------------------------------------------------
def _register_default_actions(self) -> None:
self._toolbar_actions = {
"open_image": self.open_image,
"open_folder": self._coming_soon,
"choose_color": self._coming_soon,
"pick_from_image": self._coming_soon,
"save_overlay": self._coming_soon,
"toggle_free_draw": self._coming_soon,
"clear_excludes": self._coming_soon,
"undo_exclude": self._coming_soon,
"reset_sliders": self._reset_sliders,
"toggle_theme": self._coming_soon,
"show_previous_image": self._coming_soon,
"show_next_image": self._coming_soon,
}
def _invoke_action(self, key: str) -> None:
action = self._toolbar_actions.get(key)
if action:
action()
# Image handling --------------------------------------------------------- # Image handling ---------------------------------------------------------
def open_image(self) -> None: def open_image(self) -> None:
@ -219,5 +492,34 @@ class MainWindow(QtWidgets.QMainWindow):
QtWidgets.QMessageBox.warning(self, "ICRA", "Unable to open the selected image.") QtWidgets.QMessageBox.warning(self, "ICRA", "Unable to open the selected image.")
return return
self.image_view.set_image(pixmap) self.image_view.set_image(pixmap)
self.overlay_view.set_image(None)
self._current_image_path = path self._current_image_path = path
self.status_label.setText(f"{path.name} · {pixmap.width()}×{pixmap.height()}") self.status_label.setText(f"{path.name} · {pixmap.width()}×{pixmap.height()}")
self.filename_label.setText(f"{path.name} ({pixmap.width()}×{pixmap.height()})")
self.ratio_label.setText("Matches with exclusions: pending")
# Helpers ----------------------------------------------------------------
def _update_colour_display(self, hex_code: str, label: str) -> None:
self._current_colour = hex_code
self.current_colour_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
self.current_colour_label.setText(f"({hex_code})")
self.status_label.setText(f"{label}: {hex_code}")
def _on_slider_change(self, key: str, value: int) -> None:
formatted = key.replace("_", " ").title()
self.status_label.setText(f"{formatted}{value}")
def _reset_sliders(self) -> None:
for _, key, _, _, initial in SLIDER_SPECS:
control = self._slider_controls.get(key)
if control:
control.set_value(initial)
self.status_label.setText("Sliders reset to defaults")
def _coming_soon(self) -> None:
QtWidgets.QMessageBox.information(
self,
"Coming soon",
"This action is not yet wired up in the PySide6 migration prototype.",
)