Refine UI layout and theme behavior

Generalize color selection, reorganize GUI, add presets, dynamic slider adjustments, config defaults, multiple UI updates etc
This commit is contained in:
lm
2025-10-15 20:18:21 +02:00
parent 02255f5dee
commit f9e8d01a70
8 changed files with 401 additions and 76 deletions
+77 -14
View File
@@ -11,19 +11,34 @@ class ColorPickerMixin:
"""Handles colour selection from dialogs and mouse clicks."""
ref_hue: float | None
hue_span: float = 45.0 # degrees around the picked hue
def choose_color(self):
rgb, hex_colour = colorchooser.askcolor(title="Farbe wählen")
if rgb is None:
return
r, g, b = [int(channel) for channel in rgb]
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
self.ref_hue = h * 360.0
span = 30
self.hue_min.set((self.ref_hue - span) % 360)
self.hue_max.set((self.ref_hue + span) % 360)
self.update_preview()
self.status.config(text=f"Farbe gewählt: {hex_colour} (Hue {self.ref_hue:.1f}°)")
r, g, b = (int(round(channel)) for channel in rgb)
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
label = hex_colour or f"RGB({r}, {g}, {b})"
self.status.config(
text=f"Farbe gewählt: {label} — Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
)
def apply_sample_colour(self, hex_colour: str, name: str | None = None) -> None:
"""Apply a predefined colour preset."""
rgb = self._parse_hex_colour(hex_colour)
if rgb is None:
return
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(*rgb)
if self.pick_mode:
self.pick_mode = False
label = name or hex_colour.upper()
self.status.config(
text=(
f"Beispielfarbe gewählt: {label} ({hex_colour}) — "
f"Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
)
)
def enable_pick_mode(self):
if self.preview_img is None:
@@ -47,14 +62,62 @@ class ColorPickerMixin:
r, g, b, a = self.preview_img.getpixel((x, y))
if a == 0:
return
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
self.ref_hue = h * 360.0
span = 30
self.hue_min.set((self.ref_hue - span) % 360)
self.hue_max.set((self.ref_hue + span) % 360)
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
self.disable_pick_mode()
self.status.config(
text=f"Farbe vom Bild gewählt: Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
)
def _apply_rgb_selection(self, r: int, g: int, b: int) -> tuple[float, float, float]:
"""Update slider ranges based on an RGB colour and return HSV summary."""
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
hue_deg = (h * 360.0) % 360.0
self.ref_hue = hue_deg
self._set_slider_targets(hue_deg, s, v)
self.update_preview()
self.status.config(text=f"Farbe vom Bild gewählt: Hue {self.ref_hue:.1f}°")
return hue_deg, s * 100.0, v * 100.0
def _set_slider_targets(self, hue_deg: float, saturation: float, value: float) -> None:
span = getattr(self, "hue_span", 45.0)
self.hue_min.set((hue_deg - span) % 360)
self.hue_max.set((hue_deg + span) % 360)
sat_pct = saturation * 100.0
sat_margin = 35.0
sat_min = max(0.0, min(100.0, sat_pct - sat_margin))
if saturation <= 0.05:
sat_min = 0.0
self.sat_min.set(sat_min)
v_pct = value * 100.0
val_margin = 35.0
val_min = max(0.0, v_pct - val_margin)
val_max = min(100.0, v_pct + val_margin)
if value <= 0.15:
val_max = min(45.0, max(val_max, 25.0))
if value >= 0.85:
val_min = max(55.0, min(val_min, 80.0))
if val_max <= val_min:
val_max = min(100.0, val_min + 10.0)
self.val_min.set(val_min)
self.val_max.set(val_max)
@staticmethod
def _parse_hex_colour(hex_colour: str | None) -> tuple[int, int, int] | None:
if not hex_colour:
return None
value = hex_colour.strip().lstrip("#")
if len(value) == 3:
value = "".join(ch * 2 for ch in value)
if len(value) != 6:
return None
try:
r = int(value[0:2], 16)
g = int(value[2:4], 16)
b = int(value[4:6], 16)
except ValueError:
return None
return r, g, b
__all__ = ["ColorPickerMixin"]
+14 -1
View File
@@ -57,7 +57,12 @@ class ThemeMixin:
else:
self.scale_style = "Horizontal.TScale"
bg, fg = ("#0f0f10", "#f1f1f1") if self.theme == "dark" else ("#f2f2f7", "#202020")
if self.theme == "dark":
bg, fg = "#0f0f10", "#f1f1f1"
status_fg = "#f5f5f5"
else:
bg, fg = "#ededf2", "#202020"
status_fg = "#1c1c1c"
self.root.configure(bg=bg) # type: ignore[attr-defined]
s = self.style
@@ -69,6 +74,14 @@ class ThemeMixin:
)
s.map("TButton", background=[("active", "#d0d0d0")])
button_refresher = getattr(self, "_refresh_toolbar_buttons_theme", None)
if callable(button_refresher):
button_refresher()
status_refresher = getattr(self, "_refresh_status_palette", None)
if callable(status_refresher) and hasattr(self, "status"):
status_refresher(status_fg)
def detect_system_theme(self) -> str:
"""Best-effort detection of the OS theme preference."""
try:
+264 -25
View File
@@ -3,10 +3,9 @@
from __future__ import annotations
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk
from .theme import HAS_TTKBOOTSTRAP
class UIBuilderMixin:
"""Constructs the Tkinter UI and common widgets."""
@@ -24,13 +23,33 @@ class UIBuilderMixin:
("🔄 Slider zurücksetzen", self.reset_sliders),
("🌓 Theme umschalten", self.toggle_theme),
]
for text, command in buttons:
if HAS_TTKBOOTSTRAP:
from ttkbootstrap import Button # type: ignore
self._toolbar_buttons: list[dict[str, object]] = []
Button(toolbar, text=text, command=command, bootstyle="secondary").pack(side=tk.LEFT, padx=6)
else:
ttk.Button(toolbar, text=text, command=command).pack(side=tk.LEFT, padx=6)
buttons_frame = ttk.Frame(toolbar)
buttons_frame.pack(side=tk.LEFT)
for text, command in buttons:
self._add_toolbar_button(buttons_frame, text, command)
status_container = ttk.Frame(toolbar)
status_container.pack(side=tk.RIGHT, expand=True, fill=tk.X)
self.status = ttk.Label(
status_container,
text="Keine Datei geladen.",
anchor="e",
foreground="#efefef",
)
self.status.pack(fill=tk.X)
self._attach_copy_menu(self.status)
self.status_default_text = self.status.cget("text")
self._status_palette = {"fg": self.status.cget("foreground")}
palette_frame = ttk.Frame(self.root)
palette_frame.pack(fill=tk.X, padx=12, pady=(0, 8))
ttk.Label(palette_frame, text="Beispielfarben:").pack(side=tk.LEFT, padx=(0, 8))
swatch_container = ttk.Frame(palette_frame)
swatch_container.pack(side=tk.LEFT)
for name, hex_code in self._preset_colours():
self._add_palette_swatch(swatch_container, name, hex_code)
sliders_frame = ttk.Frame(self.root)
sliders_frame.pack(fill=tk.X, padx=12, pady=4)
@@ -49,30 +68,45 @@ class UIBuilderMixin:
main = ttk.Frame(self.root)
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
self.canvas_orig = tk.Canvas(main, bg="#1e1e1e", highlightthickness=0, relief="flat")
self.canvas_orig.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 6))
left_column = ttk.Frame(main)
left_column.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 6))
self.canvas_orig = tk.Canvas(left_column, bg="#1e1e1e", highlightthickness=0, relief="flat")
self.canvas_orig.pack(fill=tk.BOTH, expand=True)
self.canvas_orig.bind("<Button-1>", self.on_canvas_click)
self.canvas_orig.bind("<ButtonPress-3>", self._exclude_start)
self.canvas_orig.bind("<B3-Motion>", self._exclude_drag)
self.canvas_orig.bind("<ButtonRelease-3>", self._exclude_end)
self.canvas_overlay = tk.Canvas(main, bg="#1e1e1e", highlightthickness=0, relief="flat")
self.canvas_overlay.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(6, 0))
right_column = ttk.Frame(main)
right_column.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(6, 0))
status_frame = ttk.Frame(self.root)
status_frame.pack(fill=tk.X, padx=12, pady=6)
self.status = ttk.Label(status_frame, text="Keine Datei geladen.")
self.status.pack(anchor="w")
self._attach_copy_menu(self.status)
self.ratio_label = ttk.Label(status_frame, text="Purple (mit Excludes): —")
self.ratio_label.pack(anchor="w")
self._attach_copy_menu(self.ratio_label)
self.hint = ttk.Label(
status_frame,
text="Tipp: Rechtsklick + Ziehen auf dem linken Bild, um Bereiche auszuschließen. Esc beendet den Pick-Modus.",
self.canvas_overlay = tk.Canvas(right_column, bg="#1e1e1e", highlightthickness=0, relief="flat")
self.canvas_overlay.pack(fill=tk.BOTH, expand=True)
info_frame = ttk.Frame(self.root)
info_frame.pack(fill=tk.X, padx=12, pady=(0, 12))
self.filename_label = ttk.Label(
info_frame,
text="",
foreground="#f2c744",
font=("Segoe UI", 10, "bold"),
anchor="center",
justify="center",
)
self.hint.pack(anchor="w", pady=(2, 0))
self._attach_copy_menu(self.hint)
self.filename_label.pack(anchor="center")
self._attach_copy_menu(self.filename_label)
self.ratio_label = ttk.Label(
info_frame,
text="Treffer (mit Excludes): —",
foreground="#f2c744",
font=("Segoe UI", 10, "bold"),
anchor="center",
justify="center",
)
self.ratio_label.pack(anchor="center", pady=(4, 0))
self._attach_copy_menu(self.ratio_label)
self.root.bind("<Escape>", self.disable_pick_mode)
@@ -114,6 +148,211 @@ class UIBuilderMixin:
pass
self._update_job = self.root.after(self.update_delay_ms, self.update_preview)
def _preset_colours(self):
return [
("Rot", "#ff3b30"),
("Orange", "#ff9500"),
("Gelb", "#ffd60a"),
("Grün", "#34c759"),
("Türkis", "#5ac8fa"),
("Blau", "#0a84ff"),
("Violett", "#af52de"),
("Magenta", "#ff2d55"),
("Weiß", "#ffffff"),
("Grau", "#8e8e93"),
("Schwarz", "#000000"),
]
def _add_palette_swatch(self, parent, name: str, hex_code: str) -> None:
swatch = tk.Canvas(
parent,
width=26,
height=26,
highlightthickness=1,
highlightbackground="#b1b1b6",
background="#ffffff",
bd=0,
relief="flat",
takefocus=1,
cursor="hand2",
)
swatch.pack(side=tk.LEFT, padx=4, pady=2)
swatch.create_rectangle(3, 3, 23, 23, outline="#4a4a4a", fill=hex_code)
def trigger(_event=None, colour=hex_code, label=name):
self.apply_sample_colour(colour, label)
swatch.bind("<Button-1>", trigger)
swatch.bind("<space>", trigger)
swatch.bind("<Return>", trigger)
swatch.bind("<Enter>", lambda _e: swatch.configure(highlightbackground="#71717a"))
swatch.bind("<Leave>", lambda _e: swatch.configure(highlightbackground="#b1b1b6"))
def _add_toolbar_button(self, parent, text: str, command) -> None:
font = tkfont.Font(root=self.root, family="Segoe UI", size=10, weight="bold")
padding_x = 18
width = font.measure(text) + padding_x * 2
height = 32
radius = 12
bg = self.root.cget("bg") if hasattr(self.root, "cget") else "#f2f2f7"
canvas = tk.Canvas(
parent,
width=width,
height=height,
bd=0,
highlightthickness=0,
bg=bg,
relief="flat",
cursor="hand2",
takefocus=1,
)
canvas.pack(side=tk.LEFT, padx=6)
palette = self._toolbar_palette()
rect_id = self._create_round_rect(
canvas,
1,
1,
width - 1,
height - 1,
radius,
fill=palette["normal"],
outline=palette["outline"],
width=1,
)
text_id = canvas.create_text(
width / 2,
height / 2,
text=text,
font=font,
fill=palette["text"],
)
button_data = {
"canvas": canvas,
"rect": rect_id,
"text_id": text_id,
"command": command,
"palette": palette.copy(),
"dimensions": (width, height, radius),
}
self._toolbar_buttons.append(button_data)
def set_fill(state: str) -> None:
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
canvas.itemconfigure(rect_id, fill=pal[state]) # type: ignore[index]
def execute():
command()
def on_press(_event=None):
set_fill("active")
def on_release(event=None):
if event is not None and (
event.x < 0 or event.y < 0 or event.x > width or event.y > height
):
set_fill("normal")
return
set_fill("hover")
self.root.after_idle(execute)
def on_enter(_event):
set_fill("hover")
def on_leave(_event):
set_fill("normal")
def on_focus_in(_event):
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
canvas.itemconfigure(rect_id, outline=pal["outline_focus"]) # type: ignore[index]
def on_focus_out(_event):
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
canvas.itemconfigure(rect_id, outline=pal["outline"]) # type: ignore[index]
def invoke_keyboard(_event=None):
set_fill("active")
canvas.after(120, lambda: set_fill("hover"))
self.root.after_idle(execute)
canvas.bind("<ButtonPress-1>", on_press)
canvas.bind("<ButtonRelease-1>", on_release)
canvas.bind("<Enter>", on_enter)
canvas.bind("<Leave>", on_leave)
canvas.bind("<FocusIn>", on_focus_in)
canvas.bind("<FocusOut>", on_focus_out)
canvas.bind("<space>", invoke_keyboard)
canvas.bind("<Return>", invoke_keyboard)
@staticmethod
def _create_round_rect(canvas: tk.Canvas, x1, y1, x2, y2, radius, **kwargs):
points = [
x1 + radius,
y1,
x2 - radius,
y1,
x2,
y1,
x2,
y1 + radius,
x2,
y2 - radius,
x2,
y2,
x2 - radius,
y2,
x1 + radius,
y2,
x1,
y2,
x1,
y2 - radius,
x1,
y1 + radius,
x1,
y1,
]
return canvas.create_polygon(points, smooth=True, splinesteps=24, **kwargs)
def _toolbar_palette(self) -> dict[str, str]:
is_dark = getattr(self, "theme", "light") == "dark"
if is_dark:
return {
"normal": "#2f2f35",
"hover": "#3a3a40",
"active": "#1f1f25",
"outline": "#4d4d50",
"outline_focus": "#7c7c88",
"text": "#f1f1f5",
}
return {
"normal": "#ffffff",
"hover": "#ededf4",
"active": "#dcdce6",
"outline": "#d0d0d8",
"outline_focus": "#a9a9b2",
"text": "#1f1f1f",
}
def _refresh_toolbar_buttons_theme(self) -> None:
if not getattr(self, "_toolbar_buttons", None):
return
bg = self.root.cget("bg") if hasattr(self.root, "cget") else "#f2f2f7"
palette = self._toolbar_palette()
for data in self._toolbar_buttons:
canvas = data["canvas"] # type: ignore[index]
rect_id = data["rect"] # type: ignore[index]
text_id = data["text_id"] # type: ignore[index]
data["palette"] = palette.copy()
canvas.configure(bg=bg)
canvas.itemconfigure(rect_id, fill=palette["normal"], outline=palette["outline"])
canvas.itemconfigure(text_id, fill=palette["text"])
def _refresh_status_palette(self, fg: str) -> None:
self.status.configure(foreground=fg)
self._status_palette["fg"] = fg
def _init_copy_menu(self):
self._copy_target = None
self.copy_menu = tk.Menu(self.root, tearoff=0)