"""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), ("🎨 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("", self.on_canvas_click) self.canvas_orig.bind("", self._exclude_start) self.canvas_orig.bind("", self._exclude_drag) self.canvas_orig.bind("", 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("", 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=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("", trigger) swatch.bind("", trigger) swatch.bind("", trigger) swatch.bind("", lambda _e: swatch.configure(highlightbackground="#71717a")) swatch.bind("", 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("", on_press) canvas.bind("", on_release) canvas.bind("", on_enter) canvas.bind("", on_leave) canvas.bind("", on_focus_in) canvas.bind("", on_focus_out) canvas.bind("", invoke_keyboard) canvas.bind("", 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("", lambda event, w=widget: self._show_copy_menu(event, w)) widget.bind("", 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"]