177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
"""Application composition root."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import platform
|
|
import tkinter as tk
|
|
from importlib import resources
|
|
|
|
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._use_overrideredirect = True
|
|
self.root.geometry(f"{screen_width}x{screen_height}+0+0")
|
|
self.root.configure(bg="#f2f2f7")
|
|
try:
|
|
self.root.overrideredirect(True)
|
|
except Exception:
|
|
try:
|
|
self.root.attributes("-type", "splash")
|
|
except Exception:
|
|
pass
|
|
self._window_icon_ref = None
|
|
self._apply_window_icon()
|
|
self._init_window_chrome()
|
|
|
|
def _ensure_taskbar_entry(self) -> None:
|
|
"""Force the borderless window to show up in the Windows taskbar."""
|
|
try:
|
|
if platform.system() != "Windows":
|
|
return
|
|
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 _apply_window_icon(self) -> None:
|
|
try:
|
|
icon_resource = resources.files("app.assets").joinpath("logo.png")
|
|
with resources.as_file(icon_resource) as icon_path:
|
|
icon = tk.PhotoImage(file=str(icon_path))
|
|
self.root.iconphoto(False, icon)
|
|
self._window_icon_ref = icon
|
|
except Exception:
|
|
self._window_icon_ref = None
|
|
|
|
def _init_window_chrome(self) -> None:
|
|
"""Configure a borderless window while retaining a taskbar entry."""
|
|
try:
|
|
self.root.bind("<Map>", self._restore_borderless)
|
|
self.root.after(0, self._restore_borderless)
|
|
self.root.after(0, self._ensure_taskbar_entry)
|
|
except Exception:
|
|
pass
|
|
|
|
def _restore_borderless(self, _event=None) -> None:
|
|
try:
|
|
if self._use_overrideredirect:
|
|
self.root.overrideredirect(True)
|
|
self._ensure_taskbar_entry()
|
|
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"]
|