Compare commits

...

15 Commits

Author SHA1 Message Date
lm f09da5018f Simplify borderless setup to fix input handling 2025-10-19 18:53:47 +02:00
lm 8053dc297a Keep custom title bar while preserving responsiveness 2025-10-19 18:51:58 +02:00
lm f65c37407c Reinstate always-borderless window setup 2025-10-19 18:48:49 +02:00
lm 3e6650eb2e Ensure Windows borderless window registers as taskbar app 2025-10-19 18:47:46 +02:00
lm 2bf9776076 Reapply Windows borderless styling without hiding taskbar entry 2025-10-19 18:44:49 +02:00
lm 1984145043 Force borderless window to appear in taskbar 2025-10-19 18:40:38 +02:00
lm 8ed1acc32d Restore custom title bar without native chrome 2025-10-19 18:38:13 +02:00
lm 183b769000 Revert "Restore native title bar with theme-aware styling"
This reverts commit 07d7679889.
2025-10-19 18:36:27 +02:00
lm 07d7679889 Restore native title bar with theme-aware styling 2025-10-19 18:34:28 +02:00
lm 01a9b2707c Implement native borderless window integration 2025-10-19 18:26:36 +02:00
lm f2db62036b Redo borderless window styling to keep taskbar entry 2025-10-19 18:21:17 +02:00
lm fb37da7e40 Ensure taskbar entry for borderless window 2025-10-19 18:18:45 +02:00
lm 50b9aa723c Improve maximize behaviour on multi-monitor setups 2025-10-19 18:14:40 +02:00
lm 27c0e55711 Upscale small previews to fill canvas 2025-10-19 18:10:24 +02:00
lm f467e0b2e5 Enhance window controls
Add minimize/maximize buttons, double-click maximize behaviour, and proper window state handling for the custom title bar.
2025-10-19 18:03:07 +02:00
3 changed files with 254 additions and 39 deletions

View File

@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import ctypes
import platform
import tkinter as tk import tkinter as tk
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
@ -73,11 +76,94 @@ class ICRAApp(
self.bring_to_front() self.bring_to_front()
def _setup_window(self) -> None: def _setup_window(self) -> None:
self.root.overrideredirect(True)
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_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.geometry(f"{screen_width}x{screen_height}+0+0")
self.root.configure(bg="#f2f2f7") 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: def start_app() -> None:

View File

@ -422,28 +422,41 @@ class UIBuilderMixin:
) )
title_label.pack(side=tk.LEFT, padx=6) title_label.pack(side=tk.LEFT, padx=6)
close_btn = tk.Button( btn_kwargs = {
title_bar, "bg": bar_bg,
text="", "fg": "#f5f5f5",
command=self._close_app, "activebackground": "#3a3a40",
bg=bar_bg, "activeforeground": "#ffffff",
fg="#f5f5f5", "borderwidth": 0,
activebackground="#ff3b30", "highlightthickness": 0,
activeforeground="#ffffff", "relief": "flat",
borderwidth=0, "font": ("Segoe UI", 10, "bold"),
highlightthickness=0, "cursor": "hand2",
relief="flat", "width": 3,
font=("Segoe UI", 10, "bold"), }
cursor="hand2",
width=3, close_btn = tk.Button(title_bar, text="", command=self._close_app, **btn_kwargs)
) close_btn.pack(side=tk.RIGHT, padx=6, pady=4)
close_btn.pack(side=tk.RIGHT, padx=8, pady=4)
close_btn.bind("<Enter>", lambda _e: close_btn.configure(bg="#cf212f")) close_btn.bind("<Enter>", lambda _e: close_btn.configure(bg="#cf212f"))
close_btn.bind("<Leave>", lambda _e: close_btn.configure(bg=bar_bg)) close_btn.bind("<Leave>", lambda _e: close_btn.configure(bg=bar_bg))
max_btn = tk.Button(title_bar, text="", command=self._toggle_maximize_window, **btn_kwargs)
max_btn.pack(side=tk.RIGHT, padx=0, pady=4)
max_btn.bind("<Enter>", lambda _e: max_btn.configure(bg="#2c2c32"))
max_btn.bind("<Leave>", 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("<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)
widget.bind("<Double-Button-1>", lambda _e: self._toggle_maximize_window())
self._update_maximize_button()
def _close_app(self) -> None: def _close_app(self) -> None:
try: try:
@ -452,6 +465,14 @@ class UIBuilderMixin:
pass pass
def _start_window_drag(self, event) -> None: def _start_window_drag(self, event) -> None:
if getattr(self, "_is_maximized", False):
cursor_x, cursor_y = event.x_root, event.y_root
self._toggle_maximize_window(force_state=False)
self.root.update_idletasks()
new_x = self.root.winfo_rootx()
new_y = self.root.winfo_rooty()
self._drag_offset = (cursor_x - new_x, cursor_y - new_y)
return
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:
@ -461,6 +482,112 @@ class UIBuilderMixin:
x = event.x_root - offset[0] x = event.x_root - offset[0]
y = event.y_root - offset[1] y = event.y_root - offset[1]
self.root.geometry(f"+{x}+{y}") self.root.geometry(f"+{x}+{y}")
if not getattr(self, "_is_maximized", False):
self._remember_window_geometry()
def _remember_window_geometry(self) -> None:
try:
self._window_geometry = self.root.geometry()
except Exception:
pass
def _monitor_work_area(self) -> tuple[int, int, int, int] | None:
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
root_x = self.root.winfo_rootx()
root_y = self.root.winfo_rooty()
width = max(self.root.winfo_width(), 1)
height = max(self.root.winfo_height(), 1)
center_x = root_x + width // 2
center_y = root_y + height // 2
class MONITORINFO(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD),
]
monitor = user32.MonitorFromPoint(
wintypes.POINT(center_x, center_y), 2 # MONITOR_DEFAULTTONEAREST
)
info = MONITORINFO()
info.cbSize = ctypes.sizeof(MONITORINFO)
if not user32.GetMonitorInfoW(monitor, ctypes.byref(info)):
return None
work = info.rcWork
return work.left, work.top, work.right, work.bottom
except Exception:
return None
def _maximize_window(self) -> None:
self._remember_window_geometry()
work_area = self._monitor_work_area()
if work_area is None:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
left = 0
top = 0
width = screen_width
height = screen_height
else:
left, top, right, bottom = work_area
width = max(1, right - left)
height = max(1, bottom - top)
self.root.geometry(f"{width}x{height}+{left}+{top}")
self._is_maximized = True
self._update_maximize_button()
def _restore_window(self) -> None:
geometry = getattr(self, "_window_geometry", None)
if not geometry:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
width = int(screen_width * 0.8)
height = int(screen_height * 0.8)
x = (screen_width - width) // 2
y = (screen_height - height) // 4
geometry = f"{width}x{height}+{x}+{y}"
self.root.geometry(geometry)
self._is_maximized = False
self._update_maximize_button()
def _toggle_maximize_window(self, force_state: bool | None = None) -> None:
desired = force_state if force_state is not None else not getattr(self, "_is_maximized", False)
if desired:
self._maximize_window()
else:
self._restore_window()
def _minimize_window(self) -> None:
try:
self._remember_window_geometry()
use_or = getattr(self, "_use_overrideredirect", False)
if use_or and hasattr(self.root, "overrideredirect"):
try:
self.root.overrideredirect(False)
except Exception:
pass
self.root.iconify()
if use_or:
restorer = getattr(self, "_restore_borderless", None)
if callable(restorer):
self.root.after(120, restorer)
elif hasattr(self.root, "overrideredirect"):
self.root.after(120, lambda: self.root.overrideredirect(True)) # type: ignore[arg-type]
except Exception:
pass
def _update_maximize_button(self) -> None:
button = getattr(self, "_max_button", None)
if button is None:
return
symbol = "" if getattr(self, "_is_maximized", False) else ""
button.configure(text=symbol)
def _maybe_focus_window(self, _event) -> None: def _maybe_focus_window(self, _event) -> None:
try: try:

View File

@ -194,7 +194,9 @@ class ImageProcessingMixin:
return return
width, height = self.orig_img.size width, height = self.orig_img.size
max_w, max_h = PREVIEW_MAX_SIZE max_w, max_h = PREVIEW_MAX_SIZE
scale = min(max_w / width, max_h / height, 1.0) scale = min(max_w / width, max_h / height)
if scale <= 0:
scale = 1.0
size = (max(1, int(width * scale)), max(1, int(height * scale))) size = (max(1, int(width * scale)), max(1, int(height * scale)))
self.preview_img = self.orig_img.resize(size, Image.LANCZOS) self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
self.preview_tk = ImageTk.PhotoImage(self.preview_img) self.preview_tk = ImageTk.PhotoImage(self.preview_img)