ICRA/app/gui/ui.py

604 lines
22 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.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)
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)
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("<Enter>", lambda _e: close_btn.configure(bg="#cf212f"))
close_btn.bind("<Leave>", lambda _e: close_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)
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 _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"]