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 dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List, Tuple
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):
"""Custom title bar mimicking modern Windows applications."""
"""Custom title bar with native window controls."""
HEIGHT = 40
@ -17,8 +186,8 @@ class TitleBar(QtWidgets.QWidget):
self.window = window
self.setFixedHeight(self.HEIGHT)
self.setCursor(QtCore.Qt.ArrowCursor)
self.setAutoFillBackground(True)
palette = self.palette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#16171d"))
self.setPalette(palette)
@ -31,13 +200,12 @@ class TitleBar(QtWidgets.QWidget):
if logo_path.exists():
pixmap = QtGui.QPixmap(str(logo_path))
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)
title_label = QtWidgets.QLabel("Interactive Color Range Analyzer")
title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;")
layout.addWidget(title_label)
layout.addStretch(1)
self.min_btn = self._create_button("", "Minimise window")
@ -52,7 +220,7 @@ class TitleBar(QtWidgets.QWidget):
close_btn.clicked.connect(window.close)
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; }
"""
)
@ -72,7 +240,7 @@ class TitleBar(QtWidgets.QWidget):
padding: 4px 10px;
}
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)
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):
"""Main application window containing custom chrome and content area."""
"""Main application window containing all controls."""
def __init__(self) -> None:
super().__init__()
self.setWindowFlag(QtCore.Qt.FramelessWindowHint)
self.setWindowFlag(QtCore.Qt.Window)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False)
self.setMinimumSize(900, 600)
self.setMinimumSize(1100, 680)
container = QtWidgets.QWidget()
container_layout = QtWidgets.QVBoxLayout(container)
@ -145,46 +282,22 @@ class MainWindow(QtWidgets.QMainWindow):
content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setSpacing(18)
toolbar = QtWidgets.QHBoxLayout()
toolbar.setSpacing(12)
self.open_button = QtWidgets.QPushButton("Open Image…")
self.open_button.setCursor(QtCore.Qt.PointingHandCursor)
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)
content_layout.addLayout(self._build_toolbar())
content_layout.addLayout(self._build_palette())
content_layout.addLayout(self._build_sliders())
content_layout.addWidget(self._build_previews(), 1)
content_layout.addLayout(self._build_status_section())
container_layout.addWidget(self.content, 1)
self.setCentralWidget(container)
self._is_maximised = False
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 -------------------------------------------------
@ -206,6 +319,166 @@ class MainWindow(QtWidgets.QMainWindow):
if handle:
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 ---------------------------------------------------------
def open_image(self) -> None:
@ -219,5 +492,34 @@ class MainWindow(QtWidgets.QMainWindow):
QtWidgets.QMessageBox.warning(self, "ICRA", "Unable to open the selected image.")
return
self.image_view.set_image(pixmap)
self.overlay_view.set_image(None)
self._current_image_path = path
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.",
)