ICRA/app/app.py

198 lines
6.4 KiB
Python

"""Application composition root."""
from __future__ import annotations
import tkinter as tk
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
from .i18n import I18nMixin
from .logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE, ImageProcessingMixin, ResetMixin
class ICRAApp(
I18nMixin,
ThemeMixin,
UIBuilderMixin,
ImageProcessingMixin,
ExclusionMixin,
ColorPickerMixin,
ResetMixin,
):
"""Tkinter based application for isolating configurable colour ranges."""
def __init__(self, root: tk.Tk):
self.root = root
self.init_i18n(LANGUAGE)
self.root.title(self._t("app.title"))
self._setup_window()
# Theme and styling
self.init_theme()
# Tkinter state variables
self.DEFAULTS = DEFAULTS.copy()
self.hue_min = tk.DoubleVar(value=self.DEFAULTS["hue_min"])
self.hue_max = tk.DoubleVar(value=self.DEFAULTS["hue_max"])
self.sat_min = tk.DoubleVar(value=self.DEFAULTS["sat_min"])
self.val_min = tk.DoubleVar(value=self.DEFAULTS["val_min"])
self.val_max = tk.DoubleVar(value=self.DEFAULTS["val_max"])
self.alpha = tk.IntVar(value=self.DEFAULTS["alpha"])
self.ref_hue = None
# Debounce for heavy preview updates
self.update_delay_ms = 400
self._update_job = None
# Exclusion rectangles (preview coordinates)
self.exclude_shapes: list[dict[str, object]] = []
self._rubber_start = None
self._rubber_id = None
self._stroke_preview_id = None
self.exclude_mode = "rect"
self.reset_exclusions_on_switch = RESET_EXCLUSIONS_ON_IMAGE_CHANGE
self._exclude_mask = None
self._exclude_mask_dirty = True
self._exclude_mask_px = None
self._exclude_canvas_ids: list[int] = []
self._current_stroke: list[tuple[int, int]] | None = None
self.free_draw_width = 14
self.pick_mode = False
# Image references
self.image_path = None
self.orig_img = None
self.preview_img = None
self.preview_tk = None
self.overlay_tk = None
self.image_paths = []
self.current_image_index = -1
# Build UI
self.setup_ui()
self._init_copy_menu()
self.bring_to_front()
def _setup_window(self) -> None:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
default_width = int(screen_width * 0.8)
default_height = int(screen_height * 0.8)
default_x = (screen_width - default_width) // 2
default_y = (screen_height - default_height) // 4
self._window_geometry = f"{default_width}x{default_height}+{default_x}+{default_y}"
self._is_maximized = True
self.root.geometry(f"{screen_width}x{screen_height}+0+0")
self.root.configure(bg="#f2f2f7")
self._init_window_chrome()
def _ensure_taskbar_entry(self) -> None:
"""Force the borderless window to show up in the Windows taskbar."""
try:
import platform
if platform.system() != "Windows":
return
import ctypes
hwnd = self.root.winfo_id()
if not hwnd:
self.root.after(50, self._ensure_taskbar_entry)
return
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
WS_EX_APPWINDOW = 0x00040000
SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
SWP_NOZORDER = 0x0004
SWP_FRAMECHANGED = 0x0020
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
shell32 = ctypes.windll.shell32 # type: ignore[attr-defined]
style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
new_style = (style & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
if new_style != style:
user32.SetWindowLongW(hwnd, GWL_EXSTYLE, new_style)
user32.SetWindowPos(
hwnd,
0,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED,
)
app_id = ctypes.c_wchar_p("ICRA.App")
shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
except Exception:
pass
def _init_window_chrome(self) -> None:
"""Configure a borderless window while retaining a taskbar entry."""
try:
import platform
system = platform.system()
if system == "Windows":
self.root.after(0, self._apply_windows_borderless_style)
self.root.after(0, self._ensure_taskbar_entry)
else:
self.root.overrideredirect(True)
except Exception:
self.root.overrideredirect(True)
def _apply_windows_borderless_style(self) -> None:
try:
import platform
import ctypes
if platform.system() != "Windows":
return
hwnd = self.root.winfo_id()
if not hwnd:
self.root.after(50, self._apply_windows_borderless_style)
return
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
GWL_STYLE = -16
WS_CAPTION = 0x00C00000
WS_THICKFRAME = 0x00040000
WS_MAXIMIZEBOX = 0x00010000
WS_MINIMIZEBOX = 0x00020000
WS_SYSMENU = 0x00080000
SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
SWP_NOZORDER = 0x0004
SWP_FRAMECHANGED = 0x0020
style = user32.GetWindowLongW(hwnd, GWL_STYLE)
new_style = style & ~(
WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU
)
if new_style != style:
user32.SetWindowLongW(hwnd, GWL_STYLE, new_style)
user32.SetWindowPos(
hwnd,
0,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED,
)
except Exception:
pass
def start_app() -> None:
"""Entry point used by the CLI script."""
root = tk.Tk()
app = ICRAApp(root)
root.mainloop()
__all__ = ["ICRAApp", "start_app"]