392 lines
13 KiB
Python
392 lines
13 KiB
Python
"""UI helpers and reusable Tk callbacks."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import tkinter as tk
|
||
import tkinter.font as tkfont
|
||
from tkinter import ttk
|
||
|
||
|
||
class UIBuilderMixin:
|
||
"""Constructs the Tkinter UI and common widgets."""
|
||
|
||
def setup_ui(self) -> None:
|
||
toolbar = ttk.Frame(self.root)
|
||
toolbar.pack(fill=tk.X, padx=12, pady=8)
|
||
buttons = [
|
||
("📂 Bild laden", self.load_image),
|
||
("📁 Ordner laden", self.load_folder),
|
||
("⬅️ Bild zurück", self.show_previous_image),
|
||
("➡️ Nächstes Bild", self.show_next_image),
|
||
("🎨 Farbe wählen", self.choose_color),
|
||
("🖱️Farbe aus Bild klicken", self.enable_pick_mode),
|
||
("💾 Overlay speichern", self.save_overlay),
|
||
("🧹 Excludes löschen", self.clear_excludes),
|
||
("↩️ Letztes Exclude entfernen", self.undo_exclude),
|
||
("🔄 Slider zurücksetzen", self.reset_sliders),
|
||
("🌓 Theme umschalten", self.toggle_theme),
|
||
]
|
||
self._toolbar_buttons: list[dict[str, object]] = []
|
||
|
||
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)
|
||
sliders = [
|
||
("Hue Min (°)", self.hue_min, 0, 360),
|
||
("Hue Max (°)", self.hue_max, 0, 360),
|
||
("Sättigung Min (%)", self.sat_min, 0, 100),
|
||
("Helligkeit Min (%)", self.val_min, 0, 100),
|
||
("Helligkeit Max (%)", self.val_max, 0, 100),
|
||
("Overlay Alpha", self.alpha, 0, 255),
|
||
]
|
||
for index, (label, variable, minimum, maximum) in enumerate(sliders):
|
||
self.add_slider_with_value(sliders_frame, label, variable, minimum, maximum, column=index)
|
||
sliders_frame.grid_columnconfigure(index, weight=1)
|
||
|
||
main = ttk.Frame(self.root)
|
||
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
|
||
|
||
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)
|
||
|
||
right_column = ttk.Frame(main)
|
||
right_column.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(6, 0))
|
||
|
||
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.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)
|
||
|
||
def add_slider_with_value(self, parent, text, var, minimum, maximum, column=0):
|
||
cell = ttk.Frame(parent)
|
||
cell.grid(row=0, column=column, sticky="we", padx=6)
|
||
header = ttk.Frame(cell)
|
||
header.pack(fill="x")
|
||
name_lbl = ttk.Label(header, text=text)
|
||
name_lbl.pack(side="left")
|
||
self._attach_copy_menu(name_lbl)
|
||
val_lbl = ttk.Label(header, text=f"{float(var.get()):.0f}")
|
||
val_lbl.pack(side="right")
|
||
self._attach_copy_menu(val_lbl)
|
||
style_name = getattr(self, "scale_style", "Horizontal.TScale")
|
||
ttk.Scale(
|
||
cell,
|
||
from_=minimum,
|
||
to=maximum,
|
||
orient="horizontal",
|
||
variable=var,
|
||
style=style_name,
|
||
command=self.on_slider_change,
|
||
).pack(fill="x", pady=(2, 8))
|
||
|
||
def on_var_change(*_):
|
||
val_lbl.config(text=f"{float(var.get()):.0f}")
|
||
|
||
try:
|
||
var.trace_add("write", on_var_change)
|
||
except Exception:
|
||
var.trace("w", lambda *_: on_var_change()) # type: ignore[attr-defined]
|
||
|
||
def on_slider_change(self, *_):
|
||
if self._update_job is not None:
|
||
try:
|
||
self.root.after_cancel(self._update_job)
|
||
except Exception:
|
||
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=24,
|
||
height=24,
|
||
highlightthickness=0,
|
||
background=hex_code,
|
||
bd=0,
|
||
relief="flat",
|
||
takefocus=1,
|
||
cursor="hand2",
|
||
)
|
||
swatch.pack(side=tk.LEFT, padx=4, pady=2)
|
||
|
||
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(cursor="hand2"))
|
||
swatch.bind("<Leave>", lambda _e: swatch.configure(cursor="arrow"))
|
||
|
||
def _add_toolbar_button(self, parent, text: str, command) -> None:
|
||
font = tkfont.Font(root=self.root, family="Segoe UI", size=9)
|
||
padding_x = 12
|
||
width = font.measure(text) + padding_x * 2
|
||
height = 28
|
||
radius = 9
|
||
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=4, pady=1)
|
||
|
||
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)
|
||
self.copy_menu.add_command(label="Kopieren", command=self._copy_current_label)
|
||
|
||
def _attach_copy_menu(self, widget):
|
||
widget.bind("<Button-3>", lambda event, w=widget: self._show_copy_menu(event, w))
|
||
widget.bind("<Control-c>", lambda event, w=widget: self._copy_widget_text(w))
|
||
|
||
def _show_copy_menu(self, event, widget):
|
||
self._copy_target = widget
|
||
try:
|
||
self.copy_menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
self.copy_menu.grab_release()
|
||
|
||
def _copy_current_label(self):
|
||
if self._copy_target is not None:
|
||
self._copy_widget_text(self._copy_target)
|
||
|
||
def _copy_widget_text(self, widget):
|
||
try:
|
||
text = widget.cget("text")
|
||
except Exception:
|
||
text = ""
|
||
if not text:
|
||
return
|
||
try:
|
||
self.root.clipboard_clear()
|
||
self.root.clipboard_append(text)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
__all__ = ["UIBuilderMixin"]
|