ICRA/app/qt/main_window.py

957 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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):
super().__init__(f"{icon_text} {label}", parent)
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedHeight(34)
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 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("<No image loaded>")
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("<No image loaded>")
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("")
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("<No overlay>")
self.overlay_view.setPixmap(QtGui.QPixmap())
else:
self.overlay_view.setText("")
self.overlay_view.setPixmap(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()