Replace toolbar with categorized menu system, standardize 'color', and improve CSV export

- Migrated toolbar to QMenuBar to fix UI crowding

- Categorized actions into File, Edit, Tools, and View

- Added dynamic theming to QMenuBar and QMenu

- Localized Export CSV delimiter and decimals for German Excel

- Padded exported CSV values for clean plain-text alignment

- Globally standardized the spelling of 'color'

- Removed duplicate code and old Tkinter codebase
This commit is contained in:
lm
2026-03-10 16:54:23 +01:00
parent 95907d6314
commit 551f5a6b8f
21 changed files with 393 additions and 2076 deletions
+11 -6
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
from PySide6 import QtCore, QtGui, QtWidgets
from app.logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE
from .main_window import MainWindow
@@ -46,16 +46,21 @@ def create_application() -> QtWidgets.QApplication:
def run() -> int:
"""Run the PySide6 GUI."""
app = create_application()
from app.logic import OVERLAY_COLOR
window = MainWindow(
language=LANGUAGE,
defaults=DEFAULTS.copy(),
reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
overlay_color=OVERLAY_COLOR,
)
primary_screen = app.primaryScreen()
if primary_screen is not None:
geometry = primary_screen.availableGeometry()
window.setGeometry(geometry)
window.showMaximized()
# Respect saved geometry from QSettings; fall back to maximised on first launch
settings = QtCore.QSettings("ICRA", "MainWindow")
if settings.value("geometry"):
window.show()
else:
primary_screen = app.primaryScreen()
if primary_screen is not None:
window.setGeometry(primary_screen.availableGeometry())
window.showMaximized()
return app.exec()
+23 -4
View File
@@ -55,7 +55,8 @@ def _rgb_to_hsv_numpy(arr: np.ndarray) -> np.ndarray:
v = cmax
# Saturation
s = np.where(cmax > 0, delta / cmax, 0.0)
s = np.zeros_like(r)
np.divide(delta, cmax, out=s, where=cmax > 0)
# Hue
h = np.zeros_like(r)
@@ -80,6 +81,11 @@ class QtImageProcessor:
self.current_index: int = -1
self.stats = Stats()
# Overlay tint color
self.overlay_r = 255
self.overlay_g = 0
self.overlay_b = 0
self.defaults: Dict[str, int] = {
"hue_min": 0,
"hue_max": 360,
@@ -163,7 +169,7 @@ class QtImageProcessor:
self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
def _rebuild_overlay(self) -> None:
"""Build colour-match overlay using vectorized NumPy operations."""
"""Build color-match overlay using vectorized NumPy operations."""
if self.preview_img is None:
self.overlay_img = None
self.stats = Stats()
@@ -212,7 +218,9 @@ class QtImageProcessor:
# Build overlay image
overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8)
overlay_arr[keep_match, 0] = 255
overlay_arr[keep_match, 0] = self.overlay_r
overlay_arr[keep_match, 1] = self.overlay_g
overlay_arr[keep_match, 2] = self.overlay_b
overlay_arr[keep_match, 3] = int(self.alpha)
self.overlay_img = Image.fromarray(overlay_arr, "RGBA")
@@ -239,7 +247,7 @@ class QtImageProcessor:
val_ok = self.val_min <= v * 100.0 <= self.val_max
return hue_ok and sat_ok and val_ok
def pick_colour(self, x: int, y: int) -> Tuple[float, float, float] | None:
def pick_color(self, x: int, y: int) -> Tuple[float, float, float] | None:
"""Return (hue°, sat%, val%) of the preview pixel at (x, y), or None."""
if self.preview_img is None:
return None
@@ -303,6 +311,17 @@ class QtImageProcessor:
draw.polygon(points, fill=255)
return mask
def set_overlay_color(self, hex_code: str) -> None:
"""Set the RGB channels for the match overlay from a hex string."""
if not hex_code.startswith("#") or len(hex_code) not in (7, 9):
return
try:
self.overlay_r = int(hex_code[1:3], 16)
self.overlay_g = int(hex_code[3:5], 16)
self.overlay_b = int(hex_code[5:7], 16)
except ValueError:
pass
def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray:
"""Return a boolean (H, W) mask — True where pixels are excluded."""
w, h = size
+225 -130
View File
@@ -2,18 +2,20 @@
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_COLOUR = "#763e92"
DEFAULT_COLOR = "#763e92"
PRESET_COLOURS: List[Tuple[str, str]] = [
PRESET_COLORS: List[Tuple[str, str]] = [
("palette.swatch.red", "#ff3b30"),
("palette.swatch.orange", "#ff9500"),
("palette.swatch.yellow", "#ffd60a"),
@@ -64,41 +66,8 @@ THEMES: Dict[str, Dict[str, str]] = {
}
class ToolbarButton(QtWidgets.QPushButton):
"""Rounded toolbar button inspired by the legacy design."""
def __init__(self, icon_text: str, label: str, callback: Callable[[], None], parent: QtWidgets.QWidget | None = None):
text = f"{icon_text} {label}"
super().__init__(text, parent)
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedHeight(32)
metrics = QtGui.QFontMetrics(self.font())
width = metrics.horizontalAdvance(text) + 28
self.setMinimumWidth(width)
self.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):
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):
@@ -108,10 +77,10 @@ class ColourSwatch(QtWidgets.QPushButton):
self.callback = callback
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedSize(28, 28)
self._apply_colour(hex_code)
self._apply_color(hex_code)
self.clicked.connect(lambda: callback(hex_code, self.name_key))
def _apply_colour(self, hex_code: str) -> None:
def _apply_color(self, hex_code: str) -> None:
self.setStyleSheet(
f"""
QPushButton {{
@@ -125,16 +94,16 @@ class ColourSwatch(QtWidgets.QPushButton):
"""
)
def apply_theme(self, colours: Dict[str, str]) -> None:
def apply_theme(self, colors: Dict[str, str]) -> None:
self.setStyleSheet(
f"""
QPushButton {{
background-color: {self.hex_code};
border: 2px solid {colours['border']};
border: 2px solid {colors['border']};
border-radius: 6px;
}}
QPushButton:hover {{
border-color: {colours['accent']};
border-color: {colors['accent']};
}}
"""
)
@@ -194,22 +163,22 @@ class SliderControl(QtWidgets.QWidget):
self.slider.blockSignals(False)
self.value_edit.setText(str(value))
def apply_theme(self, colours: Dict[str, str]) -> None:
self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
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: {colours['text_dim']}; background: transparent; "
f"border: 1px solid {colours['border']}; border-radius: 4px; padding: 0 2px;"
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 {colours['border']};
border: 1px solid {colors['border']};
height: 6px;
background: rgba(255,255,255,0.14);
border-radius: 4px;
}}
QSlider::handle:horizontal {{
background: {colours['accent_secondary']};
background: {colors['accent_secondary']};
border: 1px solid rgba(255,255,255,0.2);
width: 14px;
margin: -5px 0;
@@ -271,8 +240,8 @@ class CanvasView(QtWidgets.QGraphicsView):
def set_mode(self, mode: str) -> None:
self.mode = mode
def set_accent(self, colour: str) -> None:
self._accent = QtGui.QColor(colour)
def set_accent(self, color: str) -> None:
self._accent = QtGui.QColor(color)
self._redraw_shapes()
def undo_last(self) -> None:
@@ -399,7 +368,7 @@ class CanvasView(QtWidgets.QGraphicsView):
class OverlayCanvas(QtWidgets.QGraphicsView):
"""Read-only QGraphicsView for displaying the colour-match overlay."""
"""Read-only QGraphicsView for displaying the color-match overlay."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
@@ -488,17 +457,17 @@ class TitleBar(QtWidgets.QWidget):
)
return btn
def apply_theme(self, colours: Dict[str, str]) -> None:
def apply_theme(self, colors: Dict[str, str]) -> None:
palette = self.palette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colours["titlebar_bg"]))
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(colors["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.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: {colours['text']};
color: {colors['text']};
border: none;
padding: 4px 10px;
}}
@@ -513,7 +482,7 @@ class TitleBar(QtWidgets.QWidget):
f"""
QPushButton {{
background-color: transparent;
color: {colours['text']};
color: {colors['text']};
border: none;
padding: 4px 10px;
}}
@@ -538,7 +507,7 @@ class TitleBar(QtWidgets.QWidget):
class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"""Main application window containing all controls."""
def __init__(self, language: str, defaults: dict, reset_exclusions: bool) -> None:
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"))
@@ -560,12 +529,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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, 24, 24, 24)
self.content_layout.setContentsMargins(24, 0, 24, 24)
self.content_layout.setSpacing(18)
self.content_layout.addLayout(self._build_toolbar())
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)
@@ -576,7 +548,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._is_maximised = False
self._current_image_path: Path | None = None
self._current_colour = DEFAULT_COLOUR
self._current_color = DEFAULT_COLOR
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
self._register_default_actions()
@@ -587,7 +559,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.image_view.pixel_clicked.connect(self._on_pixel_picked)
self._sync_sliders_from_processor()
self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current"))
self._update_color_display(DEFAULT_COLOR, self._t("palette.current"))
self.current_theme = "dark"
self._apply_theme(self.current_theme)
@@ -598,6 +570,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# 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")
@@ -626,33 +604,38 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# UI builders ------------------------------------------------------------
def _build_toolbar(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(12)
def _build_menu_bar(self) -> QtWidgets.QMenuBar:
self.menu_bar = QtWidgets.QMenuBar(self)
buttons = [
("open_image", "🖼", "toolbar.open_image"),
("open_folder", "📂", "toolbar.open_folder"),
("choose_color", "🎨", "toolbar.choose_color"),
("pick_from_image", "🖱", "toolbar.pick_from_image"),
("save_overlay", "💾", "toolbar.save_overlay"),
("toggle_free_draw", "", "toolbar.toggle_free_draw"),
("clear_excludes", "🧹", "toolbar.clear_excludes"),
("undo_exclude", "", "toolbar.undo_exclude"),
("reset_sliders", "🔄", "toolbar.reset_sliders"),
("toggle_theme", "🌓", "toolbar.toggle_theme"),
]
self._toolbar_buttons: Dict[str, ToolbarButton] = {}
for key, icon_txt, text_key in buttons:
label = self._t(text_key)
button = ToolbarButton(icon_txt, label, lambda _checked=False, k=key: self._invoke_action(k))
layout.addWidget(button)
self._toolbar_buttons[key] = button
# 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")
layout.addStretch(1)
# 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"))
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout
return self.menu_bar
def _build_palette(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
@@ -664,13 +647,13 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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_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_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})")
current_group.addWidget(self.current_colour_label)
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"))
@@ -678,13 +661,15 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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)
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:
@@ -754,7 +739,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._toolbar_actions = {
"open_image": self.open_image,
"open_folder": self.open_folder,
"choose_color": self.choose_colour,
"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,
@@ -810,6 +796,83 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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"))
@@ -838,17 +901,17 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
# 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})")
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._refresh_overlay_only()
self._slider_timer.start()
def _reset_sliders(self) -> None:
for _, attr, _, _ in SLIDER_SPECS:
@@ -904,7 +967,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.status_label.setText(self._t("status.pick_mode_ended"))
def _on_pixel_picked(self, x: int, y: int) -> None:
result = self.processor.pick_colour(x, y)
result = self.processor.pick_color(x, y)
if result is None:
self._exit_pick_mode()
return
@@ -927,14 +990,14 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
ctrl.set_value(value)
self.processor.set_threshold(attr, value)
# Update colour swatch to the picked pixel colour
# 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_colour_display(hex_code, "")
self._update_color_display(hex_code, "")
self.status_label.setText(
self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val)
@@ -988,12 +1051,12 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._settings.setValue("geometry", self.saveGeometry())
super().closeEvent(event)
def choose_colour(self) -> None:
colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title"))
if not colour.isValid():
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 = colour.name()
self._update_colour_display(hex_code, self._t("dialog.choose_colour_title"))
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()
@@ -1034,34 +1097,66 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
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']};")
colors = THEMES[mode]
self.content.setStyleSheet(f"background-color: {colors['window_bg']};")
self.image_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;"
)
self.image_view.set_accent(colours["highlight"])
self.image_view.set_accent(colors["highlight"])
self.overlay_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
f"background-color: {colors['panel_bg']}; border: 1px solid {colors['border']}; border-radius: 12px;"
)
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;")
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 button in self._toolbar_buttons.values():
button.apply_theme(colours)
for swatch in self.swatch_buttons:
swatch.apply_theme(colours)
swatch.apply_theme(colors)
for control in self._slider_controls.values():
control.apply_theme(colours)
control.apply_theme(colors)
self._style_nav_button(self.prev_button)
self._style_nav_button(self.next_button)
self.title_bar.apply_theme(colours)
self.title_bar.apply_theme(colors)
def _sync_sliders_from_processor(self) -> None:
for _, attr, _, _ in SLIDER_SPECS:
@@ -1076,11 +1171,11 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return key
def _style_nav_button(self, button: QtWidgets.QToolButton) -> None:
colours = THEMES[self.current_theme]
colors = THEMES[self.current_theme]
button.setStyleSheet(
f"QToolButton {{ border-radius: 19px; background-color: {colours['panel_bg']}; "
f"border: 1px solid {colours['border']}; color: {colours['text']}; }}"
f"QToolButton:hover {{ background-color: {colours['accent_secondary']}; color: white; }}"
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):
@@ -1133,8 +1228,8 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return pixmap
result = QtGui.QPixmap(pixmap)
painter = QtGui.QPainter(result)
colour = QtGui.QColor(THEMES[self.current_theme]["highlight"])
pen = QtGui.QPen(colour)
color = QtGui.QColor(THEMES[self.current_theme]["highlight"])
pen = QtGui.QPen(color)
pen.setWidth(3)
pen.setCosmetic(True)
pen.setCapStyle(QtCore.Qt.RoundCap)