"""Main PySide6 window emulating the legacy Tk interface with translations and themes.""" from __future__ import annotations from pathlib import Path from typing import Callable, Dict, List, Tuple from PySide6 import QtCore, QtGui, QtWidgets from app.i18n import I18nMixin from app.logic import SUPPORTED_IMAGE_EXTENSIONS from .image_processor import QtImageProcessor DEFAULT_COLOUR = "#763e92" PRESET_COLOURS: List[Tuple[str, str]] = [ ("palette.swatch.red", "#ff3b30"), ("palette.swatch.orange", "#ff9500"), ("palette.swatch.yellow", "#ffd60a"), ("palette.swatch.green", "#34c759"), ("palette.swatch.teal", "#5ac8fa"), ("palette.swatch.blue", "#0a84ff"), ("palette.swatch.violet", "#af52de"), ("palette.swatch.magenta", "#ff2d55"), ("palette.swatch.white", "#ffffff"), ("palette.swatch.grey", "#8e8e93"), ("palette.swatch.black", "#000000"), ] SLIDER_SPECS: List[Tuple[str, str, int, int]] = [ ("sliders.hue_min", "hue_min", 0, 360), ("sliders.hue_max", "hue_max", 0, 360), ("sliders.sat_min", "sat_min", 0, 100), ("sliders.val_min", "val_min", 0, 100), ("sliders.val_max", "val_max", 0, 100), ("sliders.alpha", "alpha", 0, 255), ] THEMES: Dict[str, Dict[str, str]] = { "dark": { "window_bg": "#111216", "panel_bg": "#16171d", "text": "#f7f7fb", "text_muted": "rgba(255,255,255,0.68)", "text_dim": "rgba(255,255,255,0.45)", "accent": "#5168ff", "accent_secondary": "#9a4dff", "titlebar_bg": "#16171d", "border": "rgba(255,255,255,0.08)", "highlight": "#e6b84b", }, "light": { "window_bg": "#f3f4fb", "panel_bg": "#ffffff", "text": "#1d1e24", "text_muted": "rgba(29,30,36,0.78)", "text_dim": "rgba(29,30,36,0.55)", "accent": "#5168ff", "accent_secondary": "#9a4dff", "titlebar_bg": "#e9ebf5", "border": "rgba(29,30,36,0.12)", "highlight": "#c56217", }, } 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) def apply_theme(self, colours: Dict[str, str]) -> None: self.setStyleSheet( f""" QPushButton {{ padding: 8px 16px; border-radius: 10px; border: 1px solid {colours['border']}; background-color: rgba(255, 255, 255, 0.04); color: {colours['text']}; font-weight: 600; }} QPushButton:hover {{ background-color: rgba(255, 255, 255, 0.12); }} QPushButton:pressed {{ background-color: rgba(255, 255, 255, 0.18); }} """ ) 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_key = name self.callback = callback self.setCursor(QtCore.Qt.PointingHandCursor) self.setFixedSize(28, 28) self._apply_colour(hex_code) self.clicked.connect(lambda: callback(hex_code, self.name_key)) def _apply_colour(self, hex_code: str) -> None: 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); }} """ ) def apply_theme(self, colours: Dict[str, str]) -> None: self.setStyleSheet( f""" QPushButton {{ background-color: {self.hex_code}; border: 2px solid {colours['border']}; border-radius: 6px; }} QPushButton:hover {{ border-color: {colours['accent']}; }} """ ) 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) self.title_label = QtWidgets.QLabel(title) header.addWidget(self.title_label) header.addStretch(1) self.value_label = QtWidgets.QLabel(str(initial)) 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)) def apply_theme(self, colours: Dict[str, str]) -> None: self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.value_label.setStyleSheet(f"color: {colours['text_dim']};") self.slider.setStyleSheet( f""" QSlider::groove:horizontal {{ border: 1px solid {colours['border']}; height: 6px; background: rgba(255,255,255,0.14); border-radius: 4px; }} QSlider::handle:horizontal {{ background: {colours['accent_secondary']}; border: 1px solid rgba(255,255,255,0.2); width: 14px; margin: -5px 0; border-radius: 7px; }} """ ) 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) 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)) self.logo_label = QtWidgets.QLabel() self.logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) layout.addWidget(self.logo_label) else: self.logo_label = None self.title_label = QtWidgets.QLabel() layout.addWidget(self.title_label) layout.addStretch(1) self.min_btn = self._create_button("–", "Minimise") 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) self.close_btn = self._create_button("✕", "Close") self.close_btn.clicked.connect(window.close) layout.addWidget(self.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 apply_theme(self, colours: Dict[str, str]) -> None: palette = self.palette() palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colours["titlebar_bg"])) self.setPalette(palette) self.title_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;") hover_bg = "#d0342c" if colours["titlebar_bg"] != "#e9ebf5" else "#e6675a" self.close_btn.setStyleSheet( f""" QPushButton {{ background-color: transparent; color: {colours['text']}; border: none; padding: 4px 10px; }} QPushButton:hover {{ background-color: {hover_bg}; color: #ffffff; }} """ ) for btn in (self.min_btn, self.max_btn): btn.setStyleSheet( f""" QPushButton {{ background-color: transparent; color: {colours['text']}; border: none; padding: 4px 10px; }} QPushButton:hover {{ background-color: rgba(0, 0, 0, 0.1); }} """ ) 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, I18nMixin): """Main application window containing all controls.""" def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None: super().__init__() self.init_i18n(language) self.setWindowTitle(self._t("app.title")) 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) self.title_bar.title_label.setText(self._t("app.title")) container_layout.addWidget(self.title_bar) self.content = QtWidgets.QWidget() self.content_layout = QtWidgets.QVBoxLayout(self.content) self.content_layout.setContentsMargins(24, 24, 24, 24) self.content_layout.setSpacing(18) self.content_layout.addLayout(self._build_toolbar()) self.content_layout.addLayout(self._build_palette()) self.content_layout.addLayout(self._build_sliders()) self.content_layout.addWidget(self._build_previews(), 1) self.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._register_default_actions() self.processor = QtImageProcessor() self.processor.set_defaults(defaults) self.processor.reset_exclusions_on_switch = reset_exclusions self._sync_sliders_from_processor() self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current")) self.current_theme = "dark" self._apply_theme(self.current_theme) # 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", "🖼", "toolbar.open_image"), ("open_folder", "📂", "toolbar.open_folder"), ("choose_color", "🎨", "toolbar.choose_color"), ("pick_from_image", "🖱", "toolbar.pick_from_image"), ("save_overlay", "💾", "toolbar.save_overlay"), ("toggle_free_draw", "△", "toolbar.toggle_free_draw"), ("clear_excludes", "🧹", "toolbar.clear_excludes"), ("undo_exclude", "↩", "toolbar.undo_exclude"), ("reset_sliders", "🔄", "toolbar.reset_sliders"), ("toggle_theme", "🌓", "toolbar.toggle_theme"), ] self._toolbar_buttons: Dict[str, ToolbarButton] = {} for key, icon_txt, text_key in buttons: label = self._t(text_key) 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(self._t("status.no_file")) 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) self.current_label = QtWidgets.QLabel(self._t("palette.current")) current_group.addWidget(self.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})") current_group.addWidget(self.current_colour_label) layout.addLayout(current_group) self.more_label = QtWidgets.QLabel(self._t("palette.more")) layout.addWidget(self.more_label) swatch_container = QtWidgets.QHBoxLayout() swatch_container.setSpacing(8) self.swatch_buttons: List[ColourSwatch] = [] for name_key, hex_code in PRESET_COLOURS: swatch = ColourSwatch(self._t(name_key), hex_code, self._update_colour_display) swatch_container.addWidget(swatch) self.swatch_buttons.append(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: Dict[str, SliderControl] = {} for key, attr, minimum, maximum in SLIDER_SPECS: initial = int(getattr(self.processor, attr)) control = SliderControl(self._t(key), attr, minimum, maximum, initial) control.value_changed.connect(self._on_slider_change) layout.addWidget(control) self._slider_controls[attr] = 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) 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.clicked.connect(lambda: self._invoke_action("show_previous_image")) layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter) self.image_view = QtWidgets.QLabel("") self.image_view.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(self.image_view, 0, 1) self.overlay_view = QtWidgets.QLabel("") self.overlay_view.setAlignment(QtCore.Qt.AlignCenter) 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.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) layout.addWidget(self.filename_label) self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder")) self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) 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.open_folder, "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.toggle_theme, "show_previous_image": self.show_previous_image, "show_next_image": self.show_next_image, } 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, self._t("dialog.open_image_title"), "", filters) if not path_str: return path = Path(path_str) try: self.processor.load_single_image(path) except Exception as exc: # pragma: no cover - user feedback QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc)) return self._current_image_path = path self._refresh_views() def open_folder(self) -> None: directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title")) if not directory: return folder = Path(directory) paths = sorted( (p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()), key=lambda p: p.name.lower(), ) if not paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.folder_empty")) return try: self.processor.load_folder(paths) except ValueError as exc: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc)) return self._current_image_path = paths[0] self._refresh_views() def show_previous_image(self) -> None: if not self.processor.preview_paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) return self.processor.previous_image() self._current_image_path = self.processor.preview_paths[self.processor.current_index] self._refresh_views() def show_next_image(self) -> None: if not self.processor.preview_paths: QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) return self.processor.next_image() self._current_image_path = self.processor.preview_paths[self.processor.current_index] self._refresh_views() # 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: self.processor.set_threshold(key, value) formatted = self._t("status.defaults_restored") self.status_label.setText(f"{formatted} ({key} → {value})") self._refresh_overlay_only() def _reset_sliders(self) -> None: for _, attr, _, _ in SLIDER_SPECS: control = self._slider_controls.get(attr) if control: default_value = int(getattr(self.processor, attr)) control.set_value(default_value) self.status_label.setText(self._t("status.defaults_restored")) self._refresh_overlay_only() def _coming_soon(self) -> None: QtWidgets.QMessageBox.information( self, self._t("dialog.info_title"), "Feature coming soon in the PySide6 migration.", ) def toggle_theme(self) -> None: self.current_theme = "light" if self.current_theme == "dark" else "dark" self._apply_theme(self.current_theme) def _apply_theme(self, mode: str) -> None: colours = THEMES[mode] self.content.setStyleSheet(f"background-color: {colours['window_bg']};") self.image_view.setStyleSheet(f"border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};") self.overlay_view.setStyleSheet(f"border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};") self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.current_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.current_colour_label.setStyleSheet(f"color: {colours['text_dim']};") self.more_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") self.filename_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;") self.ratio_label.setStyleSheet(f"color: {colours['highlight']}; font-weight: 600;") for button in self._toolbar_buttons.values(): button.apply_theme(colours) for swatch in self.swatch_buttons: swatch.apply_theme(colours) for control in self._slider_controls.values(): control.apply_theme(colours) style_button = ( f"QToolButton {{ border-radius: 22px; background-color: {colours['panel_bg']}; " f"border: 1px solid {colours['border']}; color: {colours['text']}; }}" f"QToolButton:hover {{ background-color: {colours['accent_secondary']}; color: white; }}" ) self.prev_button.setStyleSheet(style_button) self.next_button.setStyleSheet(style_button) self.title_bar.apply_theme(colours) def _sync_sliders_from_processor(self) -> None: for _, attr, _, _ in SLIDER_SPECS: control = self._slider_controls.get(attr) if control: control.set_value(int(getattr(self.processor, attr))) def _refresh_views(self) -> None: preview_pix = self.processor.preview_pixmap() overlay_pix = self.processor.overlay_pixmap() self.image_view.setPixmap(preview_pix) self.overlay_view.setPixmap(overlay_pix) if self._current_image_path and self.processor.preview_img: width, height = self.processor.preview_img.size total = len(self.processor.preview_paths) position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else "" dimensions = f"{width}×{height}" self.status_label.setText( self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) ) self.filename_label.setText( self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position) ) self.ratio_label.setText(self.processor.stats.summary(self._t)) def _refresh_overlay_only(self) -> None: if self.processor.preview_img is None: return self.overlay_view.setPixmap(self.processor.overlay_pixmap()) self.ratio_label.setText(self.processor.stats.summary(self._t))