|
|
|
|
@ -1,4 +1,4 @@
|
|
|
|
|
"""Main PySide6 window emulating the legacy Tk interface."""
|
|
|
|
|
"""Main PySide6 window emulating the legacy Tk interface with translations and themes."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
@ -7,33 +7,62 @@ 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]] = [
|
|
|
|
|
("Red", "#ff3b30"),
|
|
|
|
|
("Orange", "#ff9500"),
|
|
|
|
|
("Yellow", "#ffd60a"),
|
|
|
|
|
("Green", "#34c759"),
|
|
|
|
|
("Teal", "#5ac8fa"),
|
|
|
|
|
("Blue", "#0a84ff"),
|
|
|
|
|
("Violet", "#af52de"),
|
|
|
|
|
("Magenta", "#ff2d55"),
|
|
|
|
|
("White", "#ffffff"),
|
|
|
|
|
("Grey", "#8e8e93"),
|
|
|
|
|
("Black", "#000000"),
|
|
|
|
|
("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, int]] = [
|
|
|
|
|
("Hue min", "hue_min", 0, 360, 0),
|
|
|
|
|
("Hue max", "hue_max", 0, 360, 360),
|
|
|
|
|
("Sat min", "sat_min", 0, 100, 25),
|
|
|
|
|
("Value min", "val_min", 0, 100, 15),
|
|
|
|
|
("Value max", "val_max", 0, 100, 100),
|
|
|
|
|
("Overlay alpha", "alpha", 0, 255, 120),
|
|
|
|
|
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."""
|
|
|
|
|
@ -62,6 +91,26 @@ class ToolbarButton(QtWidgets.QPushButton):
|
|
|
|
|
)
|
|
|
|
|
self.clicked.connect(callback)
|
|
|
|
|
|
|
|
|
|
def apply_theme(self, colours: Dict[str, str]) -> None:
|
|
|
|
|
self.setStyleSheet(
|
|
|
|
|
f"""
|
|
|
|
|
QPushButton {{
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
border: 1px solid {colours['border']};
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.04);
|
|
|
|
|
color: {colours['text']};
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}}
|
|
|
|
|
QPushButton:hover {{
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.12);
|
|
|
|
|
}}
|
|
|
|
|
QPushButton:pressed {{
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.18);
|
|
|
|
|
}}
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ColourSwatch(QtWidgets.QPushButton):
|
|
|
|
|
"""Clickable palette swatch."""
|
|
|
|
|
@ -69,10 +118,14 @@ class ColourSwatch(QtWidgets.QPushButton):
|
|
|
|
|
def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
self.hex_code = hex_code
|
|
|
|
|
self.name = name
|
|
|
|
|
self.setToolTip(f"{name} ({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 {{
|
|
|
|
|
@ -85,7 +138,20 @@ class ColourSwatch(QtWidgets.QPushButton):
|
|
|
|
|
}}
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
self.clicked.connect(lambda: callback(hex_code, name))
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
@ -103,12 +169,10 @@ class SliderControl(QtWidgets.QWidget):
|
|
|
|
|
|
|
|
|
|
header = QtWidgets.QHBoxLayout()
|
|
|
|
|
header.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
title_label = QtWidgets.QLabel(title)
|
|
|
|
|
title_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 500;")
|
|
|
|
|
header.addWidget(title_label)
|
|
|
|
|
self.title_label = QtWidgets.QLabel(title)
|
|
|
|
|
header.addWidget(self.title_label)
|
|
|
|
|
header.addStretch(1)
|
|
|
|
|
self.value_label = QtWidgets.QLabel(str(initial))
|
|
|
|
|
self.value_label.setStyleSheet("color: rgba(255,255,255,0.6);")
|
|
|
|
|
header.addWidget(self.value_label)
|
|
|
|
|
layout.addLayout(header)
|
|
|
|
|
|
|
|
|
|
@ -146,36 +210,26 @@ class SliderControl(QtWidgets.QWidget):
|
|
|
|
|
self.slider.blockSignals(False)
|
|
|
|
|
self.value_label.setText(str(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageView(QtWidgets.QLabel):
|
|
|
|
|
"""Aspect-ratio aware image view."""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.setAlignment(QtCore.Qt.AlignCenter)
|
|
|
|
|
self._pixmap: QtGui.QPixmap | None = None
|
|
|
|
|
|
|
|
|
|
def set_image(self, pixmap: QtGui.QPixmap | None) -> None:
|
|
|
|
|
self._pixmap = pixmap
|
|
|
|
|
self._rescale()
|
|
|
|
|
|
|
|
|
|
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
|
|
|
|
|
super().resizeEvent(event)
|
|
|
|
|
self._rescale()
|
|
|
|
|
|
|
|
|
|
def _rescale(self) -> None:
|
|
|
|
|
if self._pixmap is None:
|
|
|
|
|
self.setPixmap(QtGui.QPixmap())
|
|
|
|
|
self.setText("<No image loaded>")
|
|
|
|
|
self.setStyleSheet("color: rgba(255,255,255,0.45); font-size: 14px;")
|
|
|
|
|
return
|
|
|
|
|
target = self._pixmap.scaled(
|
|
|
|
|
self.size(),
|
|
|
|
|
QtCore.Qt.KeepAspectRatio,
|
|
|
|
|
QtCore.Qt.SmoothTransformation,
|
|
|
|
|
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;
|
|
|
|
|
}}
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
self.setPixmap(target)
|
|
|
|
|
self.setStyleSheet("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TitleBar(QtWidgets.QWidget):
|
|
|
|
|
@ -190,10 +244,6 @@ class TitleBar(QtWidgets.QWidget):
|
|
|
|
|
self.setCursor(QtCore.Qt.ArrowCursor)
|
|
|
|
|
self.setAutoFillBackground(True)
|
|
|
|
|
|
|
|
|
|
palette = self.palette()
|
|
|
|
|
palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#16171d"))
|
|
|
|
|
self.setPalette(palette)
|
|
|
|
|
|
|
|
|
|
layout = QtWidgets.QHBoxLayout(self)
|
|
|
|
|
layout.setContentsMargins(12, 8, 12, 8)
|
|
|
|
|
layout.setSpacing(8)
|
|
|
|
|
@ -201,16 +251,17 @@ class TitleBar(QtWidgets.QWidget):
|
|
|
|
|
logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png"
|
|
|
|
|
if logo_path.exists():
|
|
|
|
|
pixmap = QtGui.QPixmap(str(logo_path))
|
|
|
|
|
logo_label = QtWidgets.QLabel()
|
|
|
|
|
logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
|
|
|
|
|
layout.addWidget(logo_label)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
title_label = QtWidgets.QLabel("Interactive Color Range Analyzer")
|
|
|
|
|
title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;")
|
|
|
|
|
layout.addWidget(title_label)
|
|
|
|
|
self.title_label = QtWidgets.QLabel()
|
|
|
|
|
layout.addWidget(self.title_label)
|
|
|
|
|
layout.addStretch(1)
|
|
|
|
|
|
|
|
|
|
self.min_btn = self._create_button("–", "Minimise window")
|
|
|
|
|
self.min_btn = self._create_button("–", "Minimise")
|
|
|
|
|
self.min_btn.clicked.connect(window.showMinimized)
|
|
|
|
|
layout.addWidget(self.min_btn)
|
|
|
|
|
|
|
|
|
|
@ -218,15 +269,9 @@ class TitleBar(QtWidgets.QWidget):
|
|
|
|
|
self.max_btn.clicked.connect(window.toggle_maximise)
|
|
|
|
|
layout.addWidget(self.max_btn)
|
|
|
|
|
|
|
|
|
|
close_btn = self._create_button("✕", "Close")
|
|
|
|
|
close_btn.clicked.connect(window.close)
|
|
|
|
|
close_btn.setStyleSheet(
|
|
|
|
|
"""
|
|
|
|
|
QPushButton { background: transparent; color: #f7f7fb; border: none; padding: 4px 10px; }
|
|
|
|
|
QPushButton:hover { background-color: #d0342c; }
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
layout.addWidget(close_btn)
|
|
|
|
|
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)
|
|
|
|
|
@ -248,6 +293,41 @@ class TitleBar(QtWidgets.QWidget):
|
|
|
|
|
)
|
|
|
|
|
return btn
|
|
|
|
|
|
|
|
|
|
def apply_theme(self, colours: Dict[str, str]) -> None:
|
|
|
|
|
palette = self.palette()
|
|
|
|
|
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colours["titlebar_bg"]))
|
|
|
|
|
self.setPalette(palette)
|
|
|
|
|
self.title_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;")
|
|
|
|
|
hover_bg = "#d0342c" if colours["titlebar_bg"] != "#e9ebf5" else "#e6675a"
|
|
|
|
|
self.close_btn.setStyleSheet(
|
|
|
|
|
f"""
|
|
|
|
|
QPushButton {{
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
color: {colours['text']};
|
|
|
|
|
border: none;
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
}}
|
|
|
|
|
QPushButton:hover {{
|
|
|
|
|
background-color: {hover_bg};
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
}}
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
for btn in (self.min_btn, self.max_btn):
|
|
|
|
|
btn.setStyleSheet(
|
|
|
|
|
f"""
|
|
|
|
|
QPushButton {{
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
color: {colours['text']};
|
|
|
|
|
border: none;
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
}}
|
|
|
|
|
QPushButton:hover {{
|
|
|
|
|
background-color: rgba(0, 0, 0, 0.1);
|
|
|
|
|
}}
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None:
|
|
|
|
|
if event.button() == QtCore.Qt.LeftButton:
|
|
|
|
|
self.window.toggle_maximise()
|
|
|
|
|
@ -260,11 +340,13 @@ class TitleBar(QtWidgets.QWidget):
|
|
|
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
class MainWindow(QtWidgets.QMainWindow, I18nMixin):
|
|
|
|
|
"""Main application window containing all controls."""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
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)
|
|
|
|
|
@ -276,19 +358,19 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
container_layout.setSpacing(0)
|
|
|
|
|
|
|
|
|
|
self.title_bar = TitleBar(self)
|
|
|
|
|
self.title_bar.title_label.setText(self._t("app.title"))
|
|
|
|
|
container_layout.addWidget(self.title_bar)
|
|
|
|
|
|
|
|
|
|
self.content = QtWidgets.QWidget()
|
|
|
|
|
self.content.setStyleSheet("background-color: #111216;")
|
|
|
|
|
content_layout = QtWidgets.QVBoxLayout(self.content)
|
|
|
|
|
content_layout.setContentsMargins(24, 24, 24, 24)
|
|
|
|
|
content_layout.setSpacing(18)
|
|
|
|
|
self.content_layout = QtWidgets.QVBoxLayout(self.content)
|
|
|
|
|
self.content_layout.setContentsMargins(24, 24, 24, 24)
|
|
|
|
|
self.content_layout.setSpacing(18)
|
|
|
|
|
|
|
|
|
|
content_layout.addLayout(self._build_toolbar())
|
|
|
|
|
content_layout.addLayout(self._build_palette())
|
|
|
|
|
content_layout.addLayout(self._build_sliders())
|
|
|
|
|
content_layout.addWidget(self._build_previews(), 1)
|
|
|
|
|
content_layout.addLayout(self._build_status_section())
|
|
|
|
|
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)
|
|
|
|
|
@ -297,11 +379,16 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
self._current_image_path: Path | None = None
|
|
|
|
|
self._current_colour = DEFAULT_COLOUR
|
|
|
|
|
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
|
|
|
|
|
self._slider_controls: Dict[str, SliderControl] = {}
|
|
|
|
|
self._register_default_actions()
|
|
|
|
|
self._update_colour_display(DEFAULT_COLOUR, "Default colour")
|
|
|
|
|
|
|
|
|
|
self.processor = QtImageProcessor()
|
|
|
|
|
self.processor.set_defaults(defaults)
|
|
|
|
|
self.processor.reset_exclusions_on_switch = reset_exclusions
|
|
|
|
|
self._sync_sliders_from_processor()
|
|
|
|
|
self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current"))
|
|
|
|
|
|
|
|
|
|
self.current_theme = "dark"
|
|
|
|
|
self._apply_theme(self.current_theme)
|
|
|
|
|
|
|
|
|
|
# Window control helpers -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@ -330,26 +417,26 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
layout.setSpacing(12)
|
|
|
|
|
|
|
|
|
|
buttons = [
|
|
|
|
|
("open_image", "🖼", "Open image…"),
|
|
|
|
|
("open_folder", "📂", "Open folder…"),
|
|
|
|
|
("choose_color", "🎨", "Choose colour"),
|
|
|
|
|
("pick_from_image", "🖱", "Pick from image"),
|
|
|
|
|
("save_overlay", "💾", "Save overlay"),
|
|
|
|
|
("toggle_free_draw", "△", "Toggle free-draw"),
|
|
|
|
|
("clear_excludes", "🧹", "Clear exclusions"),
|
|
|
|
|
("undo_exclude", "↩", "Undo last exclusion"),
|
|
|
|
|
("reset_sliders", "🔄", "Reset sliders"),
|
|
|
|
|
("toggle_theme", "🌓", "Toggle theme"),
|
|
|
|
|
("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, label in buttons:
|
|
|
|
|
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("No image loaded")
|
|
|
|
|
self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;")
|
|
|
|
|
self.status_label = QtWidgets.QLabel(self._t("status.no_file"))
|
|
|
|
|
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
|
|
|
|
|
return layout
|
|
|
|
|
|
|
|
|
|
@ -360,9 +447,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
|
|
|
|
|
current_group = QtWidgets.QHBoxLayout()
|
|
|
|
|
current_group.setSpacing(8)
|
|
|
|
|
current_label = QtWidgets.QLabel("Current colour")
|
|
|
|
|
current_label.setStyleSheet("color: rgba(255,255,255,0.75); font-weight: 500;")
|
|
|
|
|
current_group.addWidget(current_label)
|
|
|
|
|
self.current_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)
|
|
|
|
|
@ -370,19 +456,19 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
current_group.addWidget(self.current_colour_swatch)
|
|
|
|
|
|
|
|
|
|
self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})")
|
|
|
|
|
self.current_colour_label.setStyleSheet("color: rgba(255,255,255,0.65);")
|
|
|
|
|
current_group.addWidget(self.current_colour_label)
|
|
|
|
|
layout.addLayout(current_group)
|
|
|
|
|
|
|
|
|
|
more_label = QtWidgets.QLabel("More colours")
|
|
|
|
|
more_label.setStyleSheet("color: rgba(255,255,255,0.65); font-weight: 500;")
|
|
|
|
|
layout.addWidget(more_label)
|
|
|
|
|
self.more_label = QtWidgets.QLabel(self._t("palette.more"))
|
|
|
|
|
layout.addWidget(self.more_label)
|
|
|
|
|
|
|
|
|
|
swatch_container = QtWidgets.QHBoxLayout()
|
|
|
|
|
swatch_container.setSpacing(8)
|
|
|
|
|
for name, hex_code in PRESET_COLOURS:
|
|
|
|
|
swatch = ColourSwatch(name, hex_code, self._update_colour_display)
|
|
|
|
|
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
|
|
|
|
|
@ -390,12 +476,13 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
def _build_sliders(self) -> QtWidgets.QHBoxLayout:
|
|
|
|
|
layout = QtWidgets.QHBoxLayout()
|
|
|
|
|
layout.setSpacing(16)
|
|
|
|
|
self._slider_controls.clear()
|
|
|
|
|
for title, key, minimum, maximum, initial in SLIDER_SPECS:
|
|
|
|
|
control = SliderControl(title, key, minimum, maximum, initial)
|
|
|
|
|
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[key] = control
|
|
|
|
|
self._slider_controls[attr] = control
|
|
|
|
|
return layout
|
|
|
|
|
|
|
|
|
|
def _build_previews(self) -> QtWidgets.QWidget:
|
|
|
|
|
@ -403,41 +490,26 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
layout = QtWidgets.QGridLayout(container)
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
layout.setHorizontalSpacing(16)
|
|
|
|
|
layout.setVerticalSpacing(0)
|
|
|
|
|
|
|
|
|
|
self.prev_button = QtWidgets.QToolButton()
|
|
|
|
|
self.prev_button.setText("◀")
|
|
|
|
|
self.prev_button.setCursor(QtCore.Qt.PointingHandCursor)
|
|
|
|
|
self.prev_button.setFixedSize(44, 44)
|
|
|
|
|
self.prev_button.setStyleSheet(
|
|
|
|
|
"""
|
|
|
|
|
QToolButton {
|
|
|
|
|
border-radius: 22px;
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.08);
|
|
|
|
|
color: #f7f7fb;
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
}
|
|
|
|
|
QToolButton:hover {
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.16);
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
self.prev_button.clicked.connect(lambda: self._invoke_action("show_previous_image"))
|
|
|
|
|
layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter)
|
|
|
|
|
|
|
|
|
|
self.image_view = ImageView()
|
|
|
|
|
self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
|
|
|
|
|
self.image_view = QtWidgets.QLabel("<No image loaded>")
|
|
|
|
|
self.image_view.setAlignment(QtCore.Qt.AlignCenter)
|
|
|
|
|
layout.addWidget(self.image_view, 0, 1)
|
|
|
|
|
|
|
|
|
|
self.overlay_view = ImageView()
|
|
|
|
|
self.overlay_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
|
|
|
|
|
self.overlay_view = QtWidgets.QLabel("<No image loaded>")
|
|
|
|
|
self.overlay_view.setAlignment(QtCore.Qt.AlignCenter)
|
|
|
|
|
layout.addWidget(self.overlay_view, 0, 2)
|
|
|
|
|
|
|
|
|
|
self.next_button = QtWidgets.QToolButton()
|
|
|
|
|
self.next_button.setText("▶")
|
|
|
|
|
self.next_button.setCursor(QtCore.Qt.PointingHandCursor)
|
|
|
|
|
self.next_button.setFixedSize(44, 44)
|
|
|
|
|
self.next_button.setStyleSheet(self.prev_button.styleSheet())
|
|
|
|
|
self.next_button.clicked.connect(lambda: self._invoke_action("show_next_image"))
|
|
|
|
|
layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter)
|
|
|
|
|
layout.setColumnStretch(1, 1)
|
|
|
|
|
@ -451,12 +523,10 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
|
|
|
|
|
self.filename_label = QtWidgets.QLabel("—")
|
|
|
|
|
self.filename_label.setAlignment(QtCore.Qt.AlignCenter)
|
|
|
|
|
self.filename_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 600;")
|
|
|
|
|
layout.addWidget(self.filename_label)
|
|
|
|
|
|
|
|
|
|
self.ratio_label = QtWidgets.QLabel("Matches with exclusions: —")
|
|
|
|
|
self.ratio_label = QtWidgets.QLabel(self._t("stats.placeholder"))
|
|
|
|
|
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
|
|
|
|
|
self.ratio_label.setStyleSheet("color: #e6b84b; font-weight: 600;")
|
|
|
|
|
layout.addWidget(self.ratio_label)
|
|
|
|
|
return layout
|
|
|
|
|
|
|
|
|
|
@ -473,7 +543,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
"clear_excludes": self._coming_soon,
|
|
|
|
|
"undo_exclude": self._coming_soon,
|
|
|
|
|
"reset_sliders": self._reset_sliders,
|
|
|
|
|
"toggle_theme": self._coming_soon,
|
|
|
|
|
"toggle_theme": self.toggle_theme,
|
|
|
|
|
"show_previous_image": self.show_previous_image,
|
|
|
|
|
"show_next_image": self.show_next_image,
|
|
|
|
|
}
|
|
|
|
|
@ -487,20 +557,20 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
|
|
|
|
|
def open_image(self) -> None:
|
|
|
|
|
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)"
|
|
|
|
|
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select image", "", filters)
|
|
|
|
|
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:
|
|
|
|
|
QtWidgets.QMessageBox.warning(self, "ICRA", f"Unable to open the selected image.\n{exc}")
|
|
|
|
|
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
|
|
|
|
|
self._refresh_views()
|
|
|
|
|
|
|
|
|
|
def open_folder(self) -> None:
|
|
|
|
|
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select image folder")
|
|
|
|
|
directory = QtWidgets.QFileDialog.getExistingDirectory(self, self._t("dialog.open_folder_title"))
|
|
|
|
|
if not directory:
|
|
|
|
|
return
|
|
|
|
|
folder = Path(directory)
|
|
|
|
|
@ -509,19 +579,19 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
key=lambda p: p.name.lower(),
|
|
|
|
|
)
|
|
|
|
|
if not paths:
|
|
|
|
|
QtWidgets.QMessageBox.information(self, "ICRA", "No supported image files found in the selected folder.")
|
|
|
|
|
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, "ICRA", str(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:
|
|
|
|
|
self._coming_soon()
|
|
|
|
|
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]
|
|
|
|
|
@ -529,7 +599,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
|
|
|
|
|
def show_next_image(self) -> None:
|
|
|
|
|
if not self.processor.preview_paths:
|
|
|
|
|
self._coming_soon()
|
|
|
|
|
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]
|
|
|
|
|
@ -544,46 +614,87 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
self.status_label.setText(f"{label}: {hex_code}")
|
|
|
|
|
|
|
|
|
|
def _on_slider_change(self, key: str, value: int) -> None:
|
|
|
|
|
formatted = key.replace("_", " ").title()
|
|
|
|
|
self.processor.set_threshold(key, value)
|
|
|
|
|
self.status_label.setText(f"{formatted} → {value}")
|
|
|
|
|
formatted = self._t("status.defaults_restored")
|
|
|
|
|
self.status_label.setText(f"{formatted} ({key} → {value})")
|
|
|
|
|
self._refresh_overlay_only()
|
|
|
|
|
|
|
|
|
|
def _reset_sliders(self) -> None:
|
|
|
|
|
for _, key, _, _, initial in SLIDER_SPECS:
|
|
|
|
|
control = self._slider_controls.get(key)
|
|
|
|
|
for _, attr, _, _ in SLIDER_SPECS:
|
|
|
|
|
control = self._slider_controls.get(attr)
|
|
|
|
|
if control:
|
|
|
|
|
control.set_value(initial)
|
|
|
|
|
self.processor.set_threshold(key, initial)
|
|
|
|
|
self.status_label.setText("Sliders reset to defaults")
|
|
|
|
|
default_value = int(getattr(self.processor, attr))
|
|
|
|
|
control.set_value(default_value)
|
|
|
|
|
self.status_label.setText(self._t("status.defaults_restored"))
|
|
|
|
|
self._refresh_overlay_only()
|
|
|
|
|
|
|
|
|
|
def _coming_soon(self) -> None:
|
|
|
|
|
QtWidgets.QMessageBox.information(
|
|
|
|
|
self,
|
|
|
|
|
"Coming soon",
|
|
|
|
|
"This action is not yet wired up in the PySide6 migration prototype.",
|
|
|
|
|
self._t("dialog.info_title"),
|
|
|
|
|
"Feature coming soon in the PySide6 migration.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# view refresh -----------------------------------------------------------
|
|
|
|
|
def toggle_theme(self) -> None:
|
|
|
|
|
self.current_theme = "light" if self.current_theme == "dark" else "dark"
|
|
|
|
|
self._apply_theme(self.current_theme)
|
|
|
|
|
|
|
|
|
|
def _apply_theme(self, mode: str) -> None:
|
|
|
|
|
colours = THEMES[mode]
|
|
|
|
|
self.content.setStyleSheet(f"background-color: {colours['window_bg']};")
|
|
|
|
|
self.image_view.setStyleSheet(f"border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};")
|
|
|
|
|
self.overlay_view.setStyleSheet(f"border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};")
|
|
|
|
|
|
|
|
|
|
self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
|
|
|
|
|
self.current_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
|
|
|
|
|
self.current_colour_label.setStyleSheet(f"color: {colours['text_dim']};")
|
|
|
|
|
self.more_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
|
|
|
|
|
self.filename_label.setStyleSheet(f"color: {colours['text']}; font-weight: 600;")
|
|
|
|
|
self.ratio_label.setStyleSheet(f"color: {colours['highlight']}; font-weight: 600;")
|
|
|
|
|
|
|
|
|
|
for button in self._toolbar_buttons.values():
|
|
|
|
|
button.apply_theme(colours)
|
|
|
|
|
for swatch in self.swatch_buttons:
|
|
|
|
|
swatch.apply_theme(colours)
|
|
|
|
|
for control in self._slider_controls.values():
|
|
|
|
|
control.apply_theme(colours)
|
|
|
|
|
|
|
|
|
|
style_button = (
|
|
|
|
|
f"QToolButton {{ border-radius: 22px; background-color: {colours['panel_bg']}; "
|
|
|
|
|
f"border: 1px solid {colours['border']}; color: {colours['text']}; }}"
|
|
|
|
|
f"QToolButton:hover {{ background-color: {colours['accent_secondary']}; color: white; }}"
|
|
|
|
|
)
|
|
|
|
|
self.prev_button.setStyleSheet(style_button)
|
|
|
|
|
self.next_button.setStyleSheet(style_button)
|
|
|
|
|
|
|
|
|
|
self.title_bar.apply_theme(colours)
|
|
|
|
|
|
|
|
|
|
def _sync_sliders_from_processor(self) -> None:
|
|
|
|
|
for _, attr, _, _ in SLIDER_SPECS:
|
|
|
|
|
control = self._slider_controls.get(attr)
|
|
|
|
|
if control:
|
|
|
|
|
control.set_value(int(getattr(self.processor, attr)))
|
|
|
|
|
|
|
|
|
|
def _refresh_views(self) -> None:
|
|
|
|
|
preview_pix = self.processor.preview_pixmap()
|
|
|
|
|
overlay_pix = self.processor.overlay_pixmap()
|
|
|
|
|
self.image_view.set_image(preview_pix)
|
|
|
|
|
self.overlay_view.set_image(overlay_pix)
|
|
|
|
|
self.image_view.setPixmap(preview_pix)
|
|
|
|
|
self.overlay_view.setPixmap(overlay_pix)
|
|
|
|
|
if self._current_image_path and self.processor.preview_img:
|
|
|
|
|
width, height = self.processor.preview_img.size
|
|
|
|
|
total = len(self.processor.preview_paths)
|
|
|
|
|
position = f" [{self.processor.current_index + 1}/{total}]" if total > 1 else ""
|
|
|
|
|
dimensions = f"{width}×{height}"
|
|
|
|
|
self.status_label.setText(
|
|
|
|
|
f"{self._current_image_path.name} · {width}×{height}"
|
|
|
|
|
self._t("status.loaded", name=self._current_image_path.name, dimensions=dimensions, position=position)
|
|
|
|
|
)
|
|
|
|
|
self.filename_label.setText(
|
|
|
|
|
f"{self._current_image_path.name} ({width}×{height})"
|
|
|
|
|
self._t("status.filename_label", name=self._current_image_path.name, dimensions=dimensions, position=position)
|
|
|
|
|
)
|
|
|
|
|
self.ratio_label.setText(self.processor.stats.summary())
|
|
|
|
|
self.ratio_label.setText(self.processor.stats.summary(self._t))
|
|
|
|
|
|
|
|
|
|
def _refresh_overlay_only(self) -> None:
|
|
|
|
|
if self.processor.preview_img is None:
|
|
|
|
|
return
|
|
|
|
|
self.overlay_view.set_image(self.processor.overlay_pixmap())
|
|
|
|
|
self.ratio_label.setText(self.processor.stats.summary())
|
|
|
|
|
self.overlay_view.setPixmap(self.processor.overlay_pixmap())
|
|
|
|
|
self.ratio_label.setText(self.processor.stats.summary(self._t))
|
|
|
|
|
|