957 lines
38 KiB
Python
957 lines
38 KiB
Python
"""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()
|