"""Main PySide6 window emulating the legacy Tk interface with translations and themes.""" from __future__ import annotations from pathlib import Path 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"), ] 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.""" def __init__(self, icon_text: str, label: str, callback: Callable[[], None], parent: QtWidgets.QWidget | None = None): text = f"{icon_text} {label}" super().__init__(text, parent) self.setCursor(QtCore.Qt.PointingHandCursor) self.setFixedHeight(32) metrics = QtGui.QFontMetrics(self.font()) width = metrics.horizontalAdvance(text) + 28 self.setMinimumWidth(width) self.setStyleSheet( """ QPushButton { padding: 8px 16px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.08); background-color: rgba(255, 255, 255, 0.04); color: #f7f7fb; font-weight: 600; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.12); } QPushButton:pressed { background-color: rgba(255, 255, 255, 0.18); } """ ) 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.""" 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.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 {{ background-color: {hex_code}; border: 2px solid rgba(255, 255, 255, 0.18); border-radius: 6px; }} QPushButton:hover {{ border-color: rgba(255, 255, 255, 0.45); }} """ ) 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): """Slider with header and live value label.""" value_changed = QtCore.Signal(str, int) def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int): super().__init__() self.key = key layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) header = QtWidgets.QHBoxLayout() header.setContentsMargins(0, 0, 0, 0) self.title_label = QtWidgets.QLabel(title) header.addWidget(self.title_label) header.addStretch(1) self.value_label = QtWidgets.QLabel(str(initial)) header.addWidget(self.value_label) layout.addLayout(header) self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.slider.setRange(minimum, maximum) self.slider.setValue(initial) self.slider.setCursor(QtCore.Qt.PointingHandCursor) self.slider.setStyleSheet( """ QSlider::groove:horizontal { border: 1px solid rgba(255,255,255,0.08); height: 6px; background: rgba(255,255,255,0.1); border-radius: 4px; } QSlider::handle:horizontal { background: #9a4dff; border: 1px solid rgba(255,255,255,0.2); width: 14px; margin: -5px 0; border-radius: 7px; } """ ) self.slider.valueChanged.connect(self._sync_value) layout.addWidget(self.slider) def _sync_value(self, value: int) -> None: self.value_label.setText(str(value)) self.value_changed.emit(self.key, value) def set_value(self, value: int) -> None: self.slider.blockSignals(True) self.slider.setValue(value) 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 CanvasView(QtWidgets.QGraphicsView): """Interactive canvas for drawing exclusion shapes over the preview image.""" shapes_changed = QtCore.Signal(list) def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setMouseTracking(True) self._scene = QtWidgets.QGraphicsScene(self) self.setScene(self._scene) self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None self._shape_items: list[QtWidgets.QGraphicsItem] = [] self._rubber_item: QtWidgets.QGraphicsRectItem | None = None self._stroke_item: QtWidgets.QGraphicsPathItem | None = None self.shapes: list[dict[str, object]] = [] self.mode: str = "rect" self._drawing = False self._start_pos = QtCore.QPointF() self._last_pos = QtCore.QPointF() self._path = QtGui.QPainterPath() self._accent = QtGui.QColor("#ffd700") def set_pixmap(self, pixmap: QtGui.QPixmap) -> None: self._scene.clear() self._shape_items.clear() self._pixmap_item = self._scene.addPixmap(pixmap) self._scene.setSceneRect(pixmap.rect()) self.resetTransform() self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio) self._redraw_shapes() def clear_canvas(self) -> None: if self._scene: self._scene.clear() self._pixmap_item = None self._shape_items.clear() self.shapes = [] def set_shapes(self, shapes: list[dict[str, object]]) -> None: self.shapes = shapes self._redraw_shapes() def set_mode(self, mode: str) -> None: self.mode = mode def set_accent(self, colour: str) -> None: self._accent = QtGui.QColor(colour) self._redraw_shapes() def undo_last(self) -> None: if not self.shapes: return self.shapes.pop() self._redraw_shapes() self.shapes_changed.emit(self.shapes.copy()) def clear_shapes(self) -> None: self.shapes = [] self._redraw_shapes() self.shapes_changed.emit([]) # event handling -------------------------------------------------------- def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: if event.button() == QtCore.Qt.RightButton and self._pixmap_item: self._drawing = True scene_pos = self.mapToScene(event.position().toPoint()) self._start_pos = self._clamp_to_image(scene_pos) if self.mode == "rect": pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) brush = QtGui.QBrush(QtCore.Qt.NoBrush) self._rubber_item = self._scene.addRect(QtCore.QRectF(self._start_pos, self._start_pos), pen, brush) else: pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) self._path = QtGui.QPainterPath(self._start_pos) self._stroke_item = self._scene.addPath(self._path, pen) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: if self._drawing and self._pixmap_item: scene_pos = self.mapToScene(event.position().toPoint()) self._last_pos = self._clamp_to_image(scene_pos) if self.mode == "rect" and self._rubber_item: rect = QtCore.QRectF(self._start_pos, self._last_pos).normalized() self._rubber_item.setRect(rect) elif self.mode == "free" and self._stroke_item: self._path.lineTo(self._last_pos) self._stroke_item.setPath(self._path) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: if self._drawing and event.button() == QtCore.Qt.RightButton and self._pixmap_item: scene_pos = self.mapToScene(event.position().toPoint()) end_pos = self._clamp_to_image(scene_pos) if self.mode == "rect" and self._rubber_item: rect = QtCore.QRectF(self._start_pos, end_pos).normalized() if rect.width() > 2 and rect.height() > 2: shape = { "kind": "rect", "coords": ( int(rect.left()), int(rect.top()), int(rect.right()), int(rect.bottom()), ), } self.shapes.append(shape) self._scene.removeItem(self._rubber_item) self._rubber_item = None elif self.mode == "free" and self._stroke_item: self._path.lineTo(self._start_pos) points = [(int(pt.x()), int(pt.y())) for pt in self._path.toFillPolygon()] if len(points) >= 3: shape = {"kind": "polygon", "points": points} self.shapes.append(shape) self._scene.removeItem(self._stroke_item) self._stroke_item = None self._drawing = False self._redraw_shapes() self.shapes_changed.emit(self.shapes.copy()) event.accept() return super().mouseReleaseEvent(event) # helpers ---------------------------------------------------------------- def _clamp_to_image(self, pos: QtCore.QPointF) -> QtCore.QPointF: if not self._pixmap_item: return pos pixmap = self._pixmap_item.pixmap() x = min(max(0.0, pos.x()), float(pixmap.width() - 1)) y = min(max(0.0, pos.y()), float(pixmap.height() - 1)) return QtCore.QPointF(x, y) def _redraw_shapes(self) -> None: for item in self._shape_items: self._scene.removeItem(item) self._shape_items.clear() if not self._pixmap_item: return pen = QtGui.QPen(self._accent, 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) pen.setCosmetic(True) for shape in self.shapes: kind = shape.get("kind") if kind == "rect": x0, y0, x1, y1 = shape["coords"] # type: ignore[index] rect_item = self._scene.addRect(QtCore.QRectF(x0, y0, x1 - x0, y1 - y0), pen) self._shape_items.append(rect_item) elif kind == "polygon": points = shape.get("points", []) if len(points) < 2: continue path = QtGui.QPainterPath() first = QtCore.QPointF(*points[0]) path.moveTo(first) for px, py in points[1:]: path.lineTo(px, py) path.closeSubpath() path_item = self._scene.addPath(path, pen) self._shape_items.append(path_item) class TitleBar(QtWidgets.QWidget): """Custom title bar with native window controls.""" HEIGHT = 40 def __init__(self, window: "MainWindow") -> None: super().__init__(window) self.window = window self.setFixedHeight(self.HEIGHT) self.setCursor(QtCore.Qt.ArrowCursor) self.setAutoFillBackground(True) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(12, 8, 12, 8) layout.setSpacing(8) 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 self.title_label = QtWidgets.QLabel() layout.addWidget(self.title_label) layout.addStretch(1) self.min_btn = self._create_button("–", "Minimise") self.min_btn.clicked.connect(window.showMinimized) layout.addWidget(self.min_btn) self.max_btn = self._create_button("❐", "Maximise / Restore") 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) def _create_button(self, text: str, tooltip: str) -> QtWidgets.QPushButton: btn = QtWidgets.QPushButton(text) btn.setToolTip(tooltip) btn.setFixedSize(36, 24) btn.setCursor(QtCore.Qt.ArrowCursor) btn.setStyleSheet( """ QPushButton { background-color: transparent; color: #f7f7fb; border: none; padding: 4px 10px; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.1); } """ ) 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() event.accept() def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: if event.button() == QtCore.Qt.LeftButton: self.window.start_system_move(event.globalPosition()) event.accept() super().mousePressEvent(event) class MainWindow(QtWidgets.QMainWindow, I18nMixin): """Main application window containing all controls.""" 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) self.setMinimumSize(1100, 680) container = QtWidgets.QWidget() container_layout = QtWidgets.QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) 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.processor = QtImageProcessor() self.processor.set_defaults(defaults) self.processor.reset_exclusions_on_switch = reset_exclusions self.content_layout = QtWidgets.QVBoxLayout(self.content) self.content_layout.setContentsMargins(24, 24, 24, 24) self.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()) container_layout.addWidget(self.content, 1) self.setCentralWidget(container) self._is_maximised = False self._current_image_path: Path | None = None self._current_colour = DEFAULT_COLOUR self._toolbar_actions: Dict[str, Callable[[], None]] = {} self._register_default_actions() self.exclude_mode = "rect" self.image_view.set_mode(self.exclude_mode) self.image_view.shapes_changed.connect(self._on_shapes_changed) 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 ------------------------------------------------- def toggle_maximise(self) -> None: handle = self.windowHandle() if handle is None: return if self._is_maximised: self.showNormal() self._is_maximised = False self.title_bar.max_btn.setText("❐") else: self.showMaximized() self._is_maximised = True self.title_bar.max_btn.setText("⧉") def start_system_move(self, _global_position: QtCore.QPointF) -> None: handle = self.windowHandle() if handle: handle.startSystemMove() # UI builders ------------------------------------------------------------ def _build_toolbar(self) -> QtWidgets.QHBoxLayout: layout = QtWidgets.QHBoxLayout() 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"), ] self._toolbar_buttons: Dict[str, ToolbarButton] = {} for key, icon_txt, text_key in buttons: label = self._t(text_key) button = ToolbarButton(icon_txt, label, lambda _checked=False, 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")) layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight) return layout def _build_palette(self) -> QtWidgets.QHBoxLayout: layout = QtWidgets.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(16) current_group = QtWidgets.QHBoxLayout() current_group.setSpacing(8) 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) self.current_colour_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOUR}; border-radius: 6px;") current_group.addWidget(self.current_colour_swatch) self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})") 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) 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) swatch_container.addWidget(swatch) self.swatch_buttons.append(swatch) layout.addLayout(swatch_container) layout.addStretch(1) return layout 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) control.value_changed.connect(self._on_slider_change) layout.addWidget(control) self._slider_controls[attr] = control return layout def _build_previews(self) -> QtWidgets.QWidget: container = QtWidgets.QWidget() layout = QtWidgets.QGridLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setHorizontalSpacing(16) 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.clicked.connect(lambda: self._invoke_action("show_previous_image")) layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter) self.image_view = CanvasView() layout.addWidget(self.image_view, 0, 1) self.overlay_view = QtWidgets.QLabel("") self.overlay_view.setAlignment(QtCore.Qt.AlignCenter) self.overlay_view.setScaledContents(True) 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.clicked.connect(lambda: self._invoke_action("show_next_image")) layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter) layout.setColumnStretch(1, 1) layout.setColumnStretch(2, 1) return container def _build_status_section(self) -> QtWidgets.QVBoxLayout: layout = QtWidgets.QVBoxLayout() layout.setSpacing(8) layout.setContentsMargins(0, 0, 0, 0) self.filename_label = QtWidgets.QLabel("—") self.filename_label.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(self.filename_label) self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder")) self.ratio_label.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(self.ratio_label) return layout # Action wiring ---------------------------------------------------------- def _register_default_actions(self) -> None: self._toolbar_actions = { "open_image": self.open_image, "open_folder": self.open_folder, "choose_color": self.choose_colour, "pick_from_image": self._coming_soon, "save_overlay": self.save_overlay, "toggle_free_draw": self.toggle_free_draw, "clear_excludes": self.clear_exclusions, "undo_exclude": self.undo_exclusion, "reset_sliders": self._reset_sliders, "toggle_theme": self.toggle_theme, "show_previous_image": self.show_previous_image, "show_next_image": self.show_next_image, } def _invoke_action(self, key: str) -> None: action = self._toolbar_actions.get(key) if action: action() # Image handling --------------------------------------------------------- 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) 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)) return self._current_image_path = path if self.processor.reset_exclusions_on_switch: self.image_view.clear_shapes() self.processor.set_exclusions([]) self._refresh_views() def open_folder(self) -> None: directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title")) if not directory: return folder = Path(directory) paths = sorted( (p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()), key=lambda p: p.name.lower(), ) if not paths: 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, 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: 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] if self.processor.reset_exclusions_on_switch: self.image_view.clear_shapes() self.processor.set_exclusions([]) self._refresh_views() 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")) return self.processor.next_image() self._current_image_path = self.processor.preview_paths[self.processor.current_index] if self.processor.reset_exclusions_on_switch: self.image_view.clear_shapes() self.processor.set_exclusions([]) self._refresh_views() # Helpers ---------------------------------------------------------------- def _update_colour_display(self, hex_code: str, label: str) -> None: self._current_colour = hex_code self.current_colour_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;") self.current_colour_label.setText(f"({hex_code})") self.status_label.setText(f"{label}: {hex_code}") def _on_slider_change(self, key: str, value: int) -> None: self.processor.set_threshold(key, value) label = self._slider_title(key) self.status_label.setText(f"{label}: {value}") self._refresh_overlay_only() def _reset_sliders(self) -> None: for _, attr, _, _ in SLIDER_SPECS: control = self._slider_controls.get(attr) if control: default_value = int(self.processor.defaults.get(attr, getattr(self.processor, attr))) control.set_value(default_value) self.processor.set_threshold(attr, default_value) self.status_label.setText(self._t("status.defaults_restored")) 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.", ) def choose_colour(self) -> None: colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title")) if not colour.isValid(): return hex_code = colour.name() self._update_colour_display(hex_code, self._t("dialog.choose_colour_title")) def save_overlay(self) -> None: pixmap = self.processor.overlay_pixmap() if pixmap.isNull(): QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_preview_available")) return filename, _ = QtWidgets.QFileDialog.getSaveFileName( self, self._t("dialog.save_overlay_title"), "overlay.png", "PNG (*.png)", ) if not filename: return if not pixmap.save(filename, "PNG"): QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), self._t("dialog.image_open_failed", error="Unable to save file")) return self.status_label.setText(self._t("dialog.overlay_saved", path=filename)) def toggle_free_draw(self) -> None: self.exclude_mode = "free" if self.exclude_mode == "rect" else "rect" self.image_view.set_mode(self.exclude_mode) message_key = "status.free_draw_enabled" if self.exclude_mode == "free" else "status.free_draw_disabled" self.status_label.setText(self._t(message_key)) def clear_exclusions(self) -> None: self.image_view.clear_shapes() self.processor.set_exclusions([]) self.status_label.setText(self._t("toolbar.clear_excludes")) self._refresh_overlay_only() def undo_exclusion(self) -> None: self.image_view.undo_last() self.status_label.setText(self._t("toolbar.undo_exclude")) 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"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;" ) self.image_view.set_accent(colours["highlight"]) self.overlay_view.setStyleSheet( f"background-color: {colours['panel_bg']}; 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 _slider_title(self, key: str) -> str: for title_key, attr, _, _ in SLIDER_SPECS: if attr == key: return self._t(title_key) return key def _refresh_views(self) -> None: preview_pix = self.processor.preview_pixmap() overlay_pix = self.processor.overlay_pixmap() if preview_pix.isNull(): self.image_view.clear_canvas() self.image_view.set_shapes([]) self.overlay_view.setText("") self.overlay_view.setPixmap(QtGui.QPixmap()) else: self.image_view.set_pixmap(preview_pix) self.image_view.set_shapes(self.processor.exclude_shapes.copy()) self.overlay_view.setText("") overlay_pix = self._overlay_with_outlines(overlay_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( self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position) ) self.filename_label.setText( self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position) ) self.ratio_label.setText(self.processor.stats.summary(self._t)) def _refresh_overlay_only(self) -> None: if self.processor.preview_img is None: return pix = self.processor.overlay_pixmap() if pix.isNull(): self.overlay_view.setText("") self.overlay_view.setPixmap(QtGui.QPixmap()) else: self.overlay_view.setText("") self.overlay_view.setPixmap(self._overlay_with_outlines(pix)) self.ratio_label.setText(self.processor.stats.summary(self._t)) def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None: self.processor.set_exclusions(shapes) self._refresh_overlay_only() def _overlay_with_outlines(self, pixmap: QtGui.QPixmap) -> QtGui.QPixmap: if pixmap.isNull() or not self.processor.exclude_shapes: return pixmap result = QtGui.QPixmap(pixmap) painter = QtGui.QPainter(result) colour = QtGui.QColor(THEMES[self.current_theme]["highlight"]) pen = QtGui.QPen(colour) pen.setWidth(3) pen.setCosmetic(True) pen.setCapStyle(QtCore.Qt.RoundCap) pen.setJoinStyle(QtCore.Qt.RoundJoin) painter.setPen(pen) for shape in self.processor.exclude_shapes: kind = shape.get("kind") if kind == "rect": x0, y0, x1, y1 = shape["coords"] # type: ignore[index] painter.drawRect(QtCore.QRectF(x0, y0, x1 - x0, y1 - y0)) elif kind == "polygon": points = shape.get("points", []) if len(points) >= 2: path = QtGui.QPainterPath() first = QtCore.QPointF(*points[0]) path.moveTo(first) for px, py in points[1:]: path.lineTo(px, py) path.closeSubpath() painter.drawPath(path) painter.end() return result