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
import ctypes
import platform
import tkinter as tk
from importlib import resources
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
from .i18n import I18nMixin
@ -73,11 +76,94 @@ class ICRAApp(
self.bring_to_front()
def _setup_window(self) -> None:
self.root.overrideredirect(True)
screen_width = self.root.winfo_screenwidth()
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.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:

View File

@ -422,45 +422,172 @@ class UIBuilderMixin:
)
title_label.pack(side=tk.LEFT, padx=6)
close_btn = tk.Button(
title_bar,
text="",
command=self._close_app,
bg=bar_bg,
fg="#f5f5f5",
activebackground="#ff3b30",
activeforeground="#ffffff",
borderwidth=0,
highlightthickness=0,
relief="flat",
font=("Segoe UI", 10, "bold"),
cursor="hand2",
width=3,
)
close_btn.pack(side=tk.RIGHT, padx=8, pady=4)
btn_kwargs = {
"bg": bar_bg,
"fg": "#f5f5f5",
"activebackground": "#3a3a40",
"activeforeground": "#ffffff",
"borderwidth": 0,
"highlightthickness": 0,
"relief": "flat",
"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.bind("<Enter>", lambda _e: close_btn.configure(bg="#cf212f"))
close_btn.bind("<Leave>", lambda _e: close_btn.configure(bg=bar_bg))
for widget in (title_bar, title_label):
widget.bind("<ButtonPress-1>", self._start_window_drag)
widget.bind("<B1-Motion>", self._perform_window_drag)
def _close_app(self) -> None:
try:
self.root.destroy()
except Exception:
pass
def _start_window_drag(self, event) -> None:
self._drag_offset = (event.x_root - self.root.winfo_rootx(), event.y_root - self.root.winfo_rooty())
def _perform_window_drag(self, event) -> None:
offset = getattr(self, "_drag_offset", None)
if offset is None:
return
x = event.x_root - offset[0]
y = event.y_root - offset[1]
self.root.geometry(f"+{x}+{y}")
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):
widget.bind("<ButtonPress-1>", self._start_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:
try:
self.root.destroy()
except Exception:
pass
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())
def _perform_window_drag(self, event) -> None:
offset = getattr(self, "_drag_offset", None)
if offset is None:
return
x = event.x_root - offset[0]
y = event.y_root - offset[1]
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:
try:

View File

@ -194,7 +194,9 @@ class ImageProcessingMixin:
return
width, height = self.orig_img.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)))
self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
self.preview_tk = ImageTk.PhotoImage(self.preview_img)