ICRA/app/gui/ui.py

730 lines
27 KiB
Python

"""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("<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))
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("<Escape>", self.disable_pick_mode)
self.root.bind("<ButtonPress-1>", 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("<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, 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("<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 _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("<Enter>", lambda _e: close_btn.configure(bg="#cf212f"))
close_btn.bind("<Leave>", 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("<Enter>", lambda _e: max_btn.configure(bg="#2c2c32"))
max_btn.bind("<Leave>", lambda _e: max_btn.configure(bg=bar_bg))
self._max_button = max_btn
min_btn = tk.Button(title_bar, text="โ€”", command=self._minimize_window, **btn_kwargs)
min_btn.pack(side=tk.RIGHT, padx=0, pady=4)
min_btn.bind("<Enter>", lambda _e: min_btn.configure(bg="#2c2c32"))
min_btn.bind("<Leave>", lambda _e: min_btn.configure(bg=bar_bg))
for widget in (title_bar, title_label):
widget.bind("<ButtonPress-1>", self._start_window_drag)
widget.bind("<B1-Motion>", self._perform_window_drag)
widget.bind("<Double-Button-1>", 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 _minimize_window(self) -> None:
try:
self._remember_window_geometry()
if hasattr(self.root, "overrideredirect"):
try:
self.root.overrideredirect(False)
except Exception:
pass
self.root.iconify()
restorer = getattr(self, "_restore_borderless", None)
if callable(restorer):
self.root.after(120, restorer)
elif hasattr(self.root, "overrideredirect"):
self.root.after(120, lambda: self.root.overrideredirect(True)) # type: ignore[arg-type]
except Exception:
pass
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("<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"]