"""Helpers to place child windows consistently relative to the launcher.""" from __future__ import annotations import ctypes import logging import math import tkinter as tk from pathlib import Path MODULE_LOG_NAME = "window_placement" MODULE_LOG_PATH = Path(__file__).with_name("window_placement.log") _MODULE_LOGGER = logging.getLogger(MODULE_LOG_NAME) if not _MODULE_LOGGER.handlers: _MODULE_LOGGER.setLevel(logging.DEBUG) _MODULE_LOGGER.propagate = False _handler = logging.FileHandler(MODULE_LOG_PATH, encoding="utf-8") _handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) _MODULE_LOGGER.addHandler(_handler) def _safe_xy(window: tk.Misc) -> tuple[int | None, int | None]: """Return current window coordinates without raising.""" try: return int(window.winfo_x()), int(window.winfo_y()) except Exception: return None, None def _safe_wh(window: tk.Misc) -> tuple[int | None, int | None]: """Return current window size without raising.""" try: return int(window.winfo_width()), int(window.winfo_height()) except Exception: return None, None def _window_label(window: tk.Misc) -> str: """Return a readable label for log messages.""" try: title = str(window.title()).strip() if title: return title except Exception: pass try: return str(window) except Exception: return "" def _work_area_bounds(window: tk.Misc) -> tuple[int, int, int, int]: """Return the desktop work area excluding the taskbar when available.""" try: if hasattr(ctypes, "windll"): class RECT(ctypes.Structure): _fields_ = [ ("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long), ] rect = RECT() SPI_GETWORKAREA = 0x0030 if ctypes.windll.user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, ctypes.byref(rect), 0): return int(rect.left), int(rect.top), int(rect.right), int(rect.bottom) except Exception: pass return 0, 0, int(window.winfo_screenwidth()), int(window.winfo_screenheight()) def _taskbar_thickness(window: tk.Misc) -> int: """Return the Windows taskbar thickness when it can be determined.""" try: screen_h = int(window.winfo_screenheight()) _left, _top, _right, work_bottom = _work_area_bounds(window) inferred = max(0, screen_h - int(work_bottom)) if inferred > 0: return inferred except Exception: pass try: if hasattr(ctypes, "windll"): class RECT(ctypes.Structure): _fields_ = [ ("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long), ] class APPBARDATA(ctypes.Structure): _fields_ = [ ("cbSize", ctypes.c_uint), ("hWnd", ctypes.c_void_p), ("uCallbackMessage", ctypes.c_uint), ("uEdge", ctypes.c_uint), ("rc", RECT), ("lParam", ctypes.c_long), ] ABM_GETTASKBARPOS = 0x00000005 abd = APPBARDATA() abd.cbSize = ctypes.sizeof(APPBARDATA) if ctypes.windll.shell32.SHAppBarMessage(ABM_GETTASKBARPOS, ctypes.byref(abd)): rect = abd.rc width = max(0, int(rect.right) - int(rect.left)) height = max(0, int(rect.bottom) - int(rect.top)) return max(width, height) except Exception: pass return 0 def _window_nonclient_extra(window: tk.Misc) -> tuple[int, int]: """Return extra outer frame size added by Windows around the client area.""" try: if not hasattr(ctypes, "windll"): return 0, 0 class RECT(ctypes.Structure): _fields_ = [ ("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long), ] class POINT(ctypes.Structure): _fields_ = [ ("x", ctypes.c_long), ("y", ctypes.c_long), ] user32 = ctypes.windll.user32 hwnd = int(window.winfo_id()) outer = RECT() client = RECT() origin = POINT() if not user32.GetWindowRect(hwnd, ctypes.byref(outer)): return 0, 0 if not user32.GetClientRect(hwnd, ctypes.byref(client)): return 0, 0 if not user32.ClientToScreen(hwnd, ctypes.byref(origin)): return 0, 0 outer_w = max(0, int(outer.right) - int(outer.left)) outer_h = max(0, int(outer.bottom) - int(outer.top)) client_w = max(0, int(client.right) - int(client.left)) client_h = max(0, int(client.bottom) - int(client.top)) extra_w = max(0, outer_w - client_w) extra_h = max(0, outer_h - client_h) return extra_w, extra_h except Exception: return 0, 0 def _window_outer_bounds(window: tk.Misc) -> tuple[int, int, int, int] | None: """Return the actual outer window rect in screen coordinates.""" try: if not hasattr(ctypes, "windll"): return None class RECT(ctypes.Structure): _fields_ = [ ("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long), ] rect = RECT() hwnd = int(window.winfo_id()) if not ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): return None return int(rect.left), int(rect.top), int(rect.right), int(rect.bottom) except Exception: return None def _set_window_alpha(window: tk.Misc, alpha: float) -> None: """Best-effort helper to change a window opacity.""" try: window.attributes("-alpha", float(alpha)) except Exception: pass def _set_window_bounds(child: tk.Misc, x: int, y: int, width: int | None = None, height: int | None = None) -> None: """Move a toplevel to the requested bounds, resizing it when dimensions are provided.""" before_x, before_y = _safe_xy(child) before_w, before_h = _safe_wh(child) _MODULE_LOGGER.debug( "set_bounds.start window=%s from=(%s,%s,%s,%s) target=(%s,%s,%s,%s)", _window_label(child), before_x, before_y, before_w, before_h, x, y, width, height, ) try: child.state("normal") except Exception: pass try: child.deiconify() except Exception: pass try: if hasattr(ctypes, "windll"): hwnd = int(child.winfo_id()) SWP_NOZORDER = 0x0004 SWP_NOACTIVATE = 0x0010 flags = SWP_NOZORDER | SWP_NOACTIVATE move_w = 0 if width is None else int(width) move_h = 0 if height is None else int(height) if width is None or height is None: flags |= 0x0001 # SWP_NOSIZE ctypes.windll.user32.SetWindowPos(hwnd, 0, int(x), int(y), move_w, move_h, flags) if width is None or height is None: child.geometry(f"+{x}+{y}") else: child.geometry(f"{int(width)}x{int(height)}+{x}+{y}") except Exception: fallback_w = max(child.winfo_width(), child.winfo_reqwidth()) if width is None else int(width) fallback_h = max(child.winfo_height(), child.winfo_reqheight()) if height is None else int(height) child.geometry(f"{fallback_w}x{fallback_h}+{x}+{y}") try: child.update_idletasks() except Exception: pass after_x, after_y = _safe_xy(child) after_w, after_h = _safe_wh(child) _MODULE_LOGGER.debug( "set_bounds.end window=%s final=(%s,%s,%s,%s) target=(%s,%s,%s,%s)", _window_label(child), after_x, after_y, after_w, after_h, x, y, width, height, ) def _set_window_position(child: tk.Misc, x: int, y: int) -> None: """Move a toplevel to the requested screen coordinates without resizing it.""" _set_window_bounds(child, x, y, None, None) def _ensure_window_position(child: tk.Misc, x: int, y: int, *, tolerance: int = 2) -> None: """Only correct the window position when it really drifted from the target.""" try: current_x, current_y = _safe_xy(child) if current_x is None or current_y is None: _set_window_position(child, x, y) return if abs(current_x - int(x)) <= tolerance and abs(current_y - int(y)) <= tolerance: return except Exception: pass _set_window_position(child, x, y) def _batch_set_window_positions(windows: list[tk.Misc], positions: list[tuple[int, int]]) -> bool: """Try to reposition multiple windows in one Win32 batch to reduce flicker.""" if not hasattr(ctypes, "windll"): return False if len(windows) != len(positions) or not windows: return False try: user32 = ctypes.windll.user32 WM_SETREDRAW = 0x000B RDW_INVALIDATE = 0x0001 RDW_ALLCHILDREN = 0x0080 RDW_FRAME = 0x0400 redraw_hwnds: list[int] = [] hdwp = user32.BeginDeferWindowPos(len(windows)) if not hdwp: return False SWP_NOSIZE = 0x0001 SWP_NOZORDER = 0x0004 SWP_NOACTIVATE = 0x0010 SWP_NOREDRAW = 0x0008 SWP_DEFERERASE = 0x2000 flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOREDRAW | SWP_DEFERERASE for child, (x, y) in zip(windows, positions): try: child.state("normal") except Exception: pass try: child.deiconify() except Exception: pass hwnd = int(child.winfo_id()) redraw_hwnds.append(hwnd) try: user32.SendMessageW(hwnd, WM_SETREDRAW, 0, 0) except Exception: pass hdwp = user32.DeferWindowPos(hdwp, hwnd, 0, int(x), int(y), 0, 0, flags) if not hdwp: for redraw_hwnd in redraw_hwnds: try: user32.SendMessageW(redraw_hwnd, WM_SETREDRAW, 1, 0) user32.RedrawWindow(redraw_hwnd, None, None, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_FRAME) except Exception: pass return False ok = bool(user32.EndDeferWindowPos(hdwp)) for redraw_hwnd in redraw_hwnds: try: user32.SendMessageW(redraw_hwnd, WM_SETREDRAW, 1, 0) user32.RedrawWindow(redraw_hwnd, None, None, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_FRAME) except Exception: pass return ok except Exception: return False def _restack_windows(windows: list[tk.Misc]) -> None: """Lift windows from back to front without mixing movement and z-order updates.""" for child in windows: try: child.lift() except Exception: pass def place_window_below_parent(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): """Place ``child`` so its outer top edge sits just below ``parent``. The placement uses root-window coordinates and preserves the child's computed width/height. Call it after the child has a geometry. """ try: parent.update_idletasks() child.update_idletasks() # On Windows/Tk, ``winfo_rootx`` starts at the inner client area, # while ``winfo_x`` tracks the outer window frame. Using ``winfo_x`` # keeps child windows flush with the launcher's external left border. x = parent.winfo_x() + int(x_offset) y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap) _set_window_position(child, x, y) except Exception: pass def place_window_below_parent_later(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): """Schedule child placement on the Tk queue after geometry settles.""" try: child.after(0, lambda: place_window_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap)) except Exception: place_window_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap) def place_window_fullsize_below_parent(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): """Place a child below the launcher and size it to the full remaining work area.""" try: parent.update_idletasks() child.update_idletasks() work_left, _work_top, work_right, work_bottom = _work_area_bounds(parent) screen_h = int(parent.winfo_screenheight()) x = parent.winfo_x() + int(x_offset) y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap) taskbar_h = _taskbar_thickness(parent) usable_bottom = int(work_bottom) if taskbar_h > 0: usable_bottom = min(usable_bottom, screen_h - int(taskbar_h)) extra_w, extra_h = _window_nonclient_extra(child) width = max(320, int(work_right) - int(x) - int(extra_w)) height = max(240, int(usable_bottom) - int(y) - int(extra_h)) _MODULE_LOGGER.debug( "fullsize.calc window=%s x=%s y=%s work_right=%s usable_bottom=%s taskbar=%s extra=(%s,%s) final=(%s,%s)", _window_label(child), x, y, work_right, usable_bottom, taskbar_h, extra_w, extra_h, width, height, ) _set_window_bounds(child, x, y, width, height) except Exception: pass def _fit_window_to_work_area(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0) -> None: """Trim a rendered child window so its outer frame stays above the taskbar.""" try: if not getattr(child, "winfo_exists", lambda: False)(): return parent.update_idletasks() child.update_idletasks() _work_left, _work_top, _work_right, work_bottom = _work_area_bounds(parent) screen_h = int(parent.winfo_screenheight()) taskbar_h = _taskbar_thickness(parent) usable_bottom = int(work_bottom) if taskbar_h > 0: usable_bottom = min(usable_bottom, screen_h - int(taskbar_h)) outer = _window_outer_bounds(child) if outer is None: return left, top, right, bottom = outer overflow = int(bottom) - int(usable_bottom) _MODULE_LOGGER.debug( "fit_window.check window=%s outer=(%s,%s,%s,%s) usable_bottom=%s overflow=%s", _window_label(child), left, top, right, bottom, usable_bottom, overflow, ) if overflow <= 0: return current_w, current_h = _safe_wh(child) if current_w is None or current_h is None: return new_h = max(240, int(current_h) - int(overflow) - 2) x = parent.winfo_x() + int(x_offset) y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap) _MODULE_LOGGER.debug( "fit_window.apply window=%s current=(%s,%s) new_h=%s", _window_label(child), current_w, current_h, new_h, ) _set_window_bounds(child, x, y, int(current_w), int(new_h)) except Exception: _MODULE_LOGGER.exception("fit_window.error") def place_window_fullsize_below_parent_later(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): """Schedule full-size placement below the launcher after geometry settles.""" try: try: _set_window_alpha(child, 0.0) except Exception: pass child.after(0, lambda: place_window_fullsize_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap)) child.after(120, lambda: _fit_window_to_work_area(parent, child, x_offset=x_offset, y_gap=y_gap)) child.after(260, lambda: _fit_window_to_work_area(parent, child, x_offset=x_offset, y_gap=y_gap)) child.after(300, lambda: _set_window_alpha(child, 1.0) if getattr(child, "winfo_exists", lambda: False)() else None) except Exception: place_window_fullsize_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap) def tile_children_below_parent(parent: tk.Misc, children: list[tk.Misc], *, gap: int = 8, margin: int = 8): """Arrange open children in a compact grid below the launcher.""" windows = [w for w in children if w is not None and getattr(w, "winfo_exists", lambda: False)()] if not windows: return try: parent.update_idletasks() start_x = parent.winfo_rootx() + int(margin) start_y = parent.winfo_rooty() + parent.winfo_height() + int(margin) screen_w = parent.winfo_screenwidth() screen_h = parent.winfo_screenheight() avail_w = max(320, screen_w - start_x - int(margin)) avail_h = max(240, screen_h - start_y - int(margin)) count = len(windows) cols = max(1, math.ceil(math.sqrt(count))) rows = max(1, math.ceil(count / cols)) cell_w = max(320, (avail_w - (cols - 1) * int(gap)) // cols) cell_h = max(240, (avail_h - (rows - 1) * int(gap)) // rows) for idx, child in enumerate(windows): row = idx // cols col = idx % cols x = start_x + col * (cell_w + int(gap)) y = start_y + row * (cell_h + int(gap)) child.geometry(f"{cell_w}x{cell_h}+{x}+{y}") try: child.lift() except Exception: pass except Exception: pass def cascade_children_below_parent( parent: tk.Misc, children: list[tk.Misc], *, x_offset_step: int = 20, y_offset_step: int = 20, margin_left: int = 0, margin_top: int = 0, ): """Arrange open children in cascade order below the launcher.""" windows = [w for w in children if w is not None and getattr(w, "winfo_exists", lambda: False)()] if not windows: return try: parent.update_idletasks() base_x = parent.winfo_x() + int(margin_left) base_y = parent.winfo_rooty() + parent.winfo_height() + int(margin_top) positions: list[tuple[int, int]] = [] _MODULE_LOGGER.info( "cascade.start parent=%s base=(%s,%s) count=%s x_step=%s y_step=%s", _window_label(parent), base_x, base_y, len(windows), x_offset_step, y_offset_step, ) for idx, child in enumerate(windows): child.update_idletasks() x = base_x + idx * int(x_offset_step) y = base_y + idx * int(y_offset_step) positions.append((x, y)) _MODULE_LOGGER.info( "cascade.window index=%s window=%s target=(%s,%s)", idx, _window_label(child), x, y, ) batched = _batch_set_window_positions(windows, positions) _MODULE_LOGGER.info("cascade.batch_applied=%s", batched) for child, (x, y) in zip(windows, positions): try: if not batched: _set_window_position(child, x, y) except Exception: pass try: parent.after(10, lambda wins=list(windows): _restack_windows([w for w in wins if getattr(w, "winfo_exists", lambda: False)()])) except Exception: _restack_windows(windows) for child, (x, y) in zip(windows, positions): try: child.after( 110, lambda w=child, px=x, py=y: _ensure_window_position(w, px, py) if getattr(w, "winfo_exists", lambda: False)() else None, ) except Exception: pass except Exception: _MODULE_LOGGER.exception("cascade.error")