1255 lines
50 KiB
Python
1255 lines
50 KiB
Python
"""Main PySide6 window emulating the legacy Tk interface with translations and themes."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
from pathlib import Path
|
||
from typing import Callable, Dict, List, Tuple
|
||
|
||
from PIL import Image
|
||
from PySide6 import QtCore, QtGui, QtWidgets
|
||
|
||
from app.i18n import I18nMixin
|
||
from app.logic import SUPPORTED_IMAGE_EXTENSIONS
|
||
from .image_processor import QtImageProcessor
|
||
|
||
DEFAULT_COLOR = "#763e92"
|
||
|
||
PRESET_COLORS: 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 ColorSwatch(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_color(hex_code)
|
||
self.clicked.connect(lambda: callback(hex_code, self.name_key))
|
||
|
||
def _apply_color(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, colors: Dict[str, str]) -> None:
|
||
self.setStyleSheet(
|
||
f"""
|
||
QPushButton {{
|
||
background-color: {self.hex_code};
|
||
border: 2px solid {colors['border']};
|
||
border-radius: 6px;
|
||
}}
|
||
QPushButton:hover {{
|
||
border-color: {colors['accent']};
|
||
}}
|
||
"""
|
||
)
|
||
|
||
|
||
class SliderControl(QtWidgets.QWidget):
|
||
"""Slider with header and editable live value input."""
|
||
|
||
value_changed = QtCore.Signal(str, int)
|
||
|
||
def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int):
|
||
super().__init__()
|
||
self.key = key
|
||
self._minimum = minimum
|
||
self._maximum = maximum
|
||
|
||
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_edit = QtWidgets.QLineEdit(str(initial))
|
||
self.value_edit.setFixedWidth(44)
|
||
self.value_edit.setAlignment(QtCore.Qt.AlignRight)
|
||
self.value_edit.setValidator(QtGui.QIntValidator(minimum, maximum, self))
|
||
self.value_edit.editingFinished.connect(self._commit_edit)
|
||
header.addWidget(self.value_edit)
|
||
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.valueChanged.connect(self._sync_value)
|
||
layout.addWidget(self.slider)
|
||
|
||
def _sync_value(self, value: int) -> None:
|
||
self.value_edit.setText(str(value))
|
||
self.value_changed.emit(self.key, value)
|
||
|
||
def _commit_edit(self) -> None:
|
||
text = self.value_edit.text().strip()
|
||
try:
|
||
value = int(text)
|
||
except ValueError:
|
||
value = self.slider.value()
|
||
value = max(self._minimum, min(self._maximum, value))
|
||
self.slider.setValue(value) # triggers _sync_value -> signal
|
||
|
||
def set_value(self, value: int) -> None:
|
||
self.slider.blockSignals(True)
|
||
self.slider.setValue(value)
|
||
self.slider.blockSignals(False)
|
||
self.value_edit.setText(str(value))
|
||
|
||
def apply_theme(self, colors: Dict[str, str]) -> None:
|
||
self.title_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
|
||
self.value_edit.setStyleSheet(
|
||
f"color: {colors['text_dim']}; background: transparent; "
|
||
f"border: 1px solid {colors['border']}; border-radius: 4px; padding: 0 2px;"
|
||
)
|
||
self.slider.setStyleSheet(
|
||
f"""
|
||
QSlider::groove:horizontal {{
|
||
border: 1px solid {colors['border']};
|
||
height: 6px;
|
||
background: rgba(255,255,255,0.14);
|
||
border-radius: 4px;
|
||
}}
|
||
QSlider::handle:horizontal {{
|
||
background: {colors['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)
|
||
pixel_clicked = QtCore.Signal(int, int) # x, y in image coordinates
|
||
|
||
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.pick_mode: bool = False
|
||
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, color: str) -> None:
|
||
self._accent = QtGui.QColor(color)
|
||
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.LeftButton and self.pick_mode and self._pixmap_item:
|
||
scene_pos = self.mapToScene(event.position().toPoint())
|
||
clamped = self._clamp_to_image(scene_pos)
|
||
self.pixel_clicked.emit(int(clamped.x()), int(clamped.y()))
|
||
event.accept()
|
||
return
|
||
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 OverlayCanvas(QtWidgets.QGraphicsView):
|
||
"""Read-only QGraphicsView for displaying the color-match overlay."""
|
||
|
||
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._scene = QtWidgets.QGraphicsScene(self)
|
||
self.setScene(self._scene)
|
||
self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None
|
||
|
||
def set_pixmap(self, pixmap: QtGui.QPixmap) -> None:
|
||
self._scene.clear()
|
||
self._pixmap_item = self._scene.addPixmap(pixmap)
|
||
self._scene.setSceneRect(pixmap.rect())
|
||
self.resetTransform()
|
||
self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)
|
||
|
||
def clear_canvas(self) -> None:
|
||
self._scene.clear()
|
||
self._pixmap_item = None
|
||
|
||
def resizeEvent(self, event: QtGui.QResizeEvent) -> None: # type: ignore[override]
|
||
super().resizeEvent(event)
|
||
if self._pixmap_item:
|
||
self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)
|
||
|
||
|
||
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, colors: Dict[str, str]) -> None:
|
||
palette = self.palette()
|
||
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colors["titlebar_bg"]))
|
||
self.setPalette(palette)
|
||
self.title_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;")
|
||
hover_bg = "#d0342c" if colors["titlebar_bg"] != "#e9ebf5" else "#e6675a"
|
||
self.close_btn.setStyleSheet(
|
||
f"""
|
||
QPushButton {{
|
||
background-color: transparent;
|
||
color: {colors['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: {colors['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, overlay_color: str | None = None) -> 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
|
||
if overlay_color:
|
||
self.processor.set_overlay_color(overlay_color)
|
||
|
||
self.content_layout = QtWidgets.QVBoxLayout(self.content)
|
||
self.content_layout.setContentsMargins(24, 0, 24, 24)
|
||
self.content_layout.setSpacing(18)
|
||
|
||
self.content_layout.addWidget(self._build_menu_bar())
|
||
|
||
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_color = DEFAULT_COLOR
|
||
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
|
||
self._register_default_actions()
|
||
|
||
self.exclude_mode = "rect"
|
||
self._pick_mode = False
|
||
self.image_view.set_mode(self.exclude_mode)
|
||
self.image_view.shapes_changed.connect(self._on_shapes_changed)
|
||
self.image_view.pixel_clicked.connect(self._on_pixel_picked)
|
||
|
||
self._sync_sliders_from_processor()
|
||
self._update_color_display(DEFAULT_COLOR, self._t("palette.current"))
|
||
|
||
self.current_theme = "dark"
|
||
self._apply_theme(self.current_theme)
|
||
|
||
# Drag-and-drop
|
||
self.setAcceptDrops(True)
|
||
|
||
# Keyboard shortcuts
|
||
self._setup_shortcuts()
|
||
|
||
# Slider debounce timer
|
||
self._slider_timer = QtCore.QTimer(self)
|
||
self._slider_timer.setSingleShot(True)
|
||
self._slider_timer.setInterval(80)
|
||
self._slider_timer.timeout.connect(self._refresh_overlay_only)
|
||
|
||
# Restore window geometry
|
||
self._settings = QtCore.QSettings("ICRA", "MainWindow")
|
||
geometry = self._settings.value("geometry")
|
||
if geometry:
|
||
self.restoreGeometry(geometry)
|
||
|
||
# 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_menu_bar(self) -> QtWidgets.QMenuBar:
|
||
self.menu_bar = QtWidgets.QMenuBar(self)
|
||
|
||
# File Menu
|
||
file_menu = self.menu_bar.addMenu(self._t("menu.file"))
|
||
file_menu.addAction("🖼 " + self._t("toolbar.open_image"), lambda: self._invoke_action("open_image"), "Ctrl+O")
|
||
file_menu.addAction("📂 " + self._t("toolbar.open_folder"), lambda: self._invoke_action("open_folder"), "Ctrl+Shift+O")
|
||
file_menu.addAction("📊 " + self._t("toolbar.export_folder"), lambda: self._invoke_action("export_folder"))
|
||
file_menu.addSeparator()
|
||
file_menu.addAction("💾 " + self._t("toolbar.save_overlay"), lambda: self._invoke_action("save_overlay"), "Ctrl+S")
|
||
|
||
# Edit Menu
|
||
edit_menu = self.menu_bar.addMenu(self._t("menu.edit"))
|
||
edit_menu.addAction("↩ " + self._t("toolbar.undo_exclude"), lambda: self._invoke_action("undo_exclude"), "Ctrl+Z")
|
||
edit_menu.addAction("🧹 " + self._t("toolbar.clear_excludes"), lambda: self._invoke_action("clear_excludes"))
|
||
edit_menu.addSeparator()
|
||
edit_menu.addAction("🔄 " + self._t("toolbar.reset_sliders"), lambda: self._invoke_action("reset_sliders"), "Ctrl+R")
|
||
|
||
# Tools Menu
|
||
tools_menu = self.menu_bar.addMenu(self._t("menu.tools"))
|
||
tools_menu.addAction("🎨 " + self._t("toolbar.choose_color"), lambda: self._invoke_action("choose_color"))
|
||
tools_menu.addAction("🖱 " + self._t("toolbar.pick_from_image"), lambda: self._invoke_action("pick_from_image"))
|
||
tools_menu.addAction("△ " + self._t("toolbar.toggle_free_draw"), lambda: self._invoke_action("toggle_free_draw"))
|
||
|
||
# View Menu
|
||
view_menu = self.menu_bar.addMenu(self._t("menu.view"))
|
||
view_menu.addAction("🌓 " + self._t("toolbar.toggle_theme"), lambda: self._invoke_action("toggle_theme"))
|
||
|
||
# Status label logic remains but moved to palette layout or kept minimal
|
||
# We will add it to the palette layout so that it stays on top
|
||
self.status_label = QtWidgets.QLabel(self._t("status.no_file"))
|
||
return self.menu_bar
|
||
|
||
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_color_swatch = QtWidgets.QLabel()
|
||
self.current_color_swatch.setFixedSize(28, 28)
|
||
self.current_color_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOR}; border-radius: 6px;")
|
||
current_group.addWidget(self.current_color_swatch)
|
||
|
||
self.current_color_label = QtWidgets.QLabel(f"({DEFAULT_COLOR})")
|
||
current_group.addWidget(self.current_color_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[ColorSwatch] = []
|
||
for name_key, hex_code in PRESET_COLORS:
|
||
swatch = ColorSwatch(self._t(name_key), hex_code, self._update_color_display)
|
||
swatch_container.addWidget(swatch)
|
||
self.swatch_buttons.append(swatch)
|
||
layout.addLayout(swatch_container)
|
||
|
||
layout.addStretch(1)
|
||
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
|
||
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.setCursor(QtCore.Qt.PointingHandCursor)
|
||
self.prev_button.setAutoRaise(True)
|
||
self.prev_button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly)
|
||
self.prev_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowBack))
|
||
self.prev_button.setIconSize(QtCore.QSize(20, 20))
|
||
self.prev_button.setFixedSize(38, 38)
|
||
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 = OverlayCanvas()
|
||
layout.addWidget(self.overlay_view, 0, 2)
|
||
|
||
self.next_button = QtWidgets.QToolButton()
|
||
self.next_button.setCursor(QtCore.Qt.PointingHandCursor)
|
||
self.next_button.setAutoRaise(True)
|
||
self.next_button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly)
|
||
self.next_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowForward))
|
||
self.next_button.setIconSize(QtCore.QSize(20, 20))
|
||
self.next_button.setFixedSize(38, 38)
|
||
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,
|
||
"export_folder": self.export_folder,
|
||
"choose_color": self.choose_color,
|
||
"pick_from_image": self.pick_from_image,
|
||
"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:
|
||
loaded_path = 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 = loaded_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:
|
||
loaded_path = 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 = loaded_path
|
||
self._refresh_views()
|
||
|
||
def export_folder(self) -> None:
|
||
if not self.processor.preview_paths:
|
||
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded"))
|
||
return
|
||
|
||
csv_path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||
self,
|
||
self._t("dialog.export_stats_title"),
|
||
str(self.processor.preview_paths[0].parent / "icra_stats.csv"),
|
||
self._t("dialog.csv_filter")
|
||
)
|
||
if not csv_path:
|
||
return
|
||
|
||
total = len(self.processor.preview_paths)
|
||
|
||
is_eu = self.language == "de"
|
||
delimiter = ";" if is_eu else ","
|
||
decimal = "," if is_eu else "."
|
||
|
||
headers = [
|
||
"Filename",
|
||
"Color",
|
||
"Matching Pixels",
|
||
"Matching Pixels w/ Exclusions",
|
||
"Excluded Pixels"
|
||
]
|
||
rows = [headers]
|
||
|
||
for i, img_path in enumerate(self.processor.preview_paths):
|
||
self.status_label.setText(self._t("status.exporting", current=str(i+1), total=str(total)))
|
||
QtWidgets.QApplication.processEvents() # Keep UI vaguely responsive
|
||
|
||
# Process without modifying the UI current_index
|
||
img = Image.open(img_path)
|
||
old_orig = self.processor.orig_img
|
||
old_preview = self.processor.preview_img
|
||
|
||
self.processor.orig_img = img
|
||
self.processor._build_preview()
|
||
self.processor._rebuild_overlay()
|
||
s = self.processor.stats
|
||
|
||
pct_all = (s.matches_all / s.total_all * 100) if s.total_all else 0.0
|
||
pct_keep = (s.matches_keep / s.total_keep * 100) if s.total_keep else 0.0
|
||
pct_excl = (s.total_excl / s.total_all * 100) if s.total_all else 0.0
|
||
|
||
pct_all_str = f"{pct_all:.2f}".replace(".", decimal)
|
||
pct_keep_str = f"{pct_keep:.2f}".replace(".", decimal)
|
||
pct_excl_str = f"{pct_excl:.2f}".replace(".", decimal)
|
||
|
||
rows.append([
|
||
img_path.name,
|
||
self._current_color,
|
||
pct_all_str,
|
||
pct_keep_str,
|
||
pct_excl_str
|
||
])
|
||
img.close()
|
||
|
||
# Restore previous state
|
||
self.processor.orig_img = old_orig
|
||
self.processor.preview_img = old_preview
|
||
|
||
# Compute max width per column for alignment, plus extra space so it's not cramped
|
||
col_widths = [max(len(str(item)) for item in col) + 4 for col in zip(*rows)]
|
||
|
||
with open(csv_path, mode="w", newline="", encoding="utf-8") as f:
|
||
writer = csv.writer(f, delimiter=delimiter)
|
||
for row in rows:
|
||
padded_row = [f"{str(item):>{width}}" for item, width in zip(row, col_widths)]
|
||
writer.writerow(padded_row)
|
||
|
||
# Restore overlay state for currently viewed image
|
||
self.processor._rebuild_overlay()
|
||
self.status_label.setText(self._t("status.export_done", path=csv_path))
|
||
|
||
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
|
||
path = self.processor.previous_image()
|
||
if path is None:
|
||
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 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
|
||
path = self.processor.next_image()
|
||
if path is None:
|
||
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()
|
||
|
||
# Helpers ----------------------------------------------------------------
|
||
|
||
def _update_color_display(self, hex_code: str, label: str) -> None:
|
||
self._current_color = hex_code
|
||
self.current_color_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
|
||
self.current_color_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._slider_timer.start()
|
||
|
||
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.",
|
||
)
|
||
|
||
# Shortcuts --------------------------------------------------------------
|
||
|
||
def _setup_shortcuts(self) -> None:
|
||
shortcuts = [
|
||
(QtGui.QKeySequence.Open, self.open_image),
|
||
(QtGui.QKeySequence("Ctrl+Shift+O"), self.open_folder),
|
||
(QtGui.QKeySequence.Save, self.save_overlay),
|
||
(QtGui.QKeySequence.Undo, self.undo_exclusion),
|
||
(QtGui.QKeySequence("Ctrl+R"), self._reset_sliders),
|
||
(QtGui.QKeySequence(QtCore.Qt.Key_Left), self.show_previous_image),
|
||
(QtGui.QKeySequence(QtCore.Qt.Key_Right), self.show_next_image),
|
||
(QtGui.QKeySequence(QtCore.Qt.Key_Escape), self._exit_pick_mode),
|
||
]
|
||
for seq, slot in shortcuts:
|
||
sc = QtGui.QShortcut(seq, self)
|
||
sc.activated.connect(slot)
|
||
|
||
# Pick-mode / eyedropper -------------------------------------------------
|
||
|
||
def pick_from_image(self) -> None:
|
||
if self.processor.preview_img is None:
|
||
QtWidgets.QMessageBox.information(
|
||
self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")
|
||
)
|
||
return
|
||
self._pick_mode = True
|
||
self.image_view.pick_mode = True
|
||
self.image_view.setCursor(QtCore.Qt.CrossCursor)
|
||
self.status_label.setText(self._t("status.pick_mode_ready"))
|
||
|
||
def _exit_pick_mode(self) -> None:
|
||
self._pick_mode = False
|
||
self.image_view.pick_mode = False
|
||
self.image_view.setCursor(QtCore.Qt.ArrowCursor)
|
||
self.status_label.setText(self._t("status.pick_mode_ended"))
|
||
|
||
def _on_pixel_picked(self, x: int, y: int) -> None:
|
||
result = self.processor.pick_color(x, y)
|
||
if result is None:
|
||
self._exit_pick_mode()
|
||
return
|
||
hue, sat, val = result
|
||
|
||
# Derive a narrow hue range (±15°) and minimum sat/val from the pixel
|
||
margin = 15
|
||
hue_min = max(0, int(hue) - margin)
|
||
hue_max = min(360, int(hue) + margin)
|
||
sat_min = max(0, int(sat) - 20)
|
||
val_min = max(0, int(val) - 30)
|
||
val_max = 100
|
||
|
||
for attr, value in [
|
||
("hue_min", hue_min), ("hue_max", hue_max),
|
||
("sat_min", sat_min), ("val_min", val_min), ("val_max", val_max),
|
||
]:
|
||
ctrl = self._slider_controls.get(attr)
|
||
if ctrl:
|
||
ctrl.set_value(value)
|
||
self.processor.set_threshold(attr, value)
|
||
|
||
# Update color swatch to the picked pixel color
|
||
h_norm = hue / 360.0
|
||
s_norm = sat / 100.0
|
||
v_norm = val / 100.0
|
||
import colorsys
|
||
r, g, b = colorsys.hsv_to_rgb(h_norm, s_norm, v_norm)
|
||
hex_code = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
|
||
self._update_color_display(hex_code, "")
|
||
|
||
self.status_label.setText(
|
||
self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val)
|
||
)
|
||
self._exit_pick_mode()
|
||
self._refresh_overlay_only()
|
||
|
||
# Drag-and-drop ----------------------------------------------------------
|
||
|
||
def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
|
||
def dropEvent(self, event: QtGui.QDropEvent) -> None:
|
||
urls = event.mimeData().urls()
|
||
if not urls:
|
||
return
|
||
path = Path(urls[0].toLocalFile())
|
||
if path.is_dir():
|
||
paths = sorted(
|
||
(p for p in path.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:
|
||
loaded_path = 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 = loaded_path
|
||
elif path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS:
|
||
try:
|
||
loaded_path = self.processor.load_single_image(path)
|
||
except Exception as exc:
|
||
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc))
|
||
return
|
||
self._current_image_path = loaded_path
|
||
if self.processor.reset_exclusions_on_switch:
|
||
self.image_view.clear_shapes()
|
||
self.processor.set_exclusions([])
|
||
else:
|
||
return
|
||
self._refresh_views()
|
||
event.acceptProposedAction()
|
||
|
||
# Window lifecycle -------------------------------------------------------
|
||
|
||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||
self._settings.setValue("geometry", self.saveGeometry())
|
||
super().closeEvent(event)
|
||
|
||
def choose_color(self) -> None:
|
||
color = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_color_title"))
|
||
if not color.isValid():
|
||
return
|
||
hex_code = color.name()
|
||
self._update_color_display(hex_code, self._t("dialog.choose_color_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:
|
||
colors = THEMES[mode]
|
||
self.content.setStyleSheet(f"background-color: {colors['window_bg']};")
|
||
self.image_view.setStyleSheet(
|
||
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;"
|
||
)
|
||
self.image_view.set_accent(colors["highlight"])
|
||
self.overlay_view.setStyleSheet(
|
||
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;"
|
||
)
|
||
|
||
self.status_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
|
||
self.current_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
|
||
self.current_color_label.setStyleSheet(f"color: {colors['text_dim']};")
|
||
self.more_label.setStyleSheet(f"color: {colors['text_muted']}; font-weight: 500;")
|
||
self.filename_label.setStyleSheet(f"color: {colors['text']}; font-weight: 600;")
|
||
self.ratio_label.setStyleSheet(f"color: {colors['highlight']}; font-weight: 600;")
|
||
|
||
# Style MenuBar
|
||
self.menu_bar.setStyleSheet(
|
||
f"""
|
||
QMenuBar {{
|
||
background-color: {colors['window_bg']};
|
||
color: {colors['text']};
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid {colors['border']};
|
||
}}
|
||
QMenuBar::item {{
|
||
spacing: 8px;
|
||
padding: 6px 12px;
|
||
background: transparent;
|
||
border-radius: 4px;
|
||
}}
|
||
QMenuBar::item:selected {{
|
||
background: rgba(128, 128, 128, 0.2);
|
||
}}
|
||
QMenu {{
|
||
background-color: {colors['panel_bg']};
|
||
color: {colors['text']};
|
||
border: 1px solid {colors['border']};
|
||
}}
|
||
QMenu::item {{
|
||
padding: 6px 24px;
|
||
}}
|
||
QMenu::item:selected {{
|
||
background-color: {colors['highlight']};
|
||
color: #ffffff;
|
||
}}
|
||
"""
|
||
)
|
||
|
||
for swatch in self.swatch_buttons:
|
||
swatch.apply_theme(colors)
|
||
for control in self._slider_controls.values():
|
||
control.apply_theme(colors)
|
||
|
||
self._style_nav_button(self.prev_button)
|
||
self._style_nav_button(self.next_button)
|
||
|
||
self.title_bar.apply_theme(colors)
|
||
|
||
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 _style_nav_button(self, button: QtWidgets.QToolButton) -> None:
|
||
colors = THEMES[self.current_theme]
|
||
button.setStyleSheet(
|
||
f"QToolButton {{ border-radius: 19px; background-color: {colors['panel_bg']}; "
|
||
f"border: 1px solid {colors['border']}; color: {colors['text']}; }}"
|
||
f"QToolButton:hover {{ background-color: {colors['accent_secondary']}; color: white; }}"
|
||
)
|
||
button.setIconSize(QtCore.QSize(20, 20))
|
||
if button is getattr(self, "prev_button", None):
|
||
button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowBack))
|
||
elif button is getattr(self, "next_button", None):
|
||
button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_ArrowForward))
|
||
|
||
|
||
def _refresh_views(self) -> None:
|
||
preview_pix = self.processor.preview_pixmap()
|
||
if preview_pix.isNull():
|
||
self.image_view.clear_canvas()
|
||
self.image_view.set_shapes([])
|
||
self.overlay_view.clear_canvas()
|
||
else:
|
||
self.image_view.set_pixmap(preview_pix)
|
||
self.image_view.set_shapes(self.processor.exclude_shapes.copy())
|
||
overlay_pix = self.processor.overlay_pixmap()
|
||
overlay_pix = self._overlay_with_outlines(overlay_pix)
|
||
self.overlay_view.set_pixmap(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.clear_canvas()
|
||
else:
|
||
self.overlay_view.set_pixmap(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)
|
||
color = QtGui.QColor(THEMES[self.current_theme]["highlight"])
|
||
pen = QtGui.QPen(color)
|
||
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
|