diff --git a/app/qt/main_window.py b/app/qt/main_window.py index b591585..a5d62ec 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -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("") + 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("") - 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.", + )