Restore native title bar with theme-aware styling

This commit is contained in:
lm 2025-10-19 18:34:28 +02:00
parent 01a9b2707c
commit 07d7679889
3 changed files with 64 additions and 54 deletions

View File

@ -5,7 +5,6 @@ from __future__ import annotations
import ctypes import ctypes
import platform import platform
import tkinter as tk import tkinter as tk
from ctypes import wintypes
from importlib import resources from importlib import resources
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
@ -77,6 +76,9 @@ class ICRAApp(
self.bring_to_front() self.bring_to_front()
def _setup_window(self) -> None: def _setup_window(self) -> None:
system = platform.system()
self.use_native_titlebar = system == "Windows"
screen_width = self.root.winfo_screenwidth() screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight() screen_height = self.root.winfo_screenheight()
default_width = int(screen_width * 0.8) default_width = int(screen_width * 0.8)
@ -84,8 +86,15 @@ class ICRAApp(
default_x = (screen_width - default_width) // 2 default_x = (screen_width - default_width) // 2
default_y = (screen_height - default_height) // 4 default_y = (screen_height - default_height) // 4
self._window_geometry = f"{default_width}x{default_height}+{default_x}+{default_y}" 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.root.configure(bg="#f2f2f7")
self._window_icon_ref = None self._window_icon_ref = None
self._apply_window_icon() self._apply_window_icon()
@ -142,72 +151,55 @@ class ICRAApp(
self._window_icon_ref = None self._window_icon_ref = None
def _init_window_chrome(self) -> None: def _init_window_chrome(self) -> None:
"""Configure a borderless window while retaining a taskbar entry.""" """Configure window chrome based on platform."""
try: try:
system = platform.system() if self.use_native_titlebar:
if system == "Windows": if platform.system() == "Windows":
self.root.after(0, self._apply_windows_borderless_style) self.root.after(0, self._ensure_taskbar_entry)
self.root.after(0, self._ensure_taskbar_entry) initial_mode = getattr(self, "theme", "light")
self.root.bind("<Map>", lambda _e: self._apply_windows_borderless_style(), add="+") self.root.after(0, lambda: self._apply_os_titlebar_theme(initial_mode))
self.root.bind("<Map>", lambda _e: self._ensure_taskbar_entry(), add="+") self.root.bind(
"<Map>",
lambda _e: self._apply_os_titlebar_theme(getattr(self, "theme", "light")),
add="+",
)
self.root.bind("<Map>", lambda _e: self._ensure_taskbar_entry(), add="+")
else: else:
self.root.overrideredirect(True) self.root.bind("<Map>", self._restore_borderless)
self.root.after(0, self._restore_borderless)
self.root.after(0, self._ensure_taskbar_entry)
except Exception: except Exception:
self.root.overrideredirect(True) 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: try:
if platform.system() != "Windows": if platform.system() != "Windows":
return return
hwnd = self.root.winfo_id() hwnd = self.root.winfo_id()
if not hwnd: if not hwnd:
self.root.after(50, self._apply_windows_borderless_style) self.root.after(50, lambda: self._apply_os_titlebar_theme(mode))
return 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: try:
dwmapi = ctypes.windll.dwmapi # type: ignore[attr-defined] 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: 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: except Exception:
pass pass

View File

@ -71,6 +71,10 @@ class ThemeMixin:
if callable(canvas_refresher): if callable(canvas_refresher):
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: def detect_system_theme(self) -> str:
"""Best-effort detection of the OS theme preference.""" """Best-effort detection of the OS theme preference."""
try: try:

View File

@ -12,7 +12,9 @@ class UIBuilderMixin:
"""Constructs the Tkinter UI and common widgets.""" """Constructs the Tkinter UI and common widgets."""
def setup_ui(self) -> None: 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 = ttk.Frame(self.root)
toolbar.pack(fill=tk.X, padx=12, pady=(4, 2)) toolbar.pack(fill=tk.X, padx=12, pady=(4, 2))
@ -465,6 +467,8 @@ class UIBuilderMixin:
pass pass
def _start_window_drag(self, event) -> None: def _start_window_drag(self, event) -> None:
if not getattr(self, "_custom_titlebar", True):
return
if getattr(self, "_is_maximized", False): if getattr(self, "_is_maximized", False):
cursor_x, cursor_y = event.x_root, event.y_root cursor_x, cursor_y = event.x_root, event.y_root
self._toggle_maximize_window(force_state=False) 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()) self._drag_offset = (event.x_root - self.root.winfo_rootx(), event.y_root - self.root.winfo_rooty())
def _perform_window_drag(self, event) -> None: def _perform_window_drag(self, event) -> None:
if not getattr(self, "_custom_titlebar", True):
return
offset = getattr(self, "_drag_offset", None) offset = getattr(self, "_drag_offset", None)
if offset is None: if offset is None:
return return
@ -525,6 +531,8 @@ class UIBuilderMixin:
return None return None
def _maximize_window(self) -> None: def _maximize_window(self) -> None:
if not getattr(self, "_custom_titlebar", True):
return
self._remember_window_geometry() self._remember_window_geometry()
work_area = self._monitor_work_area() work_area = self._monitor_work_area()
if work_area is None: if work_area is None:
@ -543,6 +551,8 @@ class UIBuilderMixin:
self._update_maximize_button() self._update_maximize_button()
def _restore_window(self) -> None: def _restore_window(self) -> None:
if not getattr(self, "_custom_titlebar", True):
return
geometry = getattr(self, "_window_geometry", None) geometry = getattr(self, "_window_geometry", None)
if not geometry: if not geometry:
screen_width = self.root.winfo_screenwidth() screen_width = self.root.winfo_screenwidth()
@ -557,6 +567,8 @@ class UIBuilderMixin:
self._update_maximize_button() self._update_maximize_button()
def _toggle_maximize_window(self, force_state: bool | None = None) -> None: 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) desired = force_state if force_state is not None else not getattr(self, "_is_maximized", False)
if desired: if desired:
self._maximize_window() self._maximize_window()
@ -564,6 +576,8 @@ class UIBuilderMixin:
self._restore_window() self._restore_window()
def _minimize_window(self) -> None: def _minimize_window(self) -> None:
if not getattr(self, "_custom_titlebar", True):
return
try: try:
self._remember_window_geometry() self._remember_window_geometry()
self.root.iconify() self.root.iconify()