diff --git a/app/gui/ui.py b/app/gui/ui.py index 37e406f..9597d0c 100644 --- a/app/gui/ui.py +++ b/app/gui/ui.py @@ -1,521 +1,521 @@ -"""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: - 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), - ("🧹 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]] = [] - self._nav_buttons: list[tk.Button] = [] - - 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=(6, 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="—", - 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) - 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, 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("", 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) - - 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_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_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 _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"] +"""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: + 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), + ("🧹 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]] = [] + self._nav_buttons: list[tk.Button] = [] + + 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=(6, 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="—", + 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) + 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, text: str, command) -> None: + font = tkfont.Font(root=self.root, family="Segoe UI", size=9) + padding_x = 18 + 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("", 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) + + 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_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_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 _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"]