"""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("") 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 with native window controls.""" HEIGHT = 40 def __init__(self, window: "MainWindow") -> None: super().__init__(window) 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) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(12, 8, 12, 8) layout.setSpacing(8) logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png" if logo_path.exists(): pixmap = QtGui.QPixmap(str(logo_path)) logo_label = QtWidgets.QLabel() 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") self.min_btn.clicked.connect(window.showMinimized) layout.addWidget(self.min_btn) self.max_btn = self._create_button("❐", "Maximise / Restore") self.max_btn.clicked.connect(window.toggle_maximise) layout.addWidget(self.max_btn) close_btn = self._create_button("✕", "Close") close_btn.clicked.connect(window.close) close_btn.setStyleSheet( """ QPushButton { background: transparent; color: #f7f7fb; border: none; padding: 4px 10px; } QPushButton:hover { background-color: #d0342c; } """ ) layout.addWidget(close_btn) def _create_button(self, text: str, tooltip: str) -> QtWidgets.QPushButton: btn = QtWidgets.QPushButton(text) btn.setToolTip(tooltip) btn.setFixedSize(36, 24) btn.setCursor(QtCore.Qt.ArrowCursor) btn.setStyleSheet( """ QPushButton { background-color: transparent; color: #f7f7fb; border: none; padding: 4px 10px; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.1); } """ ) return btn def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None: if event.button() == QtCore.Qt.LeftButton: self.window.toggle_maximise() event.accept() def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: if event.button() == QtCore.Qt.LeftButton: self.window.start_system_move(event.globalPosition()) event.accept() super().mousePressEvent(event) class MainWindow(QtWidgets.QMainWindow): """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(1100, 680) container = QtWidgets.QWidget() container_layout = QtWidgets.QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) container_layout.setSpacing(0) self.title_bar = TitleBar(self) container_layout.addWidget(self.title_bar) self.content = QtWidgets.QWidget() self.content.setStyleSheet("background-color: #111216;") content_layout = QtWidgets.QVBoxLayout(self.content) content_layout.setContentsMargins(24, 24, 24, 24) content_layout.setSpacing(18) 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 ------------------------------------------------- def toggle_maximise(self) -> None: handle = self.windowHandle() if handle is None: return if self._is_maximised: self.showNormal() self._is_maximised = False self.title_bar.max_btn.setText("❐") else: self.showMaximized() self._is_maximised = True self.title_bar.max_btn.setText("⧉") def start_system_move(self, _global_position: QtCore.QPointF) -> None: handle = self.windowHandle() 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: filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select image", "", filters) if not path_str: return path = Path(path_str) pixmap = QtGui.QPixmap(str(path)) if pixmap.isNull(): 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.", )