From 07d76798896cc73fa9a6b73f2c83c274e3927561 Mon Sep 17 00:00:00 2001 From: lm Date: Sun, 19 Oct 2025 18:34:28 +0200 Subject: [PATCH] Restore native title bar with theme-aware styling --- app/app.py | 98 ++++++++++++++++++++++-------------------------- app/gui/theme.py | 4 ++ app/gui/ui.py | 16 +++++++- 3 files changed, 64 insertions(+), 54 deletions(-) diff --git a/app/app.py b/app/app.py index d0f6151..159f53b 100644 --- a/app/app.py +++ b/app/app.py @@ -5,7 +5,6 @@ from __future__ import annotations import ctypes import platform import tkinter as tk -from ctypes import wintypes from importlib import resources from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin @@ -77,6 +76,9 @@ class ICRAApp( self.bring_to_front() def _setup_window(self) -> None: + system = platform.system() + self.use_native_titlebar = system == "Windows" + screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() default_width = int(screen_width * 0.8) @@ -84,8 +86,15 @@ class ICRAApp( 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") + + if self.use_native_titlebar: + self._is_maximized = False + self.root.geometry(self._window_geometry) + self.root.after(0, lambda: self.root.state("zoomed")) + else: + self.root.overrideredirect(True) + self._is_maximized = True + self.root.geometry(f"{screen_width}x{screen_height}+0+0") self.root.configure(bg="#f2f2f7") self._window_icon_ref = None self._apply_window_icon() @@ -142,72 +151,55 @@ class ICRAApp( self._window_icon_ref = None def _init_window_chrome(self) -> None: - """Configure a borderless window while retaining a taskbar entry.""" + """Configure window chrome based on platform.""" try: - system = platform.system() - if system == "Windows": - self.root.after(0, self._apply_windows_borderless_style) - self.root.after(0, self._ensure_taskbar_entry) - self.root.bind("", lambda _e: self._apply_windows_borderless_style(), add="+") - self.root.bind("", lambda _e: self._ensure_taskbar_entry(), add="+") + if self.use_native_titlebar: + if platform.system() == "Windows": + self.root.after(0, self._ensure_taskbar_entry) + initial_mode = getattr(self, "theme", "light") + self.root.after(0, lambda: self._apply_os_titlebar_theme(initial_mode)) + self.root.bind( + "", + lambda _e: self._apply_os_titlebar_theme(getattr(self, "theme", "light")), + add="+", + ) + self.root.bind("", lambda _e: self._ensure_taskbar_entry(), add="+") else: - self.root.overrideredirect(True) + self.root.bind("", self._restore_borderless) + self.root.after(0, self._restore_borderless) + self.root.after(0, self._ensure_taskbar_entry) except Exception: self.root.overrideredirect(True) - def _apply_windows_borderless_style(self) -> None: + def _restore_borderless(self, _event=None) -> None: + try: + self.root.overrideredirect(True) + except Exception: + pass + + def _apply_os_titlebar_theme(self, mode: str | None = None) -> None: try: if platform.system() != "Windows": return hwnd = self.root.winfo_id() if not hwnd: - self.root.after(50, self._apply_windows_borderless_style) + self.root.after(50, lambda: self._apply_os_titlebar_theme(mode)) return - user32 = ctypes.windll.user32 # type: ignore[attr-defined] - GWL_STYLE = -16 - WS_CAPTION = 0x00C00000 - WS_THICKFRAME = 0x00040000 - WS_BORDER = 0x00800000 - SWP_NOSIZE = 0x0001 - SWP_NOMOVE = 0x0002 - SWP_NOZORDER = 0x0004 - SWP_FRAMECHANGED = 0x0020 - - set_window_long = getattr(user32, "SetWindowLongPtrW", user32.SetWindowLongW) - get_window_long = getattr(user32, "GetWindowLongPtrW", user32.GetWindowLongW) - ptr_type = ctypes.c_longlong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_long - get_window_long.restype = ptr_type # type: ignore[attr-defined] - get_window_long.argtypes = [wintypes.HWND, ctypes.c_int] # type: ignore[attr-defined] - set_window_long.restype = ptr_type # type: ignore[attr-defined] - set_window_long.argtypes = [wintypes.HWND, ctypes.c_int, ptr_type] # type: ignore[attr-defined] - - style = get_window_long(hwnd, GWL_STYLE) - new_style = style & ~(WS_CAPTION | WS_THICKFRAME | WS_BORDER) - if new_style != style: - set_window_long(hwnd, GWL_STYLE, ptr_type(new_style)) - user32.SetWindowPos( - hwnd, - 0, - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED, - ) try: dwmapi = ctypes.windll.dwmapi # type: ignore[attr-defined] - DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - value = ctypes.c_int(1) - dwmapi.DwmSetWindowAttribute( - hwnd, - ctypes.c_uint(DWMWA_USE_IMMERSIVE_DARK_MODE), - ctypes.byref(value), - ctypes.sizeof(value), - ) except Exception: - pass + return + + attribute = ctypes.c_uint(20) # DWMWA_USE_IMMERSIVE_DARK_MODE + value = ctypes.c_int(1 if (mode or self.theme) == "dark" else 0) + dwmapi.DwmSetWindowAttribute( + hwnd, + attribute, + ctypes.byref(value), + ctypes.sizeof(value), + ) except Exception: pass diff --git a/app/gui/theme.py b/app/gui/theme.py index 24d23b0..855e1fe 100644 --- a/app/gui/theme.py +++ b/app/gui/theme.py @@ -71,6 +71,10 @@ class ThemeMixin: if callable(canvas_refresher): canvas_refresher() + os_theme_hook = getattr(self, "_apply_os_titlebar_theme", None) + if callable(os_theme_hook): + os_theme_hook(self.theme) + def detect_system_theme(self) -> str: """Best-effort detection of the OS theme preference.""" try: diff --git a/app/gui/ui.py b/app/gui/ui.py index a347e20..f5180d8 100644 --- a/app/gui/ui.py +++ b/app/gui/ui.py @@ -12,7 +12,9 @@ class UIBuilderMixin: """Constructs the Tkinter UI and common widgets.""" def setup_ui(self) -> None: - self._create_titlebar() + self._custom_titlebar = not getattr(self, "use_native_titlebar", False) + if self._custom_titlebar: + self._create_titlebar() toolbar = ttk.Frame(self.root) toolbar.pack(fill=tk.X, padx=12, pady=(4, 2)) @@ -465,6 +467,8 @@ class UIBuilderMixin: pass def _start_window_drag(self, event) -> None: + if not getattr(self, "_custom_titlebar", True): + return if getattr(self, "_is_maximized", False): cursor_x, cursor_y = event.x_root, event.y_root self._toggle_maximize_window(force_state=False) @@ -476,6 +480,8 @@ class UIBuilderMixin: self._drag_offset = (event.x_root - self.root.winfo_rootx(), event.y_root - self.root.winfo_rooty()) def _perform_window_drag(self, event) -> None: + if not getattr(self, "_custom_titlebar", True): + return offset = getattr(self, "_drag_offset", None) if offset is None: return @@ -525,6 +531,8 @@ class UIBuilderMixin: return None def _maximize_window(self) -> None: + if not getattr(self, "_custom_titlebar", True): + return self._remember_window_geometry() work_area = self._monitor_work_area() if work_area is None: @@ -543,6 +551,8 @@ class UIBuilderMixin: self._update_maximize_button() def _restore_window(self) -> None: + if not getattr(self, "_custom_titlebar", True): + return geometry = getattr(self, "_window_geometry", None) if not geometry: screen_width = self.root.winfo_screenwidth() @@ -557,6 +567,8 @@ class UIBuilderMixin: self._update_maximize_button() def _toggle_maximize_window(self, force_state: bool | None = None) -> None: + if not getattr(self, "_custom_titlebar", True): + return desired = force_state if force_state is not None else not getattr(self, "_is_maximized", False) if desired: self._maximize_window() @@ -564,6 +576,8 @@ class UIBuilderMixin: self._restore_window() def _minimize_window(self) -> None: + if not getattr(self, "_custom_titlebar", True): + return try: self._remember_window_geometry() self.root.iconify()