Implement native borderless window integration

This commit is contained in:
lm 2025-10-19 18:26:36 +02:00
parent f2db62036b
commit 01a9b2707c
2 changed files with 54 additions and 17 deletions

View File

@ -2,7 +2,11 @@
from __future__ import annotations from __future__ import annotations
import ctypes
import platform
import tkinter as tk import tkinter as tk
from ctypes import wintypes
from importlib import resources
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
from .i18n import I18nMixin from .i18n import I18nMixin
@ -83,17 +87,15 @@ class ICRAApp(
self._is_maximized = True self._is_maximized = True
self.root.geometry(f"{screen_width}x{screen_height}+0+0") 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._apply_window_icon()
self._init_window_chrome() self._init_window_chrome()
def _ensure_taskbar_entry(self) -> None: def _ensure_taskbar_entry(self) -> None:
"""Force the borderless window to show up in the Windows taskbar.""" """Force the borderless window to show up in the Windows taskbar."""
try: try:
import platform
if platform.system() != "Windows": if platform.system() != "Windows":
return return
import ctypes
hwnd = self.root.winfo_id() hwnd = self.root.winfo_id()
if not hwnd: if not hwnd:
self.root.after(50, self._ensure_taskbar_entry) self.root.after(50, self._ensure_taskbar_entry)
@ -129,15 +131,25 @@ class ICRAApp(
except Exception: except Exception:
pass 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: def _init_window_chrome(self) -> None:
"""Configure a borderless window while retaining a taskbar entry.""" """Configure a borderless window while retaining a taskbar entry."""
try: try:
import platform
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
self.root.after(0, self._apply_windows_borderless_style) 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)
self.root.bind("<Map>", lambda _e: self._apply_windows_borderless_style(), add="+")
self.root.bind("<Map>", lambda _e: self._ensure_taskbar_entry(), add="+")
else: else:
self.root.overrideredirect(True) self.root.overrideredirect(True)
except Exception: except Exception:
@ -145,9 +157,6 @@ class ICRAApp(
def _apply_windows_borderless_style(self) -> None: def _apply_windows_borderless_style(self) -> None:
try: try:
import platform
import ctypes
if platform.system() != "Windows": if platform.system() != "Windows":
return return
@ -160,20 +169,24 @@ class ICRAApp(
GWL_STYLE = -16 GWL_STYLE = -16
WS_CAPTION = 0x00C00000 WS_CAPTION = 0x00C00000
WS_THICKFRAME = 0x00040000 WS_THICKFRAME = 0x00040000
WS_MAXIMIZEBOX = 0x00010000 WS_BORDER = 0x00800000
WS_MINIMIZEBOX = 0x00020000
WS_SYSMENU = 0x00080000
SWP_NOSIZE = 0x0001 SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002 SWP_NOMOVE = 0x0002
SWP_NOZORDER = 0x0004 SWP_NOZORDER = 0x0004
SWP_FRAMECHANGED = 0x0020 SWP_FRAMECHANGED = 0x0020
style = user32.GetWindowLongW(hwnd, GWL_STYLE) set_window_long = getattr(user32, "SetWindowLongPtrW", user32.SetWindowLongW)
new_style = style & ~( get_window_long = getattr(user32, "GetWindowLongPtrW", user32.GetWindowLongW)
WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU 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: if new_style != style:
user32.SetWindowLongW(hwnd, GWL_STYLE, new_style) set_window_long(hwnd, GWL_STYLE, ptr_type(new_style))
user32.SetWindowPos( user32.SetWindowPos(
hwnd, hwnd,
0, 0,
@ -183,6 +196,18 @@ class ICRAApp(
0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED, 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
except Exception: except Exception:
pass pass

View File

@ -446,6 +446,11 @@ class UIBuilderMixin:
max_btn.bind("<Leave>", lambda _e: max_btn.configure(bg=bar_bg)) max_btn.bind("<Leave>", lambda _e: max_btn.configure(bg=bar_bg))
self._max_button = max_btn self._max_button = max_btn
min_btn = tk.Button(title_bar, text="", command=self._minimize_window, **btn_kwargs)
min_btn.pack(side=tk.RIGHT, padx=0, pady=4)
min_btn.bind("<Enter>", lambda _e: min_btn.configure(bg="#2c2c32"))
min_btn.bind("<Leave>", lambda _e: min_btn.configure(bg=bar_bg))
for widget in (title_bar, title_label): for widget in (title_bar, title_label):
widget.bind("<ButtonPress-1>", self._start_window_drag) widget.bind("<ButtonPress-1>", self._start_window_drag)
widget.bind("<B1-Motion>", self._perform_window_drag) widget.bind("<B1-Motion>", self._perform_window_drag)
@ -558,6 +563,13 @@ class UIBuilderMixin:
else: else:
self._restore_window() self._restore_window()
def _minimize_window(self) -> None:
try:
self._remember_window_geometry()
self.root.iconify()
except Exception:
pass
def _update_maximize_button(self) -> None: def _update_maximize_button(self) -> None:
button = getattr(self, "_max_button", None) button = getattr(self, "_max_button", None)
if button is None: if button is None: