Compare commits

..

No commits in common. "213b35bf20e57e8b3b9a7ed02365c87a7a36e91f" and "0ca5607fc7b1be2d8c8f4153c1ef04dadfe05e2e" have entirely different histories.

3 changed files with 177 additions and 304 deletions

View File

@ -7,7 +7,6 @@ from pathlib import Path
from PySide6 import QtGui, QtWidgets from PySide6 import QtGui, QtWidgets
from app.logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE
from .main_window import MainWindow from .main_window import MainWindow
@ -46,10 +45,6 @@ def create_application() -> QtWidgets.QApplication:
def run() -> int: def run() -> int:
"""Run the PySide6 GUI.""" """Run the PySide6 GUI."""
app = create_application() app = create_application()
window = MainWindow( window = MainWindow()
language=LANGUAGE,
defaults=DEFAULTS.copy(),
reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
)
window.show() window.show()
return app.exec() return app.exec()

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 Dict, Iterable, Tuple from typing import Iterable, Tuple
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from PySide6 import QtGui from PySide6 import QtGui
@ -20,12 +20,15 @@ class Stats:
matches_keep: int = 0 matches_keep: int = 0
total_keep: int = 0 total_keep: int = 0
def summary(self, translate) -> str: def summary(self) -> str:
if self.total_all == 0: if self.total_all == 0:
return translate("stats.placeholder") return "Matches with exclusions: —"
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 translate("stats.summary", with_pct=with_pct, without_pct=without_pct, excluded_pct=0.0, excluded_match_pct=0.0) return (
f"Matches with exclusions: {with_pct:.1f}% · "
f"Matches overall: {without_pct:.1f}%"
)
class QtImageProcessor: class QtImageProcessor:
@ -39,30 +42,16 @@ class QtImageProcessor:
self.current_index: int = -1 self.current_index: int = -1
self.stats = Stats() self.stats = Stats()
self.defaults: Dict[str, int] = { # HSV thresholds and overlay alpha
"hue_min": 0, self.hue_min = 0
"hue_max": 360, self.hue_max = 360
"sat_min": 25, self.sat_min = 25
"val_min": 15, self.val_min = 15
"val_max": 100, self.val_max = 100
"alpha": 120, self.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 with translations and themes.""" """Main PySide6 window emulating the legacy Tk interface."""
from __future__ import annotations from __future__ import annotations
@ -7,62 +7,33 @@ 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]] = [
("palette.swatch.red", "#ff3b30"), ("Red", "#ff3b30"),
("palette.swatch.orange", "#ff9500"), ("Orange", "#ff9500"),
("palette.swatch.yellow", "#ffd60a"), ("Yellow", "#ffd60a"),
("palette.swatch.green", "#34c759"), ("Green", "#34c759"),
("palette.swatch.teal", "#5ac8fa"), ("Teal", "#5ac8fa"),
("palette.swatch.blue", "#0a84ff"), ("Blue", "#0a84ff"),
("palette.swatch.violet", "#af52de"), ("Violet", "#af52de"),
("palette.swatch.magenta", "#ff2d55"), ("Magenta", "#ff2d55"),
("palette.swatch.white", "#ffffff"), ("White", "#ffffff"),
("palette.swatch.grey", "#8e8e93"), ("Grey", "#8e8e93"),
("palette.swatch.black", "#000000"), ("Black", "#000000"),
] ]
SLIDER_SPECS: List[Tuple[str, str, int, int]] = [ SLIDER_SPECS: List[Tuple[str, str, int, int, int]] = [
("sliders.hue_min", "hue_min", 0, 360), ("Hue min", "hue_min", 0, 360, 0),
("sliders.hue_max", "hue_max", 0, 360), ("Hue max", "hue_max", 0, 360, 360),
("sliders.sat_min", "sat_min", 0, 100), ("Sat min", "sat_min", 0, 100, 25),
("sliders.val_min", "val_min", 0, 100), ("Value min", "val_min", 0, 100, 15),
("sliders.val_max", "val_max", 0, 100), ("Value max", "val_max", 0, 100, 100),
("sliders.alpha", "alpha", 0, 255), ("Overlay alpha", "alpha", 0, 255, 120),
] ]
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."""
@ -91,26 +62,6 @@ 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."""
@ -118,14 +69,10 @@ 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_key = name self.name = name
self.callback = callback self.setToolTip(f"{name} ({hex_code})")
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 {{
@ -138,20 +85,7 @@ 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):
@ -169,10 +103,12 @@ class SliderControl(QtWidgets.QWidget):
header = QtWidgets.QHBoxLayout() header = QtWidgets.QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0) header.setContentsMargins(0, 0, 0, 0)
self.title_label = QtWidgets.QLabel(title) title_label = QtWidgets.QLabel(title)
header.addWidget(self.title_label) title_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 500;")
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)
@ -210,26 +146,36 @@ 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:
self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;") class ImageView(QtWidgets.QLabel):
self.value_label.setStyleSheet(f"color: {colours['text_dim']};") """Aspect-ratio aware image view."""
self.slider.setStyleSheet(
f""" def __init__(self) -> None:
QSlider::groove:horizontal {{ super().__init__()
border: 1px solid {colours['border']}; self.setAlignment(QtCore.Qt.AlignCenter)
height: 6px; self._pixmap: QtGui.QPixmap | None = None
background: rgba(255,255,255,0.14);
border-radius: 4px; def set_image(self, pixmap: QtGui.QPixmap | None) -> None:
}} self._pixmap = pixmap
QSlider::handle:horizontal {{ self._rescale()
background: {colours['accent_secondary']};
border: 1px solid rgba(255,255,255,0.2); def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
width: 14px; super().resizeEvent(event)
margin: -5px 0; self._rescale()
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):
@ -244,6 +190,10 @@ 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)
@ -251,17 +201,16 @@ 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))
self.logo_label = QtWidgets.QLabel() logo_label = QtWidgets.QLabel()
self.logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
layout.addWidget(self.logo_label) layout.addWidget(logo_label)
else:
self.logo_label = None
self.title_label = QtWidgets.QLabel() title_label = QtWidgets.QLabel("Interactive Color Range Analyzer")
layout.addWidget(self.title_label) title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;")
layout.addWidget(title_label)
layout.addStretch(1) layout.addStretch(1)
self.min_btn = self._create_button("", "Minimise") self.min_btn = self._create_button("", "Minimise window")
self.min_btn.clicked.connect(window.showMinimized) self.min_btn.clicked.connect(window.showMinimized)
layout.addWidget(self.min_btn) layout.addWidget(self.min_btn)
@ -269,9 +218,15 @@ 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)
self.close_btn = self._create_button("", "Close") close_btn = self._create_button("", "Close")
self.close_btn.clicked.connect(window.close) close_btn.clicked.connect(window.close)
layout.addWidget(self.close_btn) close_btn.setStyleSheet(
"""
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)
@ -293,41 +248,6 @@ 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()
@ -340,13 +260,11 @@ class TitleBar(QtWidgets.QWidget):
super().mousePressEvent(event) super().mousePressEvent(event)
class MainWindow(QtWidgets.QMainWindow, I18nMixin): class MainWindow(QtWidgets.QMainWindow):
"""Main application window containing all controls.""" """Main application window containing all controls."""
def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None: def __init__(self) -> 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)
@ -358,19 +276,19 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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_layout = QtWidgets.QVBoxLayout(self.content) self.content.setStyleSheet("background-color: #111216;")
self.content_layout.setContentsMargins(24, 24, 24, 24) content_layout = QtWidgets.QVBoxLayout(self.content)
self.content_layout.setSpacing(18) content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setSpacing(18)
self.content_layout.addLayout(self._build_toolbar()) content_layout.addLayout(self._build_toolbar())
self.content_layout.addLayout(self._build_palette()) content_layout.addLayout(self._build_palette())
self.content_layout.addLayout(self._build_sliders()) content_layout.addLayout(self._build_sliders())
self.content_layout.addWidget(self._build_previews(), 1) content_layout.addWidget(self._build_previews(), 1)
self.content_layout.addLayout(self._build_status_section()) 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)
@ -379,16 +297,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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 -------------------------------------------------
@ -417,26 +330,26 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
layout.setSpacing(12) layout.setSpacing(12)
buttons = [ buttons = [
("open_image", "🖼", "toolbar.open_image"), ("open_image", "🖼", "Open image…"),
("open_folder", "📂", "toolbar.open_folder"), ("open_folder", "📂", "Open folder…"),
("choose_color", "🎨", "toolbar.choose_color"), ("choose_color", "🎨", "Choose colour"),
("pick_from_image", "🖱", "toolbar.pick_from_image"), ("pick_from_image", "🖱", "Pick from image"),
("save_overlay", "💾", "toolbar.save_overlay"), ("save_overlay", "💾", "Save overlay"),
("toggle_free_draw", "", "toolbar.toggle_free_draw"), ("toggle_free_draw", "", "Toggle free-draw"),
("clear_excludes", "🧹", "toolbar.clear_excludes"), ("clear_excludes", "🧹", "Clear exclusions"),
("undo_exclude", "", "toolbar.undo_exclude"), ("undo_exclude", "", "Undo last exclusion"),
("reset_sliders", "🔄", "toolbar.reset_sliders"), ("reset_sliders", "🔄", "Reset sliders"),
("toggle_theme", "🌓", "toolbar.toggle_theme"), ("toggle_theme", "🌓", "Toggle theme"),
] ]
self._toolbar_buttons: Dict[str, ToolbarButton] = {} self._toolbar_buttons: Dict[str, ToolbarButton] = {}
for key, icon_txt, text_key in buttons: for key, icon_txt, label 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(self._t("status.no_file")) self.status_label = QtWidgets.QLabel("No image loaded")
self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;")
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight) layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout return layout
@ -447,8 +360,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
current_group = QtWidgets.QHBoxLayout() current_group = QtWidgets.QHBoxLayout()
current_group.setSpacing(8) current_group.setSpacing(8)
self.current_label = QtWidgets.QLabel(self._t("palette.current")) current_label = QtWidgets.QLabel("Current colour")
current_group.addWidget(self.current_label) current_label.setStyleSheet("color: rgba(255,255,255,0.75); font-weight: 500;")
current_group.addWidget(current_label)
self.current_colour_swatch = QtWidgets.QLabel() self.current_colour_swatch = QtWidgets.QLabel()
self.current_colour_swatch.setFixedSize(28, 28) self.current_colour_swatch.setFixedSize(28, 28)
@ -456,19 +370,19 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
self.more_label = QtWidgets.QLabel(self._t("palette.more")) more_label = QtWidgets.QLabel("More colours")
layout.addWidget(self.more_label) more_label.setStyleSheet("color: rgba(255,255,255,0.65); font-weight: 500;")
layout.addWidget(more_label)
swatch_container = QtWidgets.QHBoxLayout() swatch_container = QtWidgets.QHBoxLayout()
swatch_container.setSpacing(8) swatch_container.setSpacing(8)
self.swatch_buttons: List[ColourSwatch] = [] for name, hex_code in PRESET_COLOURS:
for name_key, hex_code in PRESET_COLOURS: swatch = ColourSwatch(name, hex_code, self._update_colour_display)
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
@ -476,13 +390,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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: Dict[str, SliderControl] = {} self._slider_controls.clear()
for key, attr, minimum, maximum in SLIDER_SPECS: for title, key, minimum, maximum, initial in SLIDER_SPECS:
initial = int(getattr(self.processor, attr)) control = SliderControl(title, key, minimum, maximum, initial)
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[attr] = control self._slider_controls[key] = control
return layout return layout
def _build_previews(self) -> QtWidgets.QWidget: def _build_previews(self) -> QtWidgets.QWidget:
@ -490,26 +403,41 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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 = QtWidgets.QLabel("<No image loaded>") self.image_view = ImageView()
self.image_view.setAlignment(QtCore.Qt.AlignCenter) self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
layout.addWidget(self.image_view, 0, 1) layout.addWidget(self.image_view, 0, 1)
self.overlay_view = QtWidgets.QLabel("<No image loaded>") self.overlay_view = ImageView()
self.overlay_view.setAlignment(QtCore.Qt.AlignCenter) self.overlay_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
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)
@ -523,10 +451,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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(self._t("stats.placeholder")) self.ratio_label = QtWidgets.QLabel("Matches with exclusions: —")
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
@ -543,7 +473,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"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.toggle_theme, "toggle_theme": self._coming_soon,
"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,
} }
@ -557,20 +487,20 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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, self._t("dialog.open_image_title"), "", filters) path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select image", "", 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: # pragma: no cover - user feedback except Exception as exc:
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc)) QtWidgets.QMessageBox.warning(self, "ICRA", f"Unable to open the selected image.\n{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, self._t("dialog.open_folder_title")) directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select image folder")
if not directory: if not directory:
return return
folder = Path(directory) folder = Path(directory)
@ -579,19 +509,19 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
key=lambda p: p.name.lower(), key=lambda p: p.name.lower(),
) )
if not paths: if not paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.folder_empty")) QtWidgets.QMessageBox.information(self, "ICRA", "No supported image files found in the selected folder.")
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, self._t("dialog.info_title"), str(exc)) QtWidgets.QMessageBox.information(self, "ICRA", 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:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) self._coming_soon()
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]
@ -599,7 +529,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def show_next_image(self) -> None: def show_next_image(self) -> None:
if not self.processor.preview_paths: if not self.processor.preview_paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")) self._coming_soon()
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]
@ -614,87 +544,46 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
formatted = self._t("status.defaults_restored") self.status_label.setText(f"{formatted}{value}")
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 _, attr, _, _ in SLIDER_SPECS: for _, key, _, _, initial in SLIDER_SPECS:
control = self._slider_controls.get(attr) control = self._slider_controls.get(key)
if control: if control:
default_value = int(getattr(self.processor, attr)) control.set_value(initial)
control.set_value(default_value) self.processor.set_threshold(key, initial)
self.status_label.setText(self._t("status.defaults_restored")) self.status_label.setText("Sliders reset to defaults")
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,
self._t("dialog.info_title"), "Coming soon",
"Feature coming soon in the PySide6 migration.", "This action is not yet wired up in the PySide6 migration prototype.",
) )
def toggle_theme(self) -> None: # view refresh -----------------------------------------------------------
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.setPixmap(preview_pix) self.image_view.set_image(preview_pix)
self.overlay_view.setPixmap(overlay_pix) self.overlay_view.set_image(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(
self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) f"{self._current_image_path.name} · {width}×{height}"
) )
self.filename_label.setText( self.filename_label.setText(
self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position) f"{self._current_image_path.name} ({width}×{height})"
) )
self.ratio_label.setText(self.processor.stats.summary(self._t)) self.ratio_label.setText(self.processor.stats.summary())
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.setPixmap(self.processor.overlay_pixmap()) self.overlay_view.set_image(self.processor.overlay_pixmap())
self.ratio_label.setText(self.processor.stats.summary(self._t)) self.ratio_label.setText(self.processor.stats.summary())