feat(ui): adapt to the screen and its scaling, not to fixed pixels

Two things differ between machines and only one of them is width: a 4K panel
has more pixels, while a 125%/150% display has the same logical pixels holding
LESS, because every label and margin is taller. Breakpoints written as raw
pixels only hold on the machine they were tuned on.

  * ui_scale() derives a factor from font height (1.0 at the 15px line the
    layouts were measured against) and every narrow-guard threshold is
    multiplied by it, so panes fold when the content is cramped rather than
    when a number is crossed.
  * The window takes a share of the available screen (80% × 85%) with the old
    1180×760 as the floor, instead of opening at that size on any monitor.
  * Moving the window to another screen re-pins the assistant and re-decides
    the fold, since the new screen's work area and scaling may differ.

Found by tools/check_multi_screen.py, which walks 5 window sizes × 3 font
scales:

  * At 150%, Schedule was clipped on 1280 and 1366 screens and the window's
    own minimum grew to 1459px — wider than a 1280 laptop, so the app could
    not fit at all. The cause was not the lanes: the one-line lane-count
    summary in the header reported a sizeHint wide enough to set the minimum
    width of the entire window. It now yields first (its text stays in the
    tooltip); the window minimum drops 1459 → 752 and holds there at every
    scale.

Also: these checkers exited 0xC0000409 from a Qt teardown crash AFTER printing
their verdict. check_probes_bite decides whether a probe caught its mutation by
reading exit codes, so a crash would have counted as "caught" — the round could
have passed while proving nothing. They now flush and os._exit with the real
verdict, and round 5 still catches all six mutations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NamPDT
2026-08-17 14:10:19 +09:00
co-authored by Claude Opus 5
parent f381fd298a
commit b6dee044c9
12 changed files with 253 additions and 15 deletions
+23 -1
View File
@@ -159,6 +159,21 @@ def guard_wheel(root: QWidget) -> None:
w.installEventFilter(_wheel_guard)
def ui_scale(widget: QWidget) -> float:
"""How much bigger this machine draws things than the design baseline.
Breakpoints written as raw pixels only hold on the screen they were tuned
on. At 125%/150% display scaling Qt still reports logical pixels, but every
label, button and margin is taller — so the same layout needs MORE logical
width before it stops being cramped. Font height is the honest proxy for
that: it moves with the display scale and with a user's font-size choice,
both of which change how much fits.
1.0 at the 15px line height the layouts were measured against.
"""
return max(0.75, min(2.5, widget.fontMetrics().height() / 15.0))
class _NarrowGuard(QObject):
"""Calls back when the WINDOW crosses a width threshold.
@@ -184,6 +199,11 @@ class _NarrowGuard(QObject):
if win is not None and win is not self._owner and win is not self._window:
win.installEventFilter(self)
self._window = win
# Dragging the window to a monitor with different scaling changes
# how much fits without changing its width, so re-decide then too.
handle = win.windowHandle()
if handle is not None:
handle.screenChanged.connect(lambda *_a: self.check())
self.check()
def eventFilter(self, obj, ev): # noqa: N802 - Qt override
@@ -194,7 +214,9 @@ class _NarrowGuard(QObject):
def check(self) -> None:
win = self._owner.window()
width = win.width() if win is not None else self._owner.width()
narrow = width < self._threshold
# The threshold is written for the baseline scale and grows with the
# machine's — see ui_scale().
narrow = width < self._threshold * ui_scale(self._owner)
if narrow == self._auto:
return
self._auto = narrow