mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-14 03:30:59 +01:00
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
139 lines
4.6 KiB
Python
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)
|