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

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import colorsys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Tuple
from typing import Iterable, Tuple
from PIL import Image, ImageDraw
from PySide6 import QtGui
@ -20,12 +20,15 @@ class Stats:
matches_keep: int = 0
total_keep: int = 0
def summary(self, translate) -> str:
def summary(self) -> str:
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
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:
@ -39,30 +42,16 @@ class QtImageProcessor:
self.current_index: int = -1
self.stats = Stats()
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"]
# 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.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 with translations and themes."""
"""Main PySide6 window emulating the legacy Tk interface."""
from __future__ import annotations
@ -7,62 +7,33 @@ from typing import Callable, Dict, List, Tuple
from PySide6 import QtCore, QtGui, QtWidgets
from app.i18n import I18nMixin
from app.logic import SUPPORTED_IMAGE_EXTENSIONS
from .image_processor import QtImageProcessor
DEFAULT_COLOUR = "#763e92"
PRESET_COLOURS: List[Tuple[str, str]] = [
("palette.swatch.red", "#ff3b30"),
("palette.swatch.orange", "#ff9500"),
("palette.swatch.yellow", "#ffd60a"),
("palette.swatch.green", "#34c759"),
("palette.swatch.teal", "#5ac8fa"),
("palette.swatch.blue", "#0a84ff"),
("palette.swatch.violet", "#af52de"),
("palette.swatch.magenta", "#ff2d55"),
("palette.swatch.white", "#ffffff"),
("palette.swatch.grey", "#8e8e93"),
("palette.swatch.black", "#000000"),
("Red", "#ff3b30"),
("Orange", "#ff9500"),
("Yellow", "#ffd60a"),
("Green", "#34c759"),
("Teal", "#5ac8fa"),
("Blue", "#0a84ff"),
("Violet", "#af52de"),
("Magenta", "#ff2d55"),
("White", "#ffffff"),
("Grey", "#8e8e93"),
("Black", "#000000"),
]
SLIDER_SPECS: List[Tuple[str, str, int, int]] = [
("sliders.hue_min", "hue_min", 0, 360),
("sliders.hue_max", "hue_max", 0, 360),
("sliders.sat_min", "sat_min", 0, 100),
("sliders.val_min", "val_min", 0, 100),
("sliders.val_max", "val_max", 0, 100),
("sliders.alpha", "alpha", 0, 255),
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),
]
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."""
@ -91,26 +62,6 @@ 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."""
@ -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):
super().__init__(parent)
self.hex_code = hex_code
self.name_key = name
self.callback = callback
self.name = name
self.setToolTip(f"{name} ({hex_code})")
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 {{
@ -138,20 +85,7 @@ class ColourSwatch(QtWidgets.QPushButton):
}}
"""
)
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']};
}}
"""
)
self.clicked.connect(lambda: callback(hex_code, name))
class SliderControl(QtWidgets.QWidget):
@ -169,10 +103,12 @@ class SliderControl(QtWidgets.QWidget):
header = QtWidgets.QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
self.title_label = QtWidgets.QLabel(title)
header.addWidget(self.title_label)
title_label = QtWidgets.QLabel(title)
title_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 500;")
header.addWidget(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)
@ -210,26 +146,36 @@ class SliderControl(QtWidgets.QWidget):
self.slider.blockSignals(False)
self.value_label.setText(str(value))
def apply_theme(self, colours: Dict[str, str]) -> None:
self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
self.value_label.setStyleSheet(f"color: {colours['text_dim']};")
self.slider.setStyleSheet(
f"""
QSlider::groove:horizontal {{
border: 1px solid {colours['border']};
height: 6px;
background: rgba(255,255,255,0.14);
border-radius: 4px;
}}
QSlider::handle:horizontal {{
background: {colours['accent_secondary']};
border: 1px solid rgba(255,255,255,0.2);
width: 14px;
margin: -5px 0;
border-radius: 7px;
}}
"""
class 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,
)
self.setPixmap(target)
self.setStyleSheet("")
class TitleBar(QtWidgets.QWidget):
@ -244,6 +190,10 @@ 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)
@ -251,17 +201,16 @@ class TitleBar(QtWidgets.QWidget):
logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png"
if logo_path.exists():
pixmap = QtGui.QPixmap(str(logo_path))
self.logo_label = QtWidgets.QLabel()
self.logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
layout.addWidget(self.logo_label)
else:
self.logo_label = None
logo_label = QtWidgets.QLabel()
logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
layout.addWidget(logo_label)
self.title_label = QtWidgets.QLabel()
layout.addWidget(self.title_label)
title_label = QtWidgets.QLabel("Interactive Color Range Analyzer")
title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;")
layout.addWidget(title_label)
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)
layout.addWidget(self.min_btn)
@ -269,9 +218,15 @@ class TitleBar(QtWidgets.QWidget):
self.max_btn.clicked.connect(window.toggle_maximise)
layout.addWidget(self.max_btn)
self.close_btn = self._create_button("", "Close")
self.close_btn.clicked.connect(window.close)
layout.addWidget(self.close_btn)
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)
def _create_button(self, text: str, tooltip: str) -> QtWidgets.QPushButton:
btn = QtWidgets.QPushButton(text)
@ -293,41 +248,6 @@ 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()
@ -340,13 +260,11 @@ class TitleBar(QtWidgets.QWidget):
super().mousePressEvent(event)
class MainWindow(QtWidgets.QMainWindow, I18nMixin):
class MainWindow(QtWidgets.QMainWindow):
"""Main application window containing all controls."""
def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None:
def __init__(self) -> 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)
@ -358,19 +276,19 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
container_layout.setSpacing(0)
self.title_bar = TitleBar(self)
self.title_bar.title_label.setText(self._t("app.title"))
container_layout.addWidget(self.title_bar)
self.content = QtWidgets.QWidget()
self.content_layout = QtWidgets.QVBoxLayout(self.content)
self.content_layout.setContentsMargins(24, 24, 24, 24)
self.content_layout.setSpacing(18)
self.content.setStyleSheet("background-color: #111216;")
content_layout = QtWidgets.QVBoxLayout(self.content)
content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setSpacing(18)
self.content_layout.addLayout(self._build_toolbar())
self.content_layout.addLayout(self._build_palette())
self.content_layout.addLayout(self._build_sliders())
self.content_layout.addWidget(self._build_previews(), 1)
self.content_layout.addLayout(self._build_status_section())
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())
container_layout.addWidget(self.content, 1)
self.setCentralWidget(container)
@ -379,16 +297,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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 -------------------------------------------------
@ -417,26 +330,26 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
layout.setSpacing(12)
buttons = [
("open_image", "🖼", "toolbar.open_image"),
("open_folder", "📂", "toolbar.open_folder"),
("choose_color", "🎨", "toolbar.choose_color"),
("pick_from_image", "🖱", "toolbar.pick_from_image"),
("save_overlay", "💾", "toolbar.save_overlay"),
("toggle_free_draw", "", "toolbar.toggle_free_draw"),
("clear_excludes", "🧹", "toolbar.clear_excludes"),
("undo_exclude", "", "toolbar.undo_exclude"),
("reset_sliders", "🔄", "toolbar.reset_sliders"),
("toggle_theme", "🌓", "toolbar.toggle_theme"),
("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"),
]
self._toolbar_buttons: Dict[str, ToolbarButton] = {}
for key, icon_txt, text_key in buttons:
label = self._t(text_key)
for key, icon_txt, label in buttons:
button = ToolbarButton(icon_txt, label, lambda k=key: self._invoke_action(k))
layout.addWidget(button)
self._toolbar_buttons[key] = button
layout.addStretch(1)
self.status_label = QtWidgets.QLabel(self._t("status.no_file"))
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)
return layout
@ -447,8 +360,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
current_group = QtWidgets.QHBoxLayout()
current_group.setSpacing(8)
self.current_label = QtWidgets.QLabel(self._t("palette.current"))
current_group.addWidget(self.current_label)
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_colour_swatch = QtWidgets.QLabel()
self.current_colour_swatch.setFixedSize(28, 28)
@ -456,19 +370,19 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
self.more_label = QtWidgets.QLabel(self._t("palette.more"))
layout.addWidget(self.more_label)
more_label = QtWidgets.QLabel("More colours")
more_label.setStyleSheet("color: rgba(255,255,255,0.65); font-weight: 500;")
layout.addWidget(more_label)
swatch_container = QtWidgets.QHBoxLayout()
swatch_container.setSpacing(8)
self.swatch_buttons: List[ColourSwatch] = []
for name_key, hex_code in PRESET_COLOURS:
swatch = ColourSwatch(self._t(name_key), hex_code, self._update_colour_display)
for name, hex_code in PRESET_COLOURS:
swatch = ColourSwatch(name, hex_code, self._update_colour_display)
swatch_container.addWidget(swatch)
self.swatch_buttons.append(swatch)
layout.addLayout(swatch_container)
layout.addStretch(1)
return layout
@ -476,13 +390,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def _build_sliders(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(16)
self._slider_controls: Dict[str, SliderControl] = {}
for key, attr, minimum, maximum in SLIDER_SPECS:
initial = int(getattr(self.processor, attr))
control = SliderControl(self._t(key), attr, minimum, maximum, initial)
self._slider_controls.clear()
for title, key, minimum, maximum, initial in SLIDER_SPECS:
control = SliderControl(title, key, minimum, maximum, initial)
control.value_changed.connect(self._on_slider_change)
layout.addWidget(control)
self._slider_controls[attr] = control
self._slider_controls[key] = control
return layout
def _build_previews(self) -> QtWidgets.QWidget:
@ -490,26 +403,41 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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 = QtWidgets.QLabel("<No image loaded>")
self.image_view.setAlignment(QtCore.Qt.AlignCenter)
self.image_view = ImageView()
self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
layout.addWidget(self.image_view, 0, 1)
self.overlay_view = QtWidgets.QLabel("<No image loaded>")
self.overlay_view.setAlignment(QtCore.Qt.AlignCenter)
self.overlay_view = ImageView()
self.overlay_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
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)
@ -523,10 +451,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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(self._t("stats.placeholder"))
self.ratio_label = QtWidgets.QLabel("Matches with exclusions: —")
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
self.ratio_label.setStyleSheet("color: #e6b84b; font-weight: 600;")
layout.addWidget(self.ratio_label)
return layout
@ -543,7 +473,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"clear_excludes": self._coming_soon,
"undo_exclude": self._coming_soon,
"reset_sliders": self._reset_sliders,
"toggle_theme": self.toggle_theme,
"toggle_theme": self._coming_soon,
"show_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image,
}
@ -557,20 +487,20 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def open_image(self) -> None:
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)"
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, self._t("dialog.open_image_title"), "", filters)
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select image", "", filters)
if not path_str:
return
path = Path(path_str)
try:
self.processor.load_single_image(path)
except Exception as exc: # pragma: no cover - user feedback
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc))
except Exception as exc:
QtWidgets.QMessageBox.warning(self, "ICRA", f"Unable to open the selected image.\n{exc}")
return
self._current_image_path = path
self._refresh_views()
def open_folder(self) -> None:
directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title"))
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select image folder")
if not directory:
return
folder = Path(directory)
@ -579,19 +509,19 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
key=lambda p: p.name.lower(),
)
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
try:
self.processor.load_folder(paths)
except ValueError as exc:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc))
QtWidgets.QMessageBox.information(self, "ICRA", str(exc))
return
self._current_image_path = paths[0]
self._refresh_views()
def show_previous_image(self) -> None:
if not self.processor.preview_paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded"))
self._coming_soon()
return
self.processor.previous_image()
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:
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
self.processor.next_image()
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}")
def _on_slider_change(self, key: str, value: int) -> None:
formatted = key.replace("_", " ").title()
self.processor.set_threshold(key, value)
formatted = self._t("status.defaults_restored")
self.status_label.setText(f"{formatted} ({key}{value})")
self.status_label.setText(f"{formatted}{value}")
self._refresh_overlay_only()
def _reset_sliders(self) -> None:
for _, attr, _, _ in SLIDER_SPECS:
control = self._slider_controls.get(attr)
for _, key, _, _, initial in SLIDER_SPECS:
control = self._slider_controls.get(key)
if control:
default_value = int(getattr(self.processor, attr))
control.set_value(default_value)
self.status_label.setText(self._t("status.defaults_restored"))
control.set_value(initial)
self.processor.set_threshold(key, initial)
self.status_label.setText("Sliders reset to defaults")
self._refresh_overlay_only()
def _coming_soon(self) -> None:
QtWidgets.QMessageBox.information(
self,
self._t("dialog.info_title"),
"Feature coming soon in the PySide6 migration.",
"Coming soon",
"This action is not yet wired up in the PySide6 migration prototype.",
)
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)))
# view refresh -----------------------------------------------------------
def _refresh_views(self) -> None:
preview_pix = self.processor.preview_pixmap()
overlay_pix = self.processor.overlay_pixmap()
self.image_view.setPixmap(preview_pix)
self.overlay_view.setPixmap(overlay_pix)
self.image_view.set_image(preview_pix)
self.overlay_view.set_image(overlay_pix)
if self._current_image_path and self.processor.preview_img:
width, height = self.processor.preview_img.size
total = len(self.processor.preview_paths)
position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else ""
dimensions = f"{width}×{height}"
self.status_label.setText(
self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position)
f"{self._current_image_path.name} · {width}×{height}"
)
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:
if self.processor.preview_img is None:
return
self.overlay_view.setPixmap(self.processor.overlay_pixmap())
self.ratio_label.setText(self.processor.stats.summary(self._t))
self.overlay_view.set_image(self.processor.overlay_pixmap())
self.ratio_label.setText(self.processor.stats.summary())