diff --git a/app/qt/image_processor.py b/app/qt/image_processor.py index 571d612..f0a506e 100644 --- a/app/qt/image_processor.py +++ b/app/qt/image_processor.py @@ -5,7 +5,7 @@ from __future__ import annotations import colorsys from dataclasses import dataclass from pathlib import Path -from typing import Iterable, Tuple +from typing import Dict, Iterable, Tuple from PIL import Image, ImageDraw from PySide6 import QtGui @@ -20,15 +20,12 @@ class Stats: matches_keep: int = 0 total_keep: int = 0 - def summary(self) -> str: + def summary(self, translate) -> str: if self.total_all == 0: - return "Matches with exclusions: —" + return translate("stats.placeholder") with_pct = (self.matches_keep / self.total_keep * 100) if self.total_keep else 0.0 without_pct = (self.matches_all / self.total_all * 100) if self.total_all else 0.0 - return ( - f"Matches with exclusions: {with_pct:.1f}% · " - f"Matches overall: {without_pct:.1f}%" - ) + return translate("stats.summary", with_pct=with_pct, without_pct=without_pct, excluded_pct=0.0, excluded_match_pct=0.0) class QtImageProcessor: @@ -42,16 +39,30 @@ class QtImageProcessor: self.current_index: int = -1 self.stats = Stats() - # HSV thresholds and overlay alpha - self.hue_min = 0 - self.hue_max = 360 - self.sat_min = 25 - self.val_min = 15 - self.val_max = 100 - self.alpha = 120 + self.defaults: Dict[str, int] = { + "hue_min": 0, + "hue_max": 360, + "sat_min": 25, + "val_min": 15, + "val_max": 100, + "alpha": 120, + } + self.hue_min = self.defaults["hue_min"] + self.hue_max = self.defaults["hue_max"] + self.sat_min = self.defaults["sat_min"] + self.val_min = self.defaults["val_min"] + self.val_max = self.defaults["val_max"] + self.alpha = self.defaults["alpha"] self.exclude_shapes: list[dict[str, object]] = [] + def set_defaults(self, defaults: dict) -> None: + for key in self.defaults: + if key in defaults: + self.defaults[key] = int(defaults[key]) + for key, value in self.defaults.items(): + setattr(self, key, value) + # thresholds ------------------------------------------------------------- def set_threshold(self, key: str, value: int) -> None: diff --git a/app/qt/main_window.py b/app/qt/main_window.py index eea37fa..39b0d48 100644 --- a/app/qt/main_window.py +++ b/app/qt/main_window.py @@ -1,4 +1,4 @@ -"""Main PySide6 window emulating the legacy Tk interface.""" +"""Main PySide6 window emulating the legacy Tk interface with translations and themes.""" from __future__ import annotations @@ -7,33 +7,62 @@ 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]] = [ - ("Red", "#ff3b30"), - ("Orange", "#ff9500"), - ("Yellow", "#ffd60a"), - ("Green", "#34c759"), - ("Teal", "#5ac8fa"), - ("Blue", "#0a84ff"), - ("Violet", "#af52de"), - ("Magenta", "#ff2d55"), - ("White", "#ffffff"), - ("Grey", "#8e8e93"), - ("Black", "#000000"), + ("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, 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), +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.""" @@ -62,6 +91,26 @@ class ToolbarButton(QtWidgets.QPushButton): ) 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.""" @@ -69,10 +118,14 @@ class ColourSwatch(QtWidgets.QPushButton): 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.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 {{ @@ -85,7 +138,20 @@ class ColourSwatch(QtWidgets.QPushButton): }} """ ) - self.clicked.connect(lambda: callback(hex_code, name)) + + 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): @@ -103,12 +169,10 @@ class SliderControl(QtWidgets.QWidget): 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) + self.title_label = QtWidgets.QLabel(title) + header.addWidget(self.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) @@ -146,36 +210,26 @@ class SliderControl(QtWidgets.QWidget): 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, + 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; + }} + """ ) - self.setPixmap(target) - self.setStyleSheet("") class TitleBar(QtWidgets.QWidget): @@ -190,10 +244,6 @@ class TitleBar(QtWidgets.QWidget): 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) @@ -201,16 +251,17 @@ class TitleBar(QtWidgets.QWidget): 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) + 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 - title_label = QtWidgets.QLabel("Interactive Color Range Analyzer") - title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;") - layout.addWidget(title_label) + self.title_label = QtWidgets.QLabel() + layout.addWidget(self.title_label) layout.addStretch(1) - self.min_btn = self._create_button("–", "Minimise window") + self.min_btn = self._create_button("–", "Minimise") self.min_btn.clicked.connect(window.showMinimized) layout.addWidget(self.min_btn) @@ -218,15 +269,9 @@ class TitleBar(QtWidgets.QWidget): 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) + 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) @@ -248,6 +293,41 @@ class TitleBar(QtWidgets.QWidget): ) 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() @@ -260,11 +340,13 @@ class TitleBar(QtWidgets.QWidget): super().mousePressEvent(event) -class MainWindow(QtWidgets.QMainWindow): +class MainWindow(QtWidgets.QMainWindow, I18nMixin): """Main application window containing all controls.""" - def __init__(self) -> None: + 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) @@ -276,19 +358,19 @@ class MainWindow(QtWidgets.QMainWindow): 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.setStyleSheet("background-color: #111216;") - content_layout = QtWidgets.QVBoxLayout(self.content) - content_layout.setContentsMargins(24, 24, 24, 24) - content_layout.setSpacing(18) + self.content_layout = QtWidgets.QVBoxLayout(self.content) + self.content_layout.setContentsMargins(24, 24, 24, 24) + self.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()) + 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) @@ -297,11 +379,16 @@ class MainWindow(QtWidgets.QMainWindow): 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") 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 ------------------------------------------------- @@ -330,26 +417,26 @@ class MainWindow(QtWidgets.QMainWindow): 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"), + ("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, label in buttons: + 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("No image loaded") - self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;") + self.status_label = QtWidgets.QLabel(self._t("status.no_file")) layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight) return layout @@ -360,9 +447,8 @@ class MainWindow(QtWidgets.QMainWindow): 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_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) @@ -370,19 +456,19 @@ class MainWindow(QtWidgets.QMainWindow): 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) + self.more_label = QtWidgets.QLabel(self._t("palette.more")) + layout.addWidget(self.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) + 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 @@ -390,12 +476,13 @@ class MainWindow(QtWidgets.QMainWindow): 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) + 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[key] = control + self._slider_controls[attr] = control return layout def _build_previews(self) -> QtWidgets.QWidget: @@ -403,41 +490,26 @@ class MainWindow(QtWidgets.QMainWindow): 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;") + self.image_view = QtWidgets.QLabel("") + self.image_view.setAlignment(QtCore.Qt.AlignCenter) 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;") + 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.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) @@ -451,12 +523,10 @@ class MainWindow(QtWidgets.QMainWindow): 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 = QtWidgets.QLabel(self._t("stats.placeholder")) self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) - self.ratio_label.setStyleSheet("color: #e6b84b; font-weight: 600;") layout.addWidget(self.ratio_label) return layout @@ -473,7 +543,7 @@ class MainWindow(QtWidgets.QMainWindow): "clear_excludes": self._coming_soon, "undo_exclude": self._coming_soon, "reset_sliders": self._reset_sliders, - "toggle_theme": self._coming_soon, + "toggle_theme": self.toggle_theme, "show_previous_image": self.show_previous_image, "show_next_image": self.show_next_image, } @@ -487,20 +557,20 @@ class MainWindow(QtWidgets.QMainWindow): def open_image(self) -> None: filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" - path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select image", "", filters) + 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: - QtWidgets.QMessageBox.warning(self, "ICRA", f"Unable to open the selected image.\n{exc}") + 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, "Select image folder") + directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title")) if not directory: return folder = Path(directory) @@ -509,19 +579,19 @@ class MainWindow(QtWidgets.QMainWindow): key=lambda p: p.name.lower(), ) if not paths: - QtWidgets.QMessageBox.information(self, "ICRA", "No supported image files found in the selected folder.") + 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, "ICRA", str(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: - self._coming_soon() + 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] @@ -529,7 +599,7 @@ class MainWindow(QtWidgets.QMainWindow): def show_next_image(self) -> None: if not self.processor.preview_paths: - self._coming_soon() + 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] @@ -544,46 +614,87 @@ class MainWindow(QtWidgets.QMainWindow): self.status_label.setText(f"{label}: {hex_code}") def _on_slider_change(self, key: str, value: int) -> None: - formatted = key.replace("_", " ").title() self.processor.set_threshold(key, value) - self.status_label.setText(f"{formatted} → {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 _, key, _, _, initial in SLIDER_SPECS: - control = self._slider_controls.get(key) + for _, attr, _, _ in SLIDER_SPECS: + control = self._slider_controls.get(attr) if control: - control.set_value(initial) - self.processor.set_threshold(key, initial) - self.status_label.setText("Sliders reset to defaults") + 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, - "Coming soon", - "This action is not yet wired up in the PySide6 migration prototype.", + self._t("dialog.info_title"), + "Feature coming soon in the PySide6 migration.", ) - # view refresh ----------------------------------------------------------- + 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.set_image(preview_pix) - self.overlay_view.set_image(overlay_pix) + 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( - f"{self._current_image_path.name} · {width}×{height}" + self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) ) self.filename_label.setText( - f"{self._current_image_path.name} ({width}×{height})" + self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position) ) - self.ratio_label.setText(self.processor.stats.summary()) + 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.set_image(self.processor.overlay_pixmap()) - self.ratio_label.setText(self.processor.stats.summary()) + self.overlay_view.setPixmap(self.processor.overlay_pixmap()) + self.ratio_label.setText(self.processor.stats.summary(self._t))