utils/gatelock/gatelock/_fitcheck.py
Krzysztof Rudnicki ba549f8f05 gatelock 0.4.0: the viewport moves for the user and nobody else
On 2026-08-03 the screen locker's lock screen scrolled to mid-content and
back with nobody touching the keyboard, on a 1366x768 panel. Measured under
Xvfb with the real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145.

Two causes, both here:

* ScrollableSurface followed every <FocusIn>. The apps focus a widget on
  every repaint (and gatelock re-asserts focus from its recovery tick), and
  Tk delivers <FocusIn> to the focused widget's whole ancestor chain when the
  toplevel takes X focus -- so each repaint aimed a scroll at containers
  taller than the screen. Now a focus move scrolls only when it followed a
  real keypress, or a click on the widget that took focus, and only for a
  leaf. Tab onto a clipped field still reveals it; nothing else moves.
* finalize()'s reset-to-top cancelled the centring, because centring moved
  the canvas item down and the region's top edge with it. The region is now
  pinned to the viewport whenever the content fits, so the view cannot move
  at all, the scrollbar is taken down, and centring survives the repaint.

Content that does not fit is a layout defect, not a mode to live in, so
finalize() reports it at warning and gatelock.measure_fit() lets each app
gate on it before shipping (scripts/verify_screen_fits.py in all four).

Also: LockConfig's type/space scales now compact below 768px (_density),
which is where a screen has no room to be at design-system size and no
scrollbar to make up for it. 768 and up is verified to fit at full size, so
it is never compacted.

Tests: _scrollable had none; the regression is now stated as code
(programmatic focus must not move yview; Tab must). 323 pass, coverage
95.38% -> 99.44%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 15:50:51 +02:00

139 lines
4.6 KiB
Python

"""Measure whether a lock screen fits its surface, at a chosen resolution.
A lock surface is fullscreen, grabbed and (on the production path) VT-locked:
whatever does not fit is content the user cannot reach except by scrolling a
screen that should never have needed scrolling. :meth:`ScrollableSurface.
finalize` reports overflow at runtime; this module is the half that runs
*before* shipping, so a consuming app can assert in CI that every one of its
screens fits on the smallest display it supports.
Usage from an app's ``scripts/verify_screen_fits.py``::
from gatelock import measure_fit
result = measure_fit("retry", paint_retry, width=1366, height=768)
if not result.fits:
...
The measurement runs against real Tk on a real (throwaway) display -- widget
heights depend on the font engine, so nothing short of rendering can answer
this honestly.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import _density
from gatelock._scrollable import ScrollableSurface
from gatelock._window import LockConfig
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class FitResult:
"""What one screen measured at one resolution."""
name: str
width: int
height: int
content_px: int
viewport_px: int
@property
def overflow_px(self) -> int:
"""Pixels of content past the bottom of the viewport (0 when it fits)."""
return max(0, self.content_px - self.viewport_px)
@property
def fits(self) -> bool:
"""Whether the whole screen is visible without scrolling."""
return self.overflow_px == 0
def describe(self) -> str:
"""One line for a verification script's output."""
verdict = "fits" if self.fits else f"OVERFLOWS by {self.overflow_px}px"
return (
f"{self.name} @ {self.width}x{self.height}: "
f"{self.content_px}px in {self.viewport_px}px -- {verdict}"
)
def measure_fit(
name: str,
build: Callable[[ScrollableSurface], tk.Misc | None],
*,
width: int,
height: int,
config: LockConfig | None = None,
) -> FitResult:
"""Build one screen at ``width``x``height`` and measure its height.
The type/space scale is forced to whatever a display of ``height`` would
resolve to, so the result is what the target machine renders rather than
what the machine running the check renders.
Args:
name: Screen name, for the report.
build: Paints the screen. Receives the surface; build into
``surface.content``. May return the widget whose height *is* the
screen's height -- needed for a layout that ``place``s a centred
frame, since ``place`` does not propagate a child's size to its
parent and the parent would otherwise measure as empty.
width: Surface width in px.
height: Surface height in px.
config: Token source. Defaults to a stock :class:`LockConfig`.
Returns:
The measurement. Never raises for a screen that merely overflows --
overflow is the thing being reported, not an error.
"""
with _density.forced(_density.factor_for_height(height)):
return _measure(name, build, width, height, config)
def _measure(
name: str,
build: Callable[[ScrollableSurface], tk.Misc | None],
width: int,
height: int,
config: LockConfig | None,
) -> FitResult:
"""Render the screen once and read its height back."""
root = tk.Tk()
try:
root.geometry(f"{width}x{height}+0+0")
root.update_idletasks()
surface = ScrollableSurface(root, config or LockConfig(), center_when_fits=True)
surface.container.place(relx=0, rely=0, relwidth=1, relheight=1)
root.update()
measured = build(surface)
surface.finalize()
root.update()
return FitResult(
name=name,
width=width,
height=height,
content_px=(measured or surface.content).winfo_reqheight(),
viewport_px=surface.canvas.winfo_height(),
)
finally:
root.destroy()
def report_fit(results: list[FitResult]) -> bool:
"""Log every measurement and return whether all of them fit."""
for result in sorted(results, key=lambda r: (-r.overflow_px, r.name)):
if result.fits:
_logger.info(" %s", result.describe())
else:
_logger.error(" %s", result.describe())
return all(result.fits for result in results)