utils/gatelock/gatelock/_density.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

197 lines
6.7 KiB
Python

"""Screen-height-derived compaction for the design-system type/space scales.
The token scales in :class:`~gatelock._window.LockConfig` are authored in
pixels, and every screen in every app fits a 1366x768 panel at full size --
which is checked, per app, by ``scripts/verify_screen_fits.py``. So 768px and
up is *not* compacted: shrinking type a screen has room for buys nothing.
Below 768px there is no such evidence, and a lock surface cannot solve
"content taller than the screen" by scrolling -- it is a full-screen, grabbed
window whose whole job is to present one screen's worth of decision. There the
scale is compacted as a best-effort so those panels degrade gracefully rather
than hiding a submit button.
This module resolves one process-wide factor from the display height and
applies it inside ``type_px()``/``space()``, which is what lets it reach the
module-level ``LockConfig()`` constants the apps build at import time, before
any Tk root exists.
The factor is deliberately *not* a continuous function of height: two
breakpoints keep the rendering predictable and testable, and keep every
supported screen looking exactly as the design system specifies.
"""
from __future__ import annotations
from contextlib import contextmanager
import logging
import os
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
_logger = logging.getLogger(__name__)
# Set to a float to force a factor -- used by the fit-check harness to render a
# target resolution's layout on whatever display it happens to run on, and
# available as a user escape hatch.
ENV_OVERRIDE = "GATELOCK_UI_DENSITY"
# (minimum screen height, factor). First match wins, tallest first.
# 768 and up is verified to fit at full size, so it is never compacted.
# Anything shorter is best-effort: compacted, but not covered by any app's
# fit check.
_BREAKPOINTS: tuple[tuple[int, float], ...] = (
(768, 1.0),
(0, 0.7),
)
# Nothing may shrink below these, whatever the factor says: text stops being
# readable and touching gaps stop reading as separation.
_MIN_TYPE_PX = 11
_MIN_SPACE_PX = 2
# One-slot cache. A dict rather than a module global with ``global`` writes,
# so the module has no rebindable state and needs no lint escape hatch.
_cache: dict[str, float] = {}
def factor_for_height(height_px: int) -> float:
"""Return the compaction factor for a display of ``height_px``."""
for minimum, factor in _BREAKPOINTS:
if height_px >= minimum:
return factor
# Unreachable: the last breakpoint matches every non-negative height. A
# negative height means Tk reported nonsense; treat it as "do not compact"
# rather than silently picking the smallest scale.
_logger.warning(
"screen height %dpx is not a real height; leaving the type scale uncompacted",
height_px,
)
return 1.0
def _usable_height(raw: object) -> int | None:
"""Return ``raw`` if it is a real pixel height, else None with a warning.
Deliberately a type check rather than ``int(raw)`` in a ``try``: the
consuming apps replace the whole ``tkinter`` module with a fake in their
test suites, so ``winfo_screenheight()`` can return a mock that neither
converts nor raises anything catchable -- and an ``except`` clause naming
a mocked ``tk.TclError`` is itself a TypeError.
"""
if isinstance(raw, int) and not isinstance(raw, bool):
return raw
_logger.warning(
"screen height came back as %r, not a number; the type scale stays "
"uncompacted, which can overflow a short screen",
raw,
)
return None
def _screen_height() -> int | None:
"""Return the display height in px, or None when there is no display."""
root = getattr(tk, "_default_root", None)
if root is not None:
return _usable_height(root.winfo_screenheight())
try:
probe = tk.Tk()
except tk.TclError as exc:
_logger.warning(
"no display available to size the type scale (%s); leaving it "
"uncompacted -- expected in headless tests, a bug anywhere else",
exc,
)
return None
try:
return _usable_height(probe.winfo_screenheight())
finally:
probe.destroy()
def density() -> float:
"""Return the process-wide compaction factor, resolving it once.
Cached because it is read for every font and every pad on every repaint,
and because a lock surface's display cannot change under it without the
surfaces being rebuilt anyway.
"""
cached = _cache.get("factor")
if cached is not None:
return cached
override = _forced_factor()
if override is not None:
_cache["factor"] = override
_logger.info("type scale forced to %.2f by %s", override, ENV_OVERRIDE)
return override
height = _screen_height()
factor = 1.0 if height is None else factor_for_height(height)
_cache["factor"] = factor
if factor != 1.0:
_logger.info(
"compacting the type/space scale to %.2f for a %dpx screen",
factor,
height,
)
return factor
def _forced_factor() -> float | None:
"""Return the env-forced factor, or None when unset or unusable."""
override = os.environ.get(ENV_OVERRIDE)
if not override:
return None
try:
return float(override)
except ValueError:
_logger.warning(
"%s=%r is not a number; ignoring it and sizing from the screen instead",
ENV_OVERRIDE,
override,
)
return None
def reset_cache() -> None:
"""Forget the resolved factor. For tests and the fit-check harness."""
_cache.clear()
def scale_type(px: int) -> int:
"""Compact a type-scale value, never below :data:`_MIN_TYPE_PX`."""
return max(_MIN_TYPE_PX, round(px * density()))
def scale_space(px: int) -> int:
"""Compact a spacing value, never below :data:`_MIN_SPACE_PX`.
A zero stays zero: "no gap" is a deliberate choice, not a small gap.
"""
if px <= 0:
return px
return max(_MIN_SPACE_PX, round(px * density()))
@contextmanager
def forced(factor: float) -> Iterator[None]:
"""Force the compaction factor for the duration of the block.
For the fit-check harness and tests: it lets a 1080p workstation render
exactly what a 768px panel would, which is the whole point of checking
the fit somewhere other than the target machine.
"""
previous = os.environ.get(ENV_OVERRIDE)
os.environ[ENV_OVERRIDE] = str(factor)
reset_cache()
try:
yield
finally:
if previous is None:
os.environ.pop(ENV_OVERRIDE, None)
else:
os.environ[ENV_OVERRIDE] = previous
reset_cache()