utils/gatelock/docs/scrollable-viewport.md
Krzysztof kuhy Rudnicki e651b1af17 gatelock: split _scrollable.py 495 -> 243 lines across four modules
Cut along concerns rather than line counts, so each file answers one question:

  _scrollfit.py   (180) — does the content fit, and what layout follows?
  _scrollkeys.py  (176) — key/wheel bindings and the six scroll commands
  _userintent.py  (106) — was this focus move the user's doing, or the app's?
  _scrollable.py  (243) — the widget wiring and the public surface

_scrollfit takes an explicit FitTarget bundle rather than the surface, matching
the ArmingCollaborators pattern; UserIntentTracker owns the two fields that
answer "did the user just act?", which is the guard that keeps the viewport
from scrolling itself under someone who touched nothing.

The 45-line module docstring — the three Tk defaults this viewport exists to
prevent, and the failure behind each — moved to docs/scrollable-viewport.md
rather than being trimmed. It is load-bearing rationale, so it is referenced,
not deleted.

389 passed, 100% branch coverage — unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:28:21 +02:00

2.7 KiB

The scrollable lock viewport

Design rationale for gatelock/_scrollable.py and its collaborators (_scrollfit.py, _scrollkeys.py, _userintent.py). Kept here rather than in the module docstring so the module itself stays readable in one piece; the behaviour it describes is load-bearing, not historical.

A keyboard-scrollable, focus-following viewport for lock-surface content.

Lock surfaces cannot solve "content taller than the screen" the obvious way. A scrollable dialog would be a second Toplevel, and on an override-redirect lock surface a second toplevel steals the Tk grab, which the recovery tick then kills a second later -- the 2026-07-26 failure where a frozen sport selector logged a walk as table tennis. So the viewport has to live inside the surface's own widget tree, which is what this provides.

Three defects this exists to prevent, all of them Tk defaults rather than oversights in any one app:

  1. Centered content clips symmetrically. A place-centered frame takes its requested size, so when it is too tall the parent shears equal amounts off the top and the bottom -- losing the header and the submit button together, with no scrollbar and no hint anything is missing. This top-anchors content inside a scroll region instead.
  2. A Canvas viewport is not keyboard-scrollable. tk.Canvas has no class-level key bindings at all, so ::tk::FocusOK rejects it as a focus stop and the only way to move the view is dragging the scrollbar thumb with a pointer. This makes the canvas focusable and binds PageUp/PageDown, Home/End and arrows.
  3. Focus walks into invisible widgets. Canvas clipping does not unmap a 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.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).