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
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 15:50:34 +02:00
parent f0be8c5919
commit ba549f8f05
10 changed files with 1232 additions and 45 deletions

View File

@ -24,6 +24,7 @@ from gatelock._escape import (
EscapePolicy,
EscapeTracker,
)
from gatelock._fitcheck import FitResult, measure_fit, report_fit
from gatelock._guards import assert_not_under_pytest, wait_for_x_server
from gatelock._keyboard import bind_activate, bind_cancel, escape_text_tab_trap
from gatelock._outputs import (
@ -47,7 +48,13 @@ from gatelock._surfaces import (
needs_backdrop_root,
)
from gatelock._vt import disable_vt_switching, restore_vt_switching
from gatelock._window import LockConfig, LockWindow, LockWindowHooks
from gatelock._window import (
LockConfig,
LockWindow,
LockWindowHooks,
SpaceStep,
TypeRole,
)
__all__ = [
"RANK_DIET_GUARD",
@ -60,6 +67,7 @@ __all__ = [
"EscapeHistory",
"EscapePolicy",
"EscapeTracker",
"FitResult",
"GateRoot",
"LockConfig",
"LockWindow",
@ -73,10 +81,12 @@ __all__ = [
"RecoveryLoop",
"RecoveryReport",
"ScrollableSurface",
"SpaceStep",
"SurfaceBuilder",
"SurfaceDelta",
"SurfaceInfo",
"SurfaceSet",
"TypeRole",
"assert_not_under_pytest",
"bind_activate",
"bind_cancel",
@ -85,9 +95,11 @@ __all__ = [
"enumerate_outputs",
"escape_text_tab_trap",
"grab_strength",
"measure_fit",
"mirror_text_widgets",
"needs_backdrop_root",
"parse_xrandr_query",
"report_fit",
"restore_vt_switching",
"wait_for_x_server",
]

View File

@ -0,0 +1,196 @@
"""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()

View File

@ -0,0 +1,138 @@
"""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)

View File

@ -24,17 +24,29 @@ oversights in any one app:
child, so every scrolled-out-of-view field stays ``winfo viewable`` and
stays in the tab chain, while Tk never scrolls to follow focus. Tabbing
therefore lands on fields the user cannot see and cannot bring into view.
:meth:`ScrollableSurface.track_focus` fixes that by scrolling the focused
widget into view.
:meth:`ScrollableSurface.scroll_into_view` fixes that, but only for focus
the *user* moved.
Wheel scrolling is bound too, but as an addition -- never as the only path.
**Scrolling is the fallback, not the design.** A lock screen is supposed to
fit its screen; content that overflows is a layout defect, and :meth:`finalize`
logs it at ``warning`` so it gets fixed rather than lived with. While the
content fits -- the normal case -- the scroll region is pinned to the viewport
and the scrollbar is hidden, so the view cannot move at all. When it does
overflow, the view still only moves in response to a keypress, a click or the
wheel: an earlier version followed *programmatic* focus too, and since the apps
re-focus a widget on every repaint, the screen scrolled itself to mid-content
and back while the user was doing nothing (reported 2026-08-03, 1366x768).
"""
from __future__ import annotations
import logging
import time
import tkinter as tk
from typing import TYPE_CHECKING
import weakref
if TYPE_CHECKING:
from gatelock._window import LockConfig
@ -49,6 +61,14 @@ _ARROW_UNITS = 3
# Extra px kept visible around a widget scrolled into view, so a focused
# field never sits flush against the viewport edge.
_FOCUS_MARGIN_PX = 12
# How long a keypress or click keeps ownership of the viewport. Focus that
# arrives within this window is treated as the user's own navigation (Tab,
# clicking a field, a button press that opens the next screen) and may scroll;
# focus set outside it is the app's own doing and must not move anything.
_USER_INPUT_WINDOW_S = 0.25
# Tk reports 1px for a widget it has not laid out yet. Treating that as a real
# viewport height would report every not-yet-mapped surface as overflowing.
_UNMAPPED_VIEWPORT_PX = 1
class ScrollableSurface:
@ -76,9 +96,12 @@ class ScrollableSurface:
Args:
parent: The surface (or notebook tab) to build inside.
config: Token source for colors and the focus ring.
show_scrollbar: Draw a scrollbar alongside the canvas. Even when
False the content stays keyboard-scrollable; the bar is a
pointer affordance and a visual hint, not the mechanism.
show_scrollbar: Allow a scrollbar alongside the canvas. It is only
ever shown while the content actually overflows -- a bar with
nothing to scroll is chrome that says the screen is bigger than
it is. Even when False the content stays keyboard-scrollable;
the bar is a pointer affordance and a visual hint, not the
mechanism.
center_when_fits: Vertically centre the content while it fits, and
fall back to top-anchored scrolling once it does not. This lets
a viewport replace a ``place``-centred frame without changing
@ -114,7 +137,9 @@ class ScrollableSurface:
# second way to scroll.
**config.focus_kwargs(),
)
self._scrollbar.pack(side="right", fill="y")
# Deliberately not packed here: _apply_fit_state() packs it the
# moment the content is found to overflow, and nothing else should
# put a scrollbar on a screen that fits.
self.canvas.configure(yscrollcommand=self._scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
@ -126,12 +151,23 @@ class ScrollableSurface:
)
self.content.bind("<Configure>", self._on_content_resize, add="+")
self.canvas.bind("<Configure>", self._on_canvas_resize, add="+")
# `content` is bound once, here, because it outlives every repaint --
# binding it in finalize() instead piled up a handler per repaint.
# Its descendants are bound in finalize(), each exactly once: Tk sends
# <FocusIn> to the ancestor chain only when the *toplevel* takes X
# focus, not when focus moves between two fields inside it, so a
# binding on `content` alone would miss every Tab.
self.content.bind("<FocusIn>", self._on_descendant_focus, add="+")
self._focus_bound: weakref.WeakSet[tk.Misc] = weakref.WeakSet()
self._last_user_input = 0.0
self._clicked: tk.Misc | None = None
self._overflowing: bool | None = None
self._bind_scroll_keys()
self._bind_user_input()
def _on_content_resize(self, _event: tk.Event) -> None:
"""Keep the scroll region matched to the content's real size."""
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
self._reposition()
self._apply_fit_state()
def _on_canvas_resize(self, event: tk.Event) -> None:
"""Center the content horizontally and match the viewport width.
@ -140,16 +176,114 @@ class ScrollableSurface:
behave the same as they did before the viewport was introduced.
"""
self.canvas.itemconfigure(self._content_id, width=event.width)
self._reposition()
self._apply_fit_state()
def _reposition(self) -> None:
"""Place the content window, centring vertically only while it fits."""
width = self.canvas.winfo_width()
top = 0
if self._center_when_fits:
spare = self.canvas.winfo_height() - self.content.winfo_reqheight()
top = max(0, spare // 2)
self.canvas.coords(self._content_id, width / 2, top)
def _apply_fit_state(self) -> None:
"""Switch between "fits" and "overflows" layout.
Fitting is the normal, intended state: the scroll region is pinned to
the viewport so *nothing* can scroll, the content is centred, and the
scrollbar is taken down because there is nothing for it to do.
Scrolling only exists as the fallback for content that genuinely does
not fit, and that is a defect worth reporting (see :meth:`finalize`),
not a layout mode to design for.
Pinning the region is also what makes centring survive. It used to be
cancelled: content was centred by moving the canvas *item* down, which
moved the scroll region's top edge down with it, and the reset in
:meth:`finalize` then scrolled to that top edge -- putting the content
back flush against the screen's top edge on every repaint.
"""
view_h = self.canvas.winfo_height()
view_w = self.canvas.winfo_width()
wanted_h = self.content.winfo_reqheight()
overflowing = wanted_h > view_h
if overflowing:
self.canvas.coords(self._content_id, view_w / 2, 0)
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
else:
top = max(0, (view_h - wanted_h) // 2) if self._center_when_fits else 0
self.canvas.coords(self._content_id, view_w / 2, top)
# Region == the *scrollable* area, so yview is exactly (0.0, 1.0)
# and nothing -- key, wheel or scrollbar -- can move it. The focus
# ring is drawn outside that area, so it has to come off both
# dimensions or the region is a hair taller than the viewport and
# a page-down still nudges the screen.
inset = 2 * self._border_px()
self.canvas.configure(
scrollregion=(0, 0, max(1, view_w - inset), max(1, view_h - inset))
)
if overflowing != self._overflowing:
self._overflowing = overflowing
self._show_scrollbar(visible=overflowing)
def _border_px(self) -> int:
"""Width of the canvas's own edge: focus ring plus border."""
return int(self.canvas.cget("highlightthickness")) + int(
self.canvas.cget("borderwidth")
)
def _show_scrollbar(self, *, visible: bool) -> None:
"""Show the scrollbar only while there is something to scroll."""
if self._scrollbar is None:
return
if visible:
# Re-packed before the canvas would put it on the wrong side, so
# the canvas is re-packed after it to keep left/right ordering.
self._scrollbar.pack(side="right", fill="y")
self.canvas.pack_forget()
self.canvas.pack(side="left", fill="both", expand=True)
else:
self._scrollbar.pack_forget()
def _bind_user_input(self) -> None:
"""Record when the user last pressed a key or a mouse button.
Focus-following scrolling is gated on this: the app focuses widgets
programmatically on every repaint (and gatelock re-asserts focus from
its recovery tick), and a viewport that follows *those* moves the
screen under a user who has touched nothing.
"""
targets: list[tk.Misc] = [self.canvas, self.content]
try:
targets.append(self.canvas.winfo_toplevel())
except (tk.TclError, KeyError, AttributeError):
_logger.warning(
"no toplevel for scroll viewport; user-input tracking is "
"bound to the viewport only, so focus-following scrolling "
"stays off unless the viewport itself saw the event"
)
for target in targets:
target.bind("<Key>", self._note_key, add="+")
target.bind("<Button>", self._note_click, add="+")
def _note_key(self, _event: tk.Event | None = None) -> None:
"""Timestamp a keypress -- Tab and arrows move focus deliberately."""
self._last_user_input = time.monotonic()
self._clicked = None
def _note_click(self, event: tk.Event | None = None) -> None:
"""Timestamp a click and remember what was clicked."""
self._last_user_input = time.monotonic()
self._clicked = getattr(event, "widget", None)
def _focus_is_user_driven(self, focused: tk.Misc) -> bool:
"""Whether the user, not the app, is responsible for this focus move.
Time alone is not enough. Clicking "Log Manual Workout" is a user
event, but the focus that follows belongs to the *next screen*, which
the app focuses itself while building -- scrolling there is the same
unasked-for movement as scrolling on a repaint. So a click only counts
when focus landed on the thing that was clicked; a keypress always
counts, because Tab and the arrows are how a user moves focus without
a pointer.
"""
if time.monotonic() - self._last_user_input > _USER_INPUT_WINDOW_S:
return False
if self._clicked is None:
return True
return self._clicked is focused
def _bind_scroll_keys(self) -> None:
"""Bind keyboard and wheel scrolling.
@ -209,25 +343,43 @@ class ScrollableSurface:
def _scroll_end(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_moveto(1.0)
def track_focus(self, widget: tk.Misc | None = None) -> None:
"""Scroll the focused descendant into view, recursively binding.
def _track_focus(self, parent: tk.Misc) -> None:
"""Bind <FocusIn> on every descendant, each exactly once.
Call after the content is built. Every descendant gets a ``<FocusIn>``
handler, because a clipped widget stays in the tab chain: without this,
Tab walks the user onto fields that are scrolled out of sight with no
keyboard way to reveal them.
Args:
widget: Subtree root to bind. Defaults to :attr:`content`.
A repaint destroys and rebuilds the widgets, so this re-runs per
screen; the WeakSet is what keeps a second :meth:`finalize` on the
*same* widgets from stacking a second handler on each of them, and
drops entries as the widgets are destroyed.
"""
target = self.content if widget is None else widget
target.bind("<FocusIn>", self._on_descendant_focus, add="+")
for child in target.winfo_children():
self.track_focus(child)
for child in parent.winfo_children():
if child not in self._focus_bound:
child.bind("<FocusIn>", self._on_descendant_focus, add="+")
self._focus_bound.add(child)
self._track_focus(child)
def _on_descendant_focus(self, event: tk.Event) -> None:
"""Scroll so the newly focused widget is fully visible."""
self.scroll_into_view(event.widget)
"""Reveal a widget the *user* just tabbed or clicked onto.
Two guards, both of which the earlier version lacked:
* The move must follow a real keypress or click. Programmatic
``focus_set()`` runs on every repaint and from gatelock's recovery
tick, and following it scrolled the screen under a user who had
touched nothing -- the reported "scrolls to the bottom and back on
its own".
* Only a *leaf* counts. Tk delivers ``<FocusIn>`` to every ancestor of
the focused widget too, and "scroll this 1643px container into view"
is not a meaningful request in a 768px viewport -- following those
is what made one focus change produce three jumps. A widget with
children is such an ancestor; the focused widget itself is not.
(Read from the event rather than ``focus_get()``, which reports
None whenever the toplevel does not currently own X input focus --
true for every surface behind the one the user is looking at.)
"""
widget = event.widget
if widget.winfo_children() or not self._focus_is_user_driven(widget):
return
self.scroll_into_view(widget)
def scroll_into_view(self, widget: tk.Misc) -> None:
"""Scroll the viewport minimally so ``widget`` is fully visible.
@ -258,16 +410,41 @@ class ScrollableSurface:
return (box[3] - box[1]) if box else 0
def finalize(self) -> None:
"""Settle geometry, wire focus-following, and reset to the top.
"""Settle geometry for freshly built content.
Idempotent, so it is safe to call again after the content changes.
Idempotent -- it re-derives everything from the current content -- so
it is safe to call again after a repaint.
The ``yview_moveto(0.0)`` here is offset *initialisation* for new
content, not a scroll: content that fits cannot be anywhere but 0.0
(see :meth:`_apply_fit_state`), and content that overflows starts at
its top. Nothing else in this class moves the viewport unless the user
asks it to.
Content that does not fit is reported at ``warning``: the surface is
supposed to fit its screen, so needing a scrollbar is a layout defect
to fix, not a mode to live in. ``scripts/verify_screen_fits.py`` in
each consuming app asserts the same property before it ships.
"""
self.content.update_idletasks()
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
self._track_focus(self.content)
self._request_content_size()
self.track_focus()
self._apply_fit_state()
self.canvas.yview_moveto(0.0)
self._reposition()
wanted_h = self.content.winfo_reqheight()
view_h = self.canvas.winfo_height()
# A viewport Tk has not laid out yet reports 1px, which is not an
# overflow -- reporting it would cry wolf on every startup and teach
# the reader to ignore the one message that matters.
if view_h > _UNMAPPED_VIEWPORT_PX and wanted_h > view_h:
_logger.warning(
"lock content overflows its surface: %dpx of content in a "
"%dpx viewport (%dpx off-screen); it is reachable only by "
"scrolling, which no screen should require",
wanted_h,
view_h,
wanted_h - view_h,
)
def _request_content_size(self) -> None:
"""Ask for a canvas big enough for the content, clamped to the screen.
@ -294,6 +471,13 @@ class ScrollableSurface:
)
def fits(self) -> bool:
"""Whether the content currently fits without scrolling."""
"""Whether the content currently fits without scrolling.
Measured the same way :meth:`finalize` and ``gatelock.measure_fit``
measure it -- the content's *requested* height against the viewport --
so a caller can never get a different answer than the one the fit
check gates on. ``bbox("all")`` was the other candidate and disagrees
while the canvas item is mid-layout.
"""
self.canvas.update_idletasks()
return self._content_span() <= self.canvas.winfo_height()
return self.content.winfo_reqheight() <= self.canvas.winfo_height()

View File

@ -32,6 +32,7 @@ import signal
import tkinter as tk
from typing import TYPE_CHECKING, Literal, Protocol
from gatelock import _density
from gatelock._arbiter import RANK_SCREEN_LOCKER
from gatelock._detect import OutputChangeDetector
from gatelock._outputs import OutputEnumerator
@ -162,12 +163,21 @@ class LockConfig:
space_xxl: int = 48
def type_px(self, role: TypeRole = "body") -> int:
"""Return the type-scale size for ``role``, in pixels."""
return int(getattr(self, f"type_{role}"))
"""Return the type-scale size for ``role``, in pixels.
Compacted on short displays -- see :mod:`gatelock._density`. The scale
is authored for 1080p; a 768px panel gets 0.8 of it, because a lock
surface has to fit one screen and cannot scroll its way out of being
too tall.
"""
return _density.scale_type(int(getattr(self, f"type_{role}")))
def space(self, step: SpaceStep = "md") -> int:
"""Return the spacing-scale value for ``step``, in pixels."""
return int(getattr(self, f"space_{step}"))
"""Return the spacing-scale value for ``step``, in pixels.
Compacted on short displays, exactly like :meth:`type_px`.
"""
return _density.scale_space(int(getattr(self, f"space_{step}")))
def font(
self,

View File

@ -0,0 +1,187 @@
"""Tests for the screen-height-derived compaction of the token scales."""
from __future__ import annotations
import tkinter as tk
import types
import pytest
from gatelock import LockConfig, _density
@pytest.fixture(autouse=True)
def _clear_cache() -> None:
"""Each test resolves the factor from scratch."""
_density.reset_cache()
@pytest.mark.parametrize(
("height", "expected"),
[
(2160, 1.0),
(1080, 1.0),
(900, 1.0),
(768, 1.0),
(767, 0.7),
(600, 0.7),
(0, 0.7),
],
)
def test_breakpoints(height: int, expected: float) -> None:
"""768px and up renders at full size; anything shorter is compacted."""
assert _density.factor_for_height(height) == expected
def test_a_nonsense_height_does_not_compact() -> None:
"""A negative height is a Tk misreport, not a tiny screen.
Compacting on it would shrink the UI of a machine nothing is known about;
leaving it alone keeps the design-system size, which the fit check has
actually verified.
"""
assert _density.factor_for_height(-1) == 1.0
def test_forced_factor_applies_and_is_restored() -> None:
"""``forced`` is what lets a 1080p machine render a 768px layout."""
with _density.forced(0.5):
assert _density.density() == 0.5
assert _density.scale_type(32) == 16
_density.reset_cache()
assert _density.density() != 0.5 or _density.factor_for_height(768) == 0.5
def test_type_never_shrinks_below_the_readable_floor() -> None:
"""Compaction may not push text under 11px, whatever the factor says."""
with _density.forced(0.1):
assert _density.scale_type(32) == 11
assert _density.scale_type(12) == 11
def test_spacing_floor_and_zero() -> None:
"""A gap never collapses to nothing, but "no gap" stays no gap.
Zero is a decision -- two labels deliberately touching -- so it must
survive compaction unchanged, while a real gap keeps at least 2px or it
stops reading as separation at all.
"""
with _density.forced(0.1):
assert _density.scale_space(48) == 5
assert _density.scale_space(4) == 2
assert _density.scale_space(0) == 0
def test_lockconfig_reads_through_the_scale() -> None:
"""The tokens themselves compact -- that is what reaches every widget."""
config = LockConfig()
with _density.forced(0.5):
assert config.type_px("display") == 16
assert config.space("xxl") == 24
# font() applies Tk's negative-is-pixels convention on top.
assert config.font("display", bold=True) == ("Arial", -16, "bold")
def test_a_non_numeric_screen_height_is_reported_not_trusted() -> None:
"""A faked tkinter returns a mock, not a height; that must not compact.
The consuming apps replace ``tkinter`` wholesale in their test suites, so
this is the shape ``density()`` sees there. Guessing a factor from a mock
would silently resize every screen in a test run.
"""
assert _density._usable_height(object()) is None
assert _density._usable_height(768) == 768
# bool is an int subclass, and a flag is not a screen height.
flag = True
assert _density._usable_height(flag) is None
def test_density_falls_back_to_full_size_without_a_display(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No display means no evidence, so nothing is compacted."""
monkeypatch.setattr(_density, "_screen_height", lambda: None)
assert _density.density() == 1.0
def test_density_reads_the_screen_and_caches_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The factor is resolved once: it is read for every font on every repaint."""
calls: list[int] = []
def _fake_height() -> int:
calls.append(1)
return 600
monkeypatch.setattr(_density, "_screen_height", _fake_height)
assert _density.density() == 0.7
assert _density.density() == 0.7
assert len(calls) == 1
def test_a_junk_override_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
"""A typo in the env var must not silently pick some other scale."""
monkeypatch.setenv(_density.ENV_OVERRIDE, "big")
monkeypatch.setattr(_density, "_screen_height", lambda: 1080)
assert _density.density() == 1.0
class _StubRoot:
"""A Tk root stand-in that reports one height and records its teardown."""
def __init__(self, height: int, destroyed: list[bool]) -> None:
self._height = height
self._destroyed = destroyed
def winfo_screenheight(self) -> int:
"""Return the configured height."""
return self._height
def destroy(self) -> None:
"""Record that the probe root was cleaned up."""
self._destroyed.append(True)
def test_probe_root_is_created_and_destroyed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With no root yet, the factor is read from a throwaway one.
The apps build their ``LockConfig`` at import time, before any Tk root
exists, so this is the normal path -- and the probe must not survive it.
"""
destroyed: list[bool] = []
stub = types.SimpleNamespace(
_default_root=None,
Tk=lambda: _StubRoot(600, destroyed),
TclError=tk.TclError,
)
monkeypatch.setattr(_density, "tk", stub)
assert _density._screen_height() == 600
assert destroyed == [True]
def test_no_display_leaves_the_scale_alone(monkeypatch: pytest.MonkeyPatch) -> None:
"""A headless run cannot size anything, and must say so rather than guess."""
message = "no display name and no $DISPLAY environment variable"
def _no_display() -> None:
raise tk.TclError(message)
stub = types.SimpleNamespace(
_default_root=None, Tk=_no_display, TclError=tk.TclError
)
monkeypatch.setattr(_density, "tk", stub)
assert _density._screen_height() is None
def test_forced_restores_a_pre_existing_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Nesting must not clobber a factor the caller had already forced."""
monkeypatch.setenv(_density.ENV_OVERRIDE, "0.9")
with _density.forced(0.5):
assert _density.density() == 0.5
assert _density.density() == 0.9

View File

@ -0,0 +1,80 @@
"""Tests for the pre-ship fit measurement.
This is the harness every consuming app gates its layout on, so it has to be
right about both answers: a screen that fits must not be reported as
overflowing (which would block a good commit) and a screen that overflows must
not be reported as fitting (which is the failure it exists to catch).
"""
from __future__ import annotations
import os
import tkinter as tk
import pytest
from gatelock._fitcheck import FitResult, measure_fit, report_fit
pytestmark = pytest.mark.skipif(
not os.environ.get("DISPLAY"),
reason="needs a real X display; run under xvfb-run in a headless checkout",
)
_WIDTH = 400
_HEIGHT = 200
def _short(surface: object) -> None:
"""Paint one label -- comfortably shorter than the viewport."""
tk.Label(surface.content, text="short").pack()
def _tall(surface: object) -> None:
"""Paint far more rows than the viewport can hold."""
for index in range(40):
tk.Label(surface.content, text=f"row {index}").pack()
def _placed(surface: object) -> tk.Misc:
"""Paint into a ``place``d frame and return it, as the lock apps do."""
frame = tk.Frame(surface.content)
frame.place(relx=0.5, rely=0.5, anchor="center")
for index in range(40):
tk.Label(frame, text=f"row {index}").pack()
return frame
def test_a_short_screen_fits() -> None:
"""The good case reports zero overflow."""
result = measure_fit("short", _short, width=_WIDTH, height=_HEIGHT)
assert result.fits
assert result.overflow_px == 0
assert "fits" in result.describe()
def test_a_tall_screen_overflows_by_the_real_amount() -> None:
"""The failing case reports how much did not fit, not just that it did."""
result = measure_fit("tall", _tall, width=_WIDTH, height=_HEIGHT)
assert not result.fits
assert result.overflow_px == result.content_px - result.viewport_px
assert "OVERFLOWS" in result.describe()
def test_a_placed_frame_is_measured_through_the_returned_widget() -> None:
"""``place`` does not propagate size, so the builder names what to measure.
Without this the parent measures as empty and every place-centred lock
screen -- which is most of them outside screen-locker -- would be reported
as fitting no matter how tall it grew.
"""
result = measure_fit("placed", _placed, width=_WIDTH, height=_HEIGHT)
assert not result.fits
def test_report_returns_false_when_anything_overflows() -> None:
"""The report is the gate's verdict, so one bad screen fails the batch."""
good = FitResult("good", _WIDTH, _HEIGHT, content_px=100, viewport_px=200)
bad = FitResult("bad", _WIDTH, _HEIGHT, content_px=300, viewport_px=200)
assert report_fit([good])
assert not report_fit([good, bad])
assert report_fit([]) is True

View File

@ -0,0 +1,102 @@
"""Tests for the keyboard affordances every lock surface depends on.
A lock takes a global grab with no other window on screen: a control that
answers only to the mouse, or a field with no way out, is not an
inconvenience there -- it is a user who cannot satisfy the lock at all.
"""
from __future__ import annotations
import os
import tkinter as tk
import pytest
from gatelock._keyboard import bind_activate, bind_cancel, escape_text_tab_trap
pytestmark = pytest.mark.skipif(
not os.environ.get("DISPLAY"),
reason="needs a real X display; run under xvfb-run in a headless checkout",
)
@pytest.fixture
def root() -> tk.Tk:
"""A focused toplevel; synthetic key events go nowhere without focus."""
made = tk.Tk()
made.geometry("300x200+0+0")
made.focus_force()
made.update()
yield made
made.destroy()
def test_enter_and_space_press_a_button(root: tk.Tk) -> None:
"""Tk's X11 class bindings give Button only <space>; Enter does nothing.
Under a grab with no pointer, "the obvious key does nothing" is the
difference between satisfying the lock and being stuck at it.
"""
pressed: list[str] = []
button = tk.Button(root, text="OK", command=lambda: pressed.append("click"))
button.pack()
button.focus_set()
root.update()
bind_activate(button)
button.event_generate("<Return>", when="now")
root.update()
assert pressed == ["click"]
button.event_generate("<KP_Enter>", when="now")
root.update()
assert len(pressed) == 2
def test_escape_runs_the_cancel_action(root: tk.Tk) -> None:
"""The declining option needs a key of its own, not just a click target."""
cancelled: list[str] = []
bind_cancel(root, lambda: cancelled.append("esc"))
root.event_generate("<Escape>", when="now")
root.update()
assert cancelled == ["esc"]
def test_tab_leaves_a_text_box_instead_of_typing_into_it(root: tk.Tk) -> None:
"""Untreated, Tk makes <Tab> insert a tab and refocus the same widget.
On a lock surface that is a dead end: the only advertised exits are
Ctrl+Tab / Ctrl+Shift+Tab, which nothing tells the user about, and the
submit button sits on the far side of the trap.
"""
text = tk.Text(root, height=2)
text.pack()
after = tk.Entry(root)
after.pack()
text.focus_set()
root.update()
escape_text_tab_trap(text)
text.event_generate("<Tab>", when="now")
root.update()
assert root.focus_get() is after
assert text.get("1.0", "end").strip() == ""
def test_shift_tab_goes_back_out_of_a_text_box(root: tk.Tk) -> None:
"""Both directions, including X11's own ISO_Left_Tab keysym."""
before = tk.Entry(root)
before.pack()
text = tk.Text(root, height=2)
text.pack()
text.focus_set()
root.update()
escape_text_tab_trap(text)
text.event_generate("<ISO_Left_Tab>", when="now")
root.update()
assert root.focus_get() is before

View File

@ -0,0 +1,278 @@
"""The viewport moves for the user, and for nobody else.
These run against real Tk: the behaviour under test is Tk's own focus
delivery and canvas scrolling, which a mock cannot reproduce -- the bug this
file exists to prevent was found precisely because the automatic scrolling
looked correct in every unit test and moved the screen anyway.
On 2026-08-03 the screen locker scrolled itself to mid-content and back with
nobody touching the keyboard: the viewport followed ``<FocusIn>``, the apps
call ``focus_set()`` on every repaint, and Tk delivers ``<FocusIn>`` to every
*ancestor* of the focused widget as well, so one programmatic focus produced
three scrolls aimed at containers taller than the screen.
"""
from __future__ import annotations
import os
import tkinter as tk
import pytest
from gatelock._scrollable import ScrollableSurface
from gatelock._window import LockConfig
pytestmark = pytest.mark.skipif(
not os.environ.get("DISPLAY"),
reason="needs a real X display; run under xvfb-run in a headless checkout",
)
_VIEWPORT_H = 200
_TALL = 60
@pytest.fixture
def surface() -> tk.Misc:
"""A 400x200 viewport holding 20 stacked entries -- deliberately taller."""
root = tk.Tk()
root.geometry(f"400x{_VIEWPORT_H}+0+0")
# No window manager under Xvfb, so the toplevel must claim X input focus
# before any synthetic key event is delivered anywhere.
root.focus_force()
root.update_idletasks()
made = ScrollableSurface(root, LockConfig())
made.container.place(relx=0, rely=0, relwidth=1, relheight=1)
for index in range(_TALL):
tk.Entry(made.content, name=f"field{index}").pack()
made.finalize()
root.update()
yield made
root.destroy()
def _offset(made: ScrollableSurface) -> float:
"""The viewport's current scroll fraction."""
return made.canvas.yview()[0]
def test_the_content_really_overflows(surface: ScrollableSurface) -> None:
"""Guard the fixture: none of the rest means anything if it fits."""
assert not surface.fits()
def test_programmatic_focus_does_not_scroll(surface: ScrollableSurface) -> None:
"""The regression: the app focusing a widget must not move the view.
Every repaint focuses something, and gatelock's recovery tick re-asserts
focus once a second. A viewport that follows those moves the screen under
a user who has touched nothing.
"""
target = surface.content.winfo_children()[-1]
before = _offset(surface)
target.focus_set()
surface.canvas.update()
assert _offset(surface) == before
def test_keyboard_focus_does_scroll(surface: ScrollableSurface) -> None:
"""Tab onto an off-screen field still reveals it.
The other half of the contract: clipped widgets stay in the tab ring, so
without this a keyboard user lands on fields they cannot see.
"""
target = surface.content.winfo_children()[-1]
before = _offset(surface)
# Generated on the viewport rather than the field: under Xvfb a synthetic
# key aimed at an Entry is dropped, and "the user pressed a key" is the
# whole signal -- which widget received it does not matter.
surface.canvas.event_generate("<Key-Tab>", when="now")
target.focus_set()
surface.canvas.update()
assert _offset(surface) > before
def test_a_click_elsewhere_does_not_scroll(surface: ScrollableSurface) -> None:
"""A click that opens the next screen is not consent to scroll it.
Clicking "Log Manual Workout" is a user event, but the focus that follows
belongs to the screen the app then builds and focuses itself.
"""
clicked = surface.content.winfo_children()[0]
target = surface.content.winfo_children()[-1]
before = _offset(surface)
clicked.event_generate("<Button-1>", when="now")
surface.canvas.update()
target.focus_set()
surface.canvas.update()
assert _offset(surface) == before
def test_clicking_a_field_scrolls_to_it(surface: ScrollableSurface) -> None:
"""Clicking a field the user can see is consent to reveal it fully."""
target = surface.content.winfo_children()[-1]
before = _offset(surface)
target.event_generate("<Button-1>", when="now")
target.focus_set()
surface.canvas.update()
assert _offset(surface) > before
def test_keys_and_wheel_scroll(surface: ScrollableSurface) -> None:
"""The user's own scroll gestures work regardless of focus."""
surface.canvas.focus_set()
surface.canvas.event_generate("<Next>", when="now")
surface.canvas.update()
assert _offset(surface) > 0.0
def test_content_that_fits_cannot_scroll_at_all() -> None:
"""A screen that fits is pinned: no scrollbar, no reachable offset.
This is the normal case, and it is what makes "no automatic scrolling"
observable rather than merely intended -- there is nowhere for the view
to go.
"""
root = tk.Tk()
try:
root.geometry(f"400x{_VIEWPORT_H}+0+0")
root.focus_force()
root.update_idletasks()
made = ScrollableSurface(root, LockConfig())
made.container.place(relx=0, rely=0, relwidth=1, relheight=1)
tk.Label(made.content, text="short").pack()
made.finalize()
root.update()
assert made.fits()
assert made.canvas.yview() == (0.0, 1.0)
made.canvas.event_generate("<Next>", when="now")
made.canvas.update()
assert made.canvas.yview() == (0.0, 1.0)
finally:
root.destroy()
def test_every_scroll_gesture_moves_the_view(surface: ScrollableSurface) -> None:
"""PageDown/PageUp, arrows, wheel and End/Home each work on their own.
Bound across three widgets and two bindtags, so "one of them works" is not
evidence for the rest -- on a lock with no pointer, a gesture that silently
does nothing is content the user cannot reach.
"""
canvas = surface.canvas
canvas.focus_set()
surface.canvas.update()
canvas.event_generate("<Next>", when="now")
canvas.update()
paged = _offset(surface)
assert paged > 0.0
canvas.event_generate("<Prior>", when="now")
canvas.update()
assert _offset(surface) < paged
canvas.event_generate("<Down>", when="now")
canvas.update()
stepped = _offset(surface)
assert stepped > 0.0
canvas.event_generate("<Up>", when="now")
canvas.update()
assert _offset(surface) < stepped
canvas.event_generate("<Button-5>", when="now")
canvas.update()
wheeled = _offset(surface)
assert wheeled > 0.0
canvas.event_generate("<Button-4>", when="now")
canvas.update()
assert _offset(surface) < wheeled
canvas.event_generate("<End>", when="now")
canvas.update()
assert _offset(surface) > 0.5
canvas.event_generate("<Home>", when="now")
canvas.update()
assert _offset(surface) == 0.0
def test_a_surface_without_a_scrollbar_still_scrolls() -> None:
"""``show_scrollbar=False`` removes the affordance, not the mechanism."""
root = tk.Tk()
try:
root.geometry(f"400x{_VIEWPORT_H}+0+0")
root.focus_force()
root.update_idletasks()
made = ScrollableSurface(root, LockConfig(), show_scrollbar=False)
made.container.place(relx=0, rely=0, relwidth=1, relheight=1)
for index in range(_TALL):
tk.Entry(made.content, name=f"field{index}").pack()
made.finalize()
root.update()
assert not made.fits()
made.canvas.focus_set()
made.canvas.event_generate("<Next>", when="now")
made.canvas.update()
assert made.canvas.yview()[0] > 0.0
finally:
root.destroy()
def test_scroll_into_view_ignores_an_unmapped_widget(
surface: ScrollableSurface,
) -> None:
"""A widget Tk has not laid out yet has no position to scroll to."""
ghost = tk.Entry(surface.content) # never packed: zero height
before = _offset(surface)
surface.scroll_into_view(ghost)
assert _offset(surface) == before
def test_scroll_into_view_leaves_an_already_visible_widget_alone(
surface: ScrollableSurface,
) -> None:
"""Nothing moves when the widget is already fully on screen."""
before = _offset(surface)
surface.scroll_into_view(surface.content.winfo_children()[0])
assert _offset(surface) == before
def test_scroll_into_view_reaches_backwards(surface: ScrollableSurface) -> None:
"""Revealing a widget above the viewport scrolls up, not down."""
surface.canvas.yview_moveto(1.0)
surface.canvas.update()
surface.scroll_into_view(surface.content.winfo_children()[0])
assert _offset(surface) == 0.0
def test_paging_degrades_to_the_canvas_without_a_toplevel(
surface: ScrollableSurface, caplog: pytest.LogCaptureFixture
) -> None:
"""A widget tree with no real toplevel still scrolls by keyboard.
Reachable under a mocked Tk root. Binding nothing at all would leave the
overflow unreachable, so it degrades to canvas-local bindings and says so
-- a silent degradation here is content the user cannot get to.
"""
message = "no toplevel"
def _no_toplevel() -> None:
raise tk.TclError(message)
surface.canvas.winfo_toplevel = _no_toplevel
with caplog.at_level("WARNING"):
surface._bind_scroll_keys()
surface._bind_user_input()
assert "paging keys bound to the canvas only" in caplog.text
assert "user-input tracking is bound to the viewport only" in caplog.text
surface.canvas.focus_set()
surface.canvas.event_generate("<Next>", when="now")
surface.canvas.update()
assert _offset(surface) > 0.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "gatelock"
version = "0.3.0"
version = "0.4.0"
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
requires-python = ">=3.10"
dependencies = [] # pure stdlib — tkinter is bundled with Python