Reintroduce translations and theme handling

This commit is contained in:
lm 2025-10-19 19:31:50 +02:00
parent 91bdf37512
commit 213b35bf20
2 changed files with 298 additions and 176 deletions

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import colorsys import colorsys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Iterable, Tuple from typing import Dict, Iterable, Tuple
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from PySide6 import QtGui from PySide6 import QtGui
@ -20,15 +20,12 @@ class Stats:
matches_keep: int = 0 matches_keep: int = 0
total_keep: int = 0 total_keep: int = 0
def summary(self) -> str: def summary(self, translate) -> str:
if self.total_all == 0: 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 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 without_pct = (self.matches_all / self.total_all * 100) if self.total_all else 0.0
return ( return translate("stats.summary", with_pct=with_pct, without_pct=without_pct, excluded_pct=0.0, excluded_match_pct=0.0)
f"Matches with exclusions: {with_pct:.1f}% · "
f"Matches overall: {without_pct:.1f}%"
)
class QtImageProcessor: class QtImageProcessor:
@ -42,16 +39,30 @@ class QtImageProcessor:
self.current_index: int = -1 self.current_index: int = -1
self.stats = Stats() self.stats = Stats()
# HSV thresholds and overlay alpha self.defaults: Dict[str, int] = {
self.hue_min = 0 "hue_min": 0,
self.hue_max = 360 "hue_max": 360,
self.sat_min = 25 "sat_min": 25,
self.val_min = 15 "val_min": 15,
self.val_max = 100 "val_max": 100,
self.alpha = 120 "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]] = [] 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 ------------------------------------------------------------- # thresholds -------------------------------------------------------------
def set_threshold(self, key: str, value: int) -> None: def set_threshold(self, key: str, value: int) -> None:

View File

@ -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 from __future__ import annotations
@ -7,33 +7,62 @@ from typing import Callable, Dict, List, Tuple
from PySide6 import QtCore, QtGui, QtWidgets from PySide6 import QtCore, QtGui, QtWidgets
from app.i18n import I18nMixin
from app.logic import SUPPORTED_IMAGE_EXTENSIONS from app.logic import SUPPORTED_IMAGE_EXTENSIONS
from .image_processor import QtImageProcessor from .image_processor import QtImageProcessor
DEFAULT_COLOUR = "#763e92" DEFAULT_COLOUR = "#763e92"
PRESET_COLOURS: List[Tuple[str, str]] = [ PRESET_COLOURS: List[Tuple[str, str]] = [
("Red", "#ff3b30"), ("palette.swatch.red", "#ff3b30"),
("Orange", "#ff9500"), ("palette.swatch.orange", "#ff9500"),
("Yellow", "#ffd60a"), ("palette.swatch.yellow", "#ffd60a"),
("Green", "#34c759"), ("palette.swatch.green", "#34c759"),
("Teal", "#5ac8fa"), ("palette.swatch.teal", "#5ac8fa"),
("Blue", "#0a84ff"), ("palette.swatch.blue", "#0a84ff"),
("Violet", "#af52de"), ("palette.swatch.violet", "#af52de"),
("Magenta", "#ff2d55"), ("palette.swatch.magenta", "#ff2d55"),
("White", "#ffffff"), ("palette.swatch.white", "#ffffff"),
("Grey", "#8e8e93"), ("palette.swatch.grey", "#8e8e93"),
("Black", "#000000"), ("palette.swatch.black", "#000000"),
] ]
SLIDER_SPECS: List[Tuple[str, str, int, int, int]] = [ SLIDER_SPECS: List[Tuple[str, str, int, int]] = [
("Hue min", "hue_min", 0, 360, 0), ("sliders.hue_min", "hue_min", 0, 360),
("Hue max", "hue_max", 0, 360, 360), ("sliders.hue_max", "hue_max", 0, 360),
("Sat min", "sat_min", 0, 100, 25), ("sliders.sat_min", "sat_min", 0, 100),
("Value min", "val_min", 0, 100, 15), ("sliders.val_min", "val_min", 0, 100),
("Value max", "val_max", 0, 100, 100), ("sliders.val_max", "val_max", 0, 100),
("Overlay alpha", "alpha", 0, 255, 120), ("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): class ToolbarButton(QtWidgets.QPushButton):
"""Rounded toolbar button inspired by the legacy design.""" """Rounded toolbar button inspired by the legacy design."""
@ -62,6 +91,26 @@ class ToolbarButton(QtWidgets.QPushButton):
) )
self.clicked.connect(callback) 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): class ColourSwatch(QtWidgets.QPushButton):
"""Clickable palette swatch.""" """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): def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None):
super().__init__(parent) super().__init__(parent)
self.hex_code = hex_code self.hex_code = hex_code
self.name = name self.name_key = name
self.setToolTip(f"{name} ({hex_code})") self.callback = callback
self.setCursor(QtCore.Qt.PointingHandCursor) self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedSize(28, 28) 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( self.setStyleSheet(
f""" f"""
QPushButton {{ 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): class SliderControl(QtWidgets.QWidget):
@ -103,12 +169,10 @@ class SliderControl(QtWidgets.QWidget):
header = QtWidgets.QHBoxLayout() header = QtWidgets.QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0) header.setContentsMargins(0, 0, 0, 0)
title_label = QtWidgets.QLabel(title) self.title_label = QtWidgets.QLabel(title)
title_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 500;") header.addWidget(self.title_label)
header.addWidget(title_label)
header.addStretch(1) header.addStretch(1)
self.value_label = QtWidgets.QLabel(str(initial)) self.value_label = QtWidgets.QLabel(str(initial))
self.value_label.setStyleSheet("color: rgba(255,255,255,0.6);")
header.addWidget(self.value_label) header.addWidget(self.value_label)
layout.addLayout(header) layout.addLayout(header)
@ -146,36 +210,26 @@ class SliderControl(QtWidgets.QWidget):
self.slider.blockSignals(False) self.slider.blockSignals(False)
self.value_label.setText(str(value)) self.value_label.setText(str(value))
def apply_theme(self, colours: Dict[str, str]) -> None:
class ImageView(QtWidgets.QLabel): self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
"""Aspect-ratio aware image view.""" self.value_label.setStyleSheet(f"color: {colours['text_dim']};")
self.slider.setStyleSheet(
def __init__(self) -> None: f"""
super().__init__() QSlider::groove:horizontal {{
self.setAlignment(QtCore.Qt.AlignCenter) border: 1px solid {colours['border']};
self._pixmap: QtGui.QPixmap | None = None height: 6px;
background: rgba(255,255,255,0.14);
def set_image(self, pixmap: QtGui.QPixmap | None) -> None: border-radius: 4px;
self._pixmap = pixmap }}
self._rescale() QSlider::handle:horizontal {{
background: {colours['accent_secondary']};
def resizeEvent(self, event: QtGui.QResizeEvent) -> None: border: 1px solid rgba(255,255,255,0.2);
super().resizeEvent(event) width: 14px;
self._rescale() margin: -5px 0;
border-radius: 7px;
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): class TitleBar(QtWidgets.QWidget):
@ -190,10 +244,6 @@ class TitleBar(QtWidgets.QWidget):
self.setCursor(QtCore.Qt.ArrowCursor) self.setCursor(QtCore.Qt.ArrowCursor)
self.setAutoFillBackground(True) self.setAutoFillBackground(True)
palette = self.palette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#16171d"))
self.setPalette(palette)
layout = QtWidgets.QHBoxLayout(self) layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8) layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(8) layout.setSpacing(8)
@ -201,16 +251,17 @@ class TitleBar(QtWidgets.QWidget):
logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png" logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png"
if logo_path.exists(): if logo_path.exists():
pixmap = QtGui.QPixmap(str(logo_path)) pixmap = QtGui.QPixmap(str(logo_path))
logo_label = QtWidgets.QLabel() self.logo_label = QtWidgets.QLabel()
logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) self.logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
layout.addWidget(logo_label) layout.addWidget(self.logo_label)
else:
self.logo_label = None
title_label = QtWidgets.QLabel("Interactive Color Range Analyzer") self.title_label = QtWidgets.QLabel()
title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;") layout.addWidget(self.title_label)
layout.addWidget(title_label)
layout.addStretch(1) 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) self.min_btn.clicked.connect(window.showMinimized)
layout.addWidget(self.min_btn) layout.addWidget(self.min_btn)
@ -218,15 +269,9 @@ class TitleBar(QtWidgets.QWidget):
self.max_btn.clicked.connect(window.toggle_maximise) self.max_btn.clicked.connect(window.toggle_maximise)
layout.addWidget(self.max_btn) layout.addWidget(self.max_btn)
close_btn = self._create_button("", "Close") self.close_btn = self._create_button("", "Close")
close_btn.clicked.connect(window.close) self.close_btn.clicked.connect(window.close)
close_btn.setStyleSheet( layout.addWidget(self.close_btn)
"""
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: def _create_button(self, text: str, tooltip: str) -> QtWidgets.QPushButton:
btn = QtWidgets.QPushButton(text) btn = QtWidgets.QPushButton(text)
@ -248,6 +293,41 @@ class TitleBar(QtWidgets.QWidget):
) )
return btn 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: def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None:
if event.button() == QtCore.Qt.LeftButton: if event.button() == QtCore.Qt.LeftButton:
self.window.toggle_maximise() self.window.toggle_maximise()
@ -260,11 +340,13 @@ class TitleBar(QtWidgets.QWidget):
super().mousePressEvent(event) super().mousePressEvent(event)
class MainWindow(QtWidgets.QMainWindow): class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"""Main application window containing all controls.""" """Main application window containing all controls."""
def __init__(self) -> None: def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None:
super().__init__() super().__init__()
self.init_i18n(language)
self.setWindowTitle(self._t("app.title"))
self.setWindowFlag(QtCore.Qt.FramelessWindowHint) self.setWindowFlag(QtCore.Qt.FramelessWindowHint)
self.setWindowFlag(QtCore.Qt.Window) self.setWindowFlag(QtCore.Qt.Window)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False) self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False)
@ -276,19 +358,19 @@ class MainWindow(QtWidgets.QMainWindow):
container_layout.setSpacing(0) container_layout.setSpacing(0)
self.title_bar = TitleBar(self) self.title_bar = TitleBar(self)
self.title_bar.title_label.setText(self._t("app.title"))
container_layout.addWidget(self.title_bar) container_layout.addWidget(self.title_bar)
self.content = QtWidgets.QWidget() self.content = QtWidgets.QWidget()
self.content.setStyleSheet("background-color: #111216;") self.content_layout = QtWidgets.QVBoxLayout(self.content)
content_layout = QtWidgets.QVBoxLayout(self.content) self.content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setContentsMargins(24, 24, 24, 24) self.content_layout.setSpacing(18)
content_layout.setSpacing(18)
content_layout.addLayout(self._build_toolbar()) self.content_layout.addLayout(self._build_toolbar())
content_layout.addLayout(self._build_palette()) self.content_layout.addLayout(self._build_palette())
content_layout.addLayout(self._build_sliders()) self.content_layout.addLayout(self._build_sliders())
content_layout.addWidget(self._build_previews(), 1) self.content_layout.addWidget(self._build_previews(), 1)
content_layout.addLayout(self._build_status_section()) self.content_layout.addLayout(self._build_status_section())
container_layout.addWidget(self.content, 1) container_layout.addWidget(self.content, 1)
self.setCentralWidget(container) self.setCentralWidget(container)
@ -297,11 +379,16 @@ class MainWindow(QtWidgets.QMainWindow):
self._current_image_path: Path | None = None self._current_image_path: Path | None = None
self._current_colour = DEFAULT_COLOUR self._current_colour = DEFAULT_COLOUR
self._toolbar_actions: Dict[str, Callable[[], None]] = {} self._toolbar_actions: Dict[str, Callable[[], None]] = {}
self._slider_controls: Dict[str, SliderControl] = {}
self._register_default_actions() self._register_default_actions()
self._update_colour_display(DEFAULT_COLOUR, "Default colour")
self.processor = QtImageProcessor() 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 ------------------------------------------------- # Window control helpers -------------------------------------------------
@ -330,26 +417,26 @@ class MainWindow(QtWidgets.QMainWindow):
layout.setSpacing(12) layout.setSpacing(12)
buttons = [ buttons = [
("open_image", "🖼", "Open image…"), ("open_image", "🖼", "toolbar.open_image"),
("open_folder", "📂", "Open folder…"), ("open_folder", "📂", "toolbar.open_folder"),
("choose_color", "🎨", "Choose colour"), ("choose_color", "🎨", "toolbar.choose_color"),
("pick_from_image", "🖱", "Pick from image"), ("pick_from_image", "🖱", "toolbar.pick_from_image"),
("save_overlay", "💾", "Save overlay"), ("save_overlay", "💾", "toolbar.save_overlay"),
("toggle_free_draw", "", "Toggle free-draw"), ("toggle_free_draw", "", "toolbar.toggle_free_draw"),
("clear_excludes", "🧹", "Clear exclusions"), ("clear_excludes", "🧹", "toolbar.clear_excludes"),
("undo_exclude", "", "Undo last exclusion"), ("undo_exclude", "", "toolbar.undo_exclude"),
("reset_sliders", "🔄", "Reset sliders"), ("reset_sliders", "🔄", "toolbar.reset_sliders"),
("toggle_theme", "🌓", "Toggle theme"), ("toggle_theme", "🌓", "toolbar.toggle_theme"),
] ]
self._toolbar_buttons: Dict[str, ToolbarButton] = {} 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)) button = ToolbarButton(icon_txt, label, lambda k=key: self._invoke_action(k))
layout.addWidget(button) layout.addWidget(button)
self._toolbar_buttons[key] = button self._toolbar_buttons[key] = button
layout.addStretch(1) layout.addStretch(1)
self.status_label = QtWidgets.QLabel("No image loaded") self.status_label = QtWidgets.QLabel(self._t("status.no_file"))
self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;")
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight) layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout return layout
@ -360,9 +447,8 @@ class MainWindow(QtWidgets.QMainWindow):
current_group = QtWidgets.QHBoxLayout() current_group = QtWidgets.QHBoxLayout()
current_group.setSpacing(8) current_group.setSpacing(8)
current_label = QtWidgets.QLabel("Current colour") self.current_label = QtWidgets.QLabel(self._t("palette.current"))
current_label.setStyleSheet("color: rgba(255,255,255,0.75); font-weight: 500;") current_group.addWidget(self.current_label)
current_group.addWidget(current_label)
self.current_colour_swatch = QtWidgets.QLabel() self.current_colour_swatch = QtWidgets.QLabel()
self.current_colour_swatch.setFixedSize(28, 28) self.current_colour_swatch.setFixedSize(28, 28)
@ -370,19 +456,19 @@ class MainWindow(QtWidgets.QMainWindow):
current_group.addWidget(self.current_colour_swatch) current_group.addWidget(self.current_colour_swatch)
self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})") 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) current_group.addWidget(self.current_colour_label)
layout.addLayout(current_group) layout.addLayout(current_group)
more_label = QtWidgets.QLabel("More colours") self.more_label = QtWidgets.QLabel(self._t("palette.more"))
more_label.setStyleSheet("color: rgba(255,255,255,0.65); font-weight: 500;") layout.addWidget(self.more_label)
layout.addWidget(more_label)
swatch_container = QtWidgets.QHBoxLayout() swatch_container = QtWidgets.QHBoxLayout()
swatch_container.setSpacing(8) swatch_container.setSpacing(8)
for name, hex_code in PRESET_COLOURS: self.swatch_buttons: List[ColourSwatch] = []
swatch = ColourSwatch(name, hex_code, self._update_colour_display) for name_key, hex_code in PRESET_COLOURS:
swatch = ColourSwatch(self._t(name_key), hex_code, self._update_colour_display)
swatch_container.addWidget(swatch) swatch_container.addWidget(swatch)
self.swatch_buttons.append(swatch)
layout.addLayout(swatch_container) layout.addLayout(swatch_container)
layout.addStretch(1) layout.addStretch(1)
return layout return layout
@ -390,12 +476,13 @@ class MainWindow(QtWidgets.QMainWindow):
def _build_sliders(self) -> QtWidgets.QHBoxLayout: def _build_sliders(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout() layout = QtWidgets.QHBoxLayout()
layout.setSpacing(16) layout.setSpacing(16)
self._slider_controls.clear() self._slider_controls: Dict[str, SliderControl] = {}
for title, key, minimum, maximum, initial in SLIDER_SPECS: for key, attr, minimum, maximum in SLIDER_SPECS:
control = SliderControl(title, key, minimum, maximum, initial) initial = int(getattr(self.processor, attr))
control = SliderControl(self._t(key), attr, minimum, maximum, initial)
control.value_changed.connect(self._on_slider_change) control.value_changed.connect(self._on_slider_change)
layout.addWidget(control) layout.addWidget(control)
self._slider_controls[key] = control self._slider_controls[attr] = control
return layout return layout
def _build_previews(self) -> QtWidgets.QWidget: def _build_previews(self) -> QtWidgets.QWidget:
@ -403,41 +490,26 @@ class MainWindow(QtWidgets.QMainWindow):
layout = QtWidgets.QGridLayout(container) layout = QtWidgets.QGridLayout(container)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setHorizontalSpacing(16) layout.setHorizontalSpacing(16)
layout.setVerticalSpacing(0)
self.prev_button = QtWidgets.QToolButton() self.prev_button = QtWidgets.QToolButton()
self.prev_button.setText("") self.prev_button.setText("")
self.prev_button.setCursor(QtCore.Qt.PointingHandCursor) self.prev_button.setCursor(QtCore.Qt.PointingHandCursor)
self.prev_button.setFixedSize(44, 44) 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")) self.prev_button.clicked.connect(lambda: self._invoke_action("show_previous_image"))
layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter) layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter)
self.image_view = ImageView() self.image_view = QtWidgets.QLabel("<No image loaded>")
self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;") self.image_view.setAlignment(QtCore.Qt.AlignCenter)
layout.addWidget(self.image_view, 0, 1) layout.addWidget(self.image_view, 0, 1)
self.overlay_view = ImageView() self.overlay_view = QtWidgets.QLabel("<No image loaded>")
self.overlay_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;") self.overlay_view.setAlignment(QtCore.Qt.AlignCenter)
layout.addWidget(self.overlay_view, 0, 2) layout.addWidget(self.overlay_view, 0, 2)
self.next_button = QtWidgets.QToolButton() self.next_button = QtWidgets.QToolButton()
self.next_button.setText("") self.next_button.setText("")
self.next_button.setCursor(QtCore.Qt.PointingHandCursor) self.next_button.setCursor(QtCore.Qt.PointingHandCursor)
self.next_button.setFixedSize(44, 44) 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")) self.next_button.clicked.connect(lambda: self._invoke_action("show_next_image"))
layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter) layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter)
layout.setColumnStretch(1, 1) layout.setColumnStretch(1, 1)
@ -451,12 +523,10 @@ class MainWindow(QtWidgets.QMainWindow):
self.filename_label = QtWidgets.QLabel("") self.filename_label = QtWidgets.QLabel("")
self.filename_label.setAlignment(QtCore.Qt.AlignCenter) 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) 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.setAlignment(QtCore.Qt.AlignCenter)
self.ratio_label.setStyleSheet("color: #e6b84b; font-weight: 600;")
layout.addWidget(self.ratio_label) layout.addWidget(self.ratio_label)
return layout return layout
@ -473,7 +543,7 @@ class MainWindow(QtWidgets.QMainWindow):
"clear_excludes": self._coming_soon, "clear_excludes": self._coming_soon,
"undo_exclude": self._coming_soon, "undo_exclude": self._coming_soon,
"reset_sliders": self._reset_sliders, "reset_sliders": self._reset_sliders,
"toggle_theme": self._coming_soon, "toggle_theme": self.toggle_theme,
"show_previous_image": self.show_previous_image, "show_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image, "show_next_image": self.show_next_image,
} }
@ -487,20 +557,20 @@ class MainWindow(QtWidgets.QMainWindow):
def open_image(self) -> None: def open_image(self) -> None:
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" 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: if not path_str:
return return
path = Path(path_str) path = Path(path_str)
try: try:
self.processor.load_single_image(path) self.processor.load_single_image(path)
except Exception as exc: except Exception as exc: # pragma: no cover - user feedback
QtWidgets.QMessageBox.warning(self, "ICRA", f"Unable to open the selected image.\n{exc}") QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc))
return return
self._current_image_path = path self._current_image_path = path
self._refresh_views() self._refresh_views()
def open_folder(self) -> None: 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: if not directory:
return return
folder = Path(directory) folder = Path(directory)
@ -509,19 +579,19 @@ class MainWindow(QtWidgets.QMainWindow):
key=lambda p: p.name.lower(), key=lambda p: p.name.lower(),
) )
if not paths: 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 return
try: try:
self.processor.load_folder(paths) self.processor.load_folder(paths)
except ValueError as exc: except ValueError as exc:
QtWidgets.QMessageBox.information(self, "ICRA", str(exc)) QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc))
return return
self._current_image_path = paths[0] self._current_image_path = paths[0]
self._refresh_views() self._refresh_views()
def show_previous_image(self) -> None: def show_previous_image(self) -> None:
if not self.processor.preview_paths: 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 return
self.processor.previous_image() self.processor.previous_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index] 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: def show_next_image(self) -> None:
if not self.processor.preview_paths: 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 return
self.processor.next_image() self.processor.next_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index] 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}") self.status_label.setText(f"{label}: {hex_code}")
def _on_slider_change(self, key: str, value: int) -> None: def _on_slider_change(self, key: str, value: int) -> None:
formatted = key.replace("_", " ").title()
self.processor.set_threshold(key, value) 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() self._refresh_overlay_only()
def _reset_sliders(self) -> None: def _reset_sliders(self) -> None:
for _, key, _, _, initial in SLIDER_SPECS: for _, attr, _, _ in SLIDER_SPECS:
control = self._slider_controls.get(key) control = self._slider_controls.get(attr)
if control: if control:
control.set_value(initial) default_value = int(getattr(self.processor, attr))
self.processor.set_threshold(key, initial) control.set_value(default_value)
self.status_label.setText("Sliders reset to defaults") self.status_label.setText(self._t("status.defaults_restored"))
self._refresh_overlay_only() self._refresh_overlay_only()
def _coming_soon(self) -> None: def _coming_soon(self) -> None:
QtWidgets.QMessageBox.information( QtWidgets.QMessageBox.information(
self, self,
"Coming soon", self._t("dialog.info_title"),
"This action is not yet wired up in the PySide6 migration prototype.", "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: def _refresh_views(self) -> None:
preview_pix = self.processor.preview_pixmap() preview_pix = self.processor.preview_pixmap()
overlay_pix = self.processor.overlay_pixmap() overlay_pix = self.processor.overlay_pixmap()
self.image_view.set_image(preview_pix) self.image_view.setPixmap(preview_pix)
self.overlay_view.set_image(overlay_pix) self.overlay_view.setPixmap(overlay_pix)
if self._current_image_path and self.processor.preview_img: if self._current_image_path and self.processor.preview_img:
width, height = self.processor.preview_img.size 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.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( 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: def _refresh_overlay_only(self) -> None:
if self.processor.preview_img is None: if self.processor.preview_img is None:
return return
self.overlay_view.set_image(self.processor.overlay_pixmap()) self.overlay_view.setPixmap(self.processor.overlay_pixmap())
self.ratio_label.setText(self.processor.stats.summary()) self.ratio_label.setText(self.processor.stats.summary(self._t))