Checkpoint before more window sizing work
This commit is contained in:
335
window_placement.py
Normal file
335
window_placement.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""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 "<window>"
|
||||
|
||||
|
||||
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 _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 _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
|
||||
hdwp = user32.BeginDeferWindowPos(len(windows))
|
||||
if not hdwp:
|
||||
return False
|
||||
|
||||
SWP_NOSIZE = 0x0001
|
||||
SWP_NOZORDER = 0x0004
|
||||
SWP_NOACTIVATE = 0x0010
|
||||
flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
|
||||
|
||||
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())
|
||||
hdwp = user32.DeferWindowPos(hdwp, hwnd, 0, int(x), int(y), 0, 0, flags)
|
||||
if not hdwp:
|
||||
return False
|
||||
|
||||
return bool(user32.EndDeferWindowPos(hdwp))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
x = parent.winfo_x() + int(x_offset)
|
||||
y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap)
|
||||
width = max(320, int(work_right) - int(x))
|
||||
height = max(240, int(work_bottom) - int(y))
|
||||
_set_window_bounds(child, x, y, width, height)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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:
|
||||
child.after(0, lambda: place_window_fullsize_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap))
|
||||
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)
|
||||
child.after(110, lambda w=child, px=x, py=y: _set_window_position(w, px, py) if getattr(w, "winfo_exists", lambda: False)() else None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
child.lift()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
_MODULE_LOGGER.exception("cascade.error")
|
||||
Reference in New Issue
Block a user