utils/gatelock/gatelock/_fitcheck.py
Krzysztof kuhy Rudnicki 7e7c34a073
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
gatelock: make fit/scroll geometry tests immune to a real WM
Under i3 (not Xvfb), a managed tk.Tk() toplevel's geometry() request
was silently rewritten to the tiled pane size instead of the
requested WxH, so the content never actually overflowed and every
assertion built on "does this overflow" failed -- 11 tests across
test_fitcheck.py/test_scrollable.py. Fix: overrideredirect the
throwaway root, matching how every production lock window is already
built and for the identical reason (a WM must not be able to rewrite
geometry a fit/scroll check depends on).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:00:20 +02:00

146 lines
5.0 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:
# A managed window has its geometry rewritten wholesale by a real
# window manager -- confirmed under i3, which tiled this to the pane
# size instead of the requested width/height, silently measuring the
# wrong resolution. Production lock windows are overrideredirect for
# exactly this reason; the throwaway root here must match so the
# measurement is honest regardless of what WM is running the check.
root.overrideredirect(boolean=True)
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)