"""UI helpers and reusable Tk callbacks.""" from __future__ import annotations import colorsys 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: self._create_titlebar() toolbar = ttk.Frame(self.root) toolbar.pack(fill=tk.X, padx=12, pady=(4, 2)) buttons = [ ("📂", "Bild laden", self.load_image), ("📁", "Ordner laden", self.load_folder), ("🎨", "Farbe wählen", self.choose_color), ("🖱", "Farbe aus Bild klicken", self.enable_pick_mode), ("💾", "Overlay speichern", self.save_overlay), ("🧹", "Ausschlüsse löschen", self.clear_excludes), ("↩", "Letzten Ausschluss entfernen", self.undo_exclude), ("🔄", "Slider zurücksetzen", self.reset_sliders), ("🌓", "Theme umschalten", self.toggle_theme), ] self._toolbar_buttons: list[dict[str, object]] = [] self._nav_buttons: list[tk.Button] = [] buttons_frame = ttk.Frame(toolbar) buttons_frame.pack(side=tk.LEFT) for icon, label, command in buttons: self._add_toolbar_button(buttons_frame, icon, label, 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=(6, 8)) default_colour = self._default_colour_hex() self.current_colour_sw = tk.Canvas( palette_frame, width=24, height=24, highlightthickness=0, background=default_colour, bd=0, ) self.current_colour_sw.pack(side=tk.LEFT, padx=(0, 8), pady=2) self.current_colour_label = ttk.Label(palette_frame, text=f"Aktuelle Farbe ({default_colour})") self.current_colour_label.pack(side=tk.LEFT, padx=(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)) left_column.grid_columnconfigure(1, weight=1) left_column.grid_rowconfigure(0, weight=1) self._create_navigation_button(left_column, "◀", self.show_previous_image, column=0) self.canvas_orig = tk.Canvas(left_column, bg="#1e1e1e", highlightthickness=0, relief="flat") self.canvas_orig.grid(row=0, column=1, sticky="nsew") 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)) right_column.grid_columnconfigure(0, weight=1) right_column.grid_rowconfigure(0, weight=1) self.canvas_overlay = tk.Canvas(right_column, bg="#1e1e1e", highlightthickness=0, relief="flat") self.canvas_overlay.grid(row=0, column=0, sticky="nsew") self._create_navigation_button(right_column, "▶", self.show_next_image, column=1) 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="—", 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="Markierungen (mit Ausschlüssen): —", 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) self.root.bind("", self._maybe_focus_window) 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("", trigger) swatch.bind("", trigger) swatch.bind("", trigger) swatch.bind("", lambda _e: swatch.configure(cursor="hand2")) swatch.bind("", lambda _e: swatch.configure(cursor="arrow")) def _add_toolbar_button(self, parent, icon: str, label: str, command) -> None: font = tkfont.Font(root=self.root, family="Segoe UI", size=9) padding_x = 12 gap = font.measure(" ") icon_width = font.measure(icon) label_width = font.measure(label) width = padding_x * 2 + icon_width + gap + label_width 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, ) icon_id = canvas.create_text( padding_x, height / 2, text=icon, font=font, fill=palette["text"], anchor="w", ) label_id = canvas.create_text( padding_x + icon_width + gap, height / 2, text=label, font=font, fill=palette["text"], anchor="w", ) button_data = { "canvas": canvas, "rect": rect_id, "text_ids": (icon_id, label_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 _create_navigation_button(self, container, symbol: str, command, *, column: int) -> None: palette = self._navigation_palette() bg = palette["bg"] fg = palette["fg"] container.grid_rowconfigure(0, weight=1) btn = tk.Button( container, text=symbol, command=command, font=("Segoe UI", 26, "bold"), relief="flat", borderwidth=0, background=bg, activebackground=bg, highlightthickness=0, fg=fg, activeforeground=fg, cursor="hand2", width=2, ) btn.grid(row=0, column=column, sticky="ns", padx=6) self._nav_buttons.append(btn) def _create_titlebar(self) -> None: bar_bg = "#1f1f1f" title_bar = tk.Frame(self.root, bg=bar_bg, relief="flat", height=34) title_bar.pack(fill=tk.X, side=tk.TOP) title_bar.pack_propagate(False) logo = None try: from PIL import Image, ImageTk # type: ignore from importlib import resources logo_resource = resources.files("app.assets").joinpath("logo.png") with resources.as_file(logo_resource) as logo_path: image = Image.open(logo_path).convert("RGBA") image.thumbnail((26, 26)) logo = ImageTk.PhotoImage(image) except Exception: logo = None if logo is not None: logo_label = tk.Label(title_bar, image=logo, bg=bar_bg) logo_label.image = logo # keep reference logo_label.pack(side=tk.LEFT, padx=(10, 6), pady=4) title_label = tk.Label( title_bar, text="Interactive Color Range Analyzer", bg=bar_bg, fg="#f5f5f5", font=("Segoe UI", 11, "bold"), anchor="w", ) title_label.pack(side=tk.LEFT, padx=6) close_btn = tk.Button( title_bar, text="✕", command=self._close_app, bg=bar_bg, fg="#f5f5f5", activebackground="#ff3b30", activeforeground="#ffffff", borderwidth=0, highlightthickness=0, relief="flat", font=("Segoe UI", 10, "bold"), cursor="hand2", width=3, ) close_btn.pack(side=tk.RIGHT, padx=8, pady=4) close_btn.bind("", lambda _e: close_btn.configure(bg="#cf212f")) close_btn.bind("", lambda _e: close_btn.configure(bg=bar_bg)) for widget in (title_bar, title_label): widget.bind("", self._start_window_drag) widget.bind("", self._perform_window_drag) def _close_app(self) -> None: try: self.root.destroy() except Exception: pass def _start_window_drag(self, event) -> None: self._drag_offset = (event.x_root - self.root.winfo_rootx(), event.y_root - self.root.winfo_rooty()) def _perform_window_drag(self, event) -> None: offset = getattr(self, "_drag_offset", None) if offset is None: return x = event.x_root - offset[0] y = event.y_root - offset[1] self.root.geometry(f"+{x}+{y}") def _maybe_focus_window(self, _event) -> None: try: self.root.focus_set() except Exception: pass 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 _navigation_palette(self) -> dict[str, str]: is_dark = getattr(self, "theme", "light") == "dark" default_bg = "#0f0f10" if is_dark else "#ededf2" bg = self.root.cget("bg") if hasattr(self.root, "cget") else default_bg fg = "#f5f5f5" if is_dark else "#1f1f1f" return {"bg": bg, "fg": fg} 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_ids = data["text_ids"] # type: ignore[index] data["palette"] = palette.copy() canvas.configure(bg=bg) canvas.itemconfigure(rect_id, fill=palette["normal"], outline=palette["outline"]) for text_id in text_ids: canvas.itemconfigure(text_id, fill=palette["text"]) def _refresh_navigation_buttons_theme(self) -> None: if not getattr(self, "_nav_buttons", None): return palette = self._navigation_palette() for btn in self._nav_buttons: btn.configure( background=palette["bg"], activebackground=palette["bg"], fg=palette["fg"], activeforeground=palette["fg"], ) def _refresh_status_palette(self, fg: str) -> None: self.status.configure(foreground=fg) self._status_palette["fg"] = fg def _refresh_accent_labels(self, colour: str) -> None: try: self.filename_label.configure(foreground=colour) self.ratio_label.configure(foreground=colour) except Exception: pass def _default_colour_hex(self) -> str: defaults = getattr(self, "DEFAULTS", {}) hue_min = float(defaults.get("hue_min", 0.0)) hue_max = float(defaults.get("hue_max", hue_min)) if hue_min <= hue_max: hue = (hue_min + hue_max) / 2.0 else: span = ((hue_max + 360.0) - hue_min) / 2.0 hue = (hue_min + span) % 360.0 sat_min = float(defaults.get("sat_min", 0.0)) saturation = (sat_min + 100.0) / 2.0 val_min = float(defaults.get("val_min", 0.0)) val_max = float(defaults.get("val_max", 100.0)) value = (val_min + val_max) / 2.0 r, g, b = colorsys.hsv_to_rgb(hue / 360.0, saturation / 100.0, value / 100.0) return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" 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"]