From 01a9b2707ce51e35619268ff8bb5e4701fd3cf7a Mon Sep 17 00:00:00 2001 From: lm Date: Sun, 19 Oct 2025 18:26:36 +0200 Subject: [PATCH] Implement native borderless window integration --- app/app.py | 59 ++++++++++++++++++++++++++++++++++++--------------- app/gui/ui.py | 12 +++++++++++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/app/app.py b/app/app.py index a34546d..d0f6151 100644 --- a/app/app.py +++ b/app/app.py @@ -2,7 +2,11 @@ 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 from .i18n import I18nMixin @@ -83,17 +87,15 @@ class ICRAApp( 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() 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) @@ -129,15 +131,25 @@ class ICRAApp( 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: - 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) + self.root.bind("", lambda _e: self._apply_windows_borderless_style(), add="+") + self.root.bind("", lambda _e: self._ensure_taskbar_entry(), add="+") else: self.root.overrideredirect(True) except Exception: @@ -145,9 +157,6 @@ class ICRAApp( def _apply_windows_borderless_style(self) -> None: try: - import platform - import ctypes - if platform.system() != "Windows": return @@ -160,20 +169,24 @@ class ICRAApp( GWL_STYLE = -16 WS_CAPTION = 0x00C00000 WS_THICKFRAME = 0x00040000 - WS_MAXIMIZEBOX = 0x00010000 - WS_MINIMIZEBOX = 0x00020000 - WS_SYSMENU = 0x00080000 + WS_BORDER = 0x00800000 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 - ) + 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: - user32.SetWindowLongW(hwnd, GWL_STYLE, new_style) + set_window_long(hwnd, GWL_STYLE, ptr_type(new_style)) user32.SetWindowPos( hwnd, 0, @@ -183,6 +196,18 @@ class ICRAApp( 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 except Exception: pass diff --git a/app/gui/ui.py b/app/gui/ui.py index 0f930f6..a347e20 100644 --- a/app/gui/ui.py +++ b/app/gui/ui.py @@ -446,6 +446,11 @@ class UIBuilderMixin: max_btn.bind("", lambda _e: max_btn.configure(bg=bar_bg)) 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("", lambda _e: min_btn.configure(bg="#2c2c32")) + min_btn.bind("", lambda _e: min_btn.configure(bg=bar_bg)) + for widget in (title_bar, title_label): widget.bind("", self._start_window_drag) widget.bind("", self._perform_window_drag) @@ -558,6 +563,13 @@ class UIBuilderMixin: else: 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: button = getattr(self, "_max_button", None) if button is None: