"""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 = [ ("๐Ÿ–ผ", self._t("toolbar.open_image"), self.load_image), ("๐Ÿ“‚", self._t("toolbar.open_folder"), self.load_folder), ("๐ŸŽจ", self._t("toolbar.choose_color"), self.choose_color), ("๐Ÿ–ฑ", self._t("toolbar.pick_from_image"), self.enable_pick_mode), ("๐Ÿ’พ", self._t("toolbar.save_overlay"), self.save_overlay), ("โ–ณ", self._t("toolbar.toggle_free_draw"), self.toggle_exclusion_mode), ("๐Ÿงน", self._t("toolbar.clear_excludes"), self.clear_excludes), ("โ†ฉ", self._t("toolbar.undo_exclude"), self.undo_exclude), ("๐Ÿ”„", self._t("toolbar.reset_sliders"), self.reset_sliders), ("๐ŸŒ“", self._t("toolbar.toggle_theme"), 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=self._t("status.no_file"), 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() current_frame = ttk.Frame(palette_frame) current_frame.pack(side=tk.LEFT, padx=(0, 16)) ttk.Label(current_frame, text=self._t("palette.current")).pack(side=tk.LEFT, padx=(0, 6)) self.current_colour_sw = tk.Canvas( current_frame, width=24, height=24, highlightthickness=0, background=default_colour, bd=0, ) self.current_colour_sw.pack(side=tk.LEFT, pady=2) self.current_colour_label = ttk.Label(current_frame, text=f"({default_colour})") self.current_colour_label.pack(side=tk.LEFT, padx=(6, 0)) ttk.Label(palette_frame, text=self._t("palette.more")).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 = [ (self._t("sliders.hue_min"), self.hue_min, 0, 360), (self._t("sliders.hue_max"), self.hue_max, 0, 360), (self._t("sliders.sat_min"), self.sat_min, 0, 100), (self._t("sliders.val_min"), self.val_min, 0, 100), (self._t("sliders.val_max"), self.val_max, 0, 100), (self._t("sliders.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=self._canvas_background_colour(), 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=self._canvas_background_colour(), 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=self._t("stats.placeholder"), 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 [ (self._t("palette.swatch.red"), "#ff3b30"), (self._t("palette.swatch.orange"), "#ff9500"), (self._t("palette.swatch.yellow"), "#ffd60a"), (self._t("palette.swatch.green"), "#34c759"), (self._t("palette.swatch.teal"), "#5ac8fa"), (self._t("palette.swatch.blue"), "#0a84ff"), (self._t("palette.swatch.violet"), "#af52de"), (self._t("palette.swatch.magenta"), "#ff2d55"), (self._t("palette.swatch.white"), "#ffffff"), (self._t("palette.swatch.grey"), "#8e8e93"), (self._t("palette.swatch.black"), "#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) or font.measure(" ") 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=self._t("app.title"), bg=bar_bg, fg="#f5f5f5", font=("Segoe UI", 11, "bold"), anchor="w", ) title_label.pack(side=tk.LEFT, padx=6) btn_kwargs = { "bg": bar_bg, "fg": "#f5f5f5", "activebackground": "#3a3a40", "activeforeground": "#ffffff", "borderwidth": 0, "highlightthickness": 0, "relief": "flat", "font": ("Segoe UI", 10, "bold"), "cursor": "hand2", "width": 3, } close_btn = tk.Button(title_bar, text="โœ•", command=self._close_app, **btn_kwargs) close_btn.pack(side=tk.RIGHT, padx=6, pady=4) close_btn.bind("", lambda _e: close_btn.configure(bg="#cf212f")) close_btn.bind("", lambda _e: close_btn.configure(bg=bar_bg)) max_btn = tk.Button(title_bar, text="โ", command=self._toggle_maximize_window, **btn_kwargs) max_btn.pack(side=tk.RIGHT, padx=0, pady=4) max_btn.bind("", lambda _e: max_btn.configure(bg="#2c2c32")) max_btn.bind("", lambda _e: max_btn.configure(bg=bar_bg)) self._max_button = max_btn for widget in (title_bar, title_label): widget.bind("", self._start_window_drag) widget.bind("", self._perform_window_drag) widget.bind("", lambda _e: self._toggle_maximize_window()) self._update_maximize_button() def _close_app(self) -> None: try: self.root.destroy() except Exception: pass def _start_window_drag(self, event) -> None: if getattr(self, "_is_maximized", False): cursor_x, cursor_y = event.x_root, event.y_root self._toggle_maximize_window(force_state=False) self.root.update_idletasks() new_x = self.root.winfo_rootx() new_y = self.root.winfo_rooty() self._drag_offset = (cursor_x - new_x, cursor_y - new_y) return 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}") if not getattr(self, "_is_maximized", False): self._remember_window_geometry() def _remember_window_geometry(self) -> None: try: self._window_geometry = self.root.geometry() except Exception: pass def _monitor_work_area(self) -> tuple[int, int, int, int] | None: try: import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 # type: ignore[attr-defined] root_x = self.root.winfo_rootx() root_y = self.root.winfo_rooty() width = max(self.root.winfo_width(), 1) height = max(self.root.winfo_height(), 1) center_x = root_x + width // 2 center_y = root_y + height // 2 class MONITORINFO(ctypes.Structure): _fields_ = [ ("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD), ] monitor = user32.MonitorFromPoint( wintypes.POINT(center_x, center_y), 2 # MONITOR_DEFAULTTONEAREST ) info = MONITORINFO() info.cbSize = ctypes.sizeof(MONITORINFO) if not user32.GetMonitorInfoW(monitor, ctypes.byref(info)): return None work = info.rcWork return work.left, work.top, work.right, work.bottom except Exception: return None def _maximize_window(self) -> None: self._remember_window_geometry() work_area = self._monitor_work_area() if work_area is None: screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() left = 0 top = 0 width = screen_width height = screen_height else: left, top, right, bottom = work_area width = max(1, right - left) height = max(1, bottom - top) self.root.geometry(f"{width}x{height}+{left}+{top}") self._is_maximized = True self._update_maximize_button() def _restore_window(self) -> None: geometry = getattr(self, "_window_geometry", None) if not geometry: screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() width = int(screen_width * 0.8) height = int(screen_height * 0.8) x = (screen_width - width) // 2 y = (screen_height - height) // 4 geometry = f"{width}x{height}+{x}+{y}" self.root.geometry(geometry) self._is_maximized = False self._update_maximize_button() def _toggle_maximize_window(self, force_state: bool | None = None) -> None: desired = force_state if force_state is not None else not getattr(self, "_is_maximized", False) if desired: self._maximize_window() else: self._restore_window() def _update_maximize_button(self) -> None: button = getattr(self, "_max_button", None) if button is None: return symbol = "โ" if getattr(self, "_is_maximized", False) else "โ–ก" button.configure(text=symbol) 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 _canvas_background_colour(self) -> str: return "#0f0f10" if getattr(self, "theme", "light") == "dark" else "#ffffff" def _refresh_canvas_backgrounds(self) -> None: bg = self._canvas_background_colour() for attr in ("canvas_orig", "canvas_overlay"): canvas = getattr(self, attr, None) if canvas is not None: try: canvas.configure(bg=bg) except Exception: pass 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) label = self._t("menu.copy") if hasattr(self, "_t") else "Copy" self.copy_menu.add_command(label=label, 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"]