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
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:

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
@ -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("<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,
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("<No image loaded>")
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("<No image loaded>")
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))