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
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:
- 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. - A Canvas viewport is not keyboard-scrollable.
tk.Canvashas no class-level key bindings at all, so::tk::FocusOKrejects 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. - Focus walks into invisible widgets. Canvas clipping does not unmap a
child, so every scrolled-out-of-view field stays
winfo viewableand 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_viewfixes 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).