diff --git a/gatelock/docs/scrollable-viewport.md b/gatelock/docs/scrollable-viewport.md new file mode 100644 index 0000000..b9e25f6 --- /dev/null +++ b/gatelock/docs/scrollable-viewport.md @@ -0,0 +1,47 @@ +# 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). diff --git a/gatelock/gatelock/_scrollable.py b/gatelock/gatelock/_scrollable.py index 387ffc1..68f9998 100644 --- a/gatelock/gatelock/_scrollable.py +++ b/gatelock/gatelock/_scrollable.py @@ -1,74 +1,41 @@ """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. +Build it inside a lock surface, pack widgets into :attr:`content`, then call +:meth:`ScrollableSurface.finalize`. Content that fits cannot scroll at all; +content that overflows scrolls by key, click or wheel -- never on its own. -Three defects this exists to prevent, all of them Tk defaults rather than -oversights in any one app: +Scrolling is the fallback, not the design: a lock surface is supposed to fit +its screen, and :meth:`ScrollableSurface.finalize` logs an overflow at +``warning`` so it gets fixed. See ``docs/scrollable-viewport.md`` for the +three Tk defaults this exists to prevent and the failures behind each. -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). +Collaborators: :mod:`gatelock._scrollfit` measures and lays out, +:mod:`gatelock._scrollkeys` binds the keys and wheel, and +:mod:`gatelock._userintent` decides whether a focus move was the user's. """ from __future__ import annotations -import logging -import time import tkinter as tk from typing import TYPE_CHECKING import weakref +from gatelock._scrollfit import ( + FitTarget, + apply_fit_state, + content_span, + report_overflow, + request_content_size, +) +from gatelock._scrollkeys import bind_scroll_keys, track_focus +from gatelock._userintent import UserIntentTracker, viewport_targets + if TYPE_CHECKING: from gatelock._window import LockConfig -_logger = logging.getLogger(__name__) - -# Tk reports wheel notches as Button-4/5 on X11 rather than . -_WHEEL_UP = "" -_WHEEL_DOWN = "" -# Scroll step in "units" for arrow keys; pages use the viewport height. -_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: @@ -159,11 +126,16 @@ class ScrollableSurface: # binding on `content` alone would miss every Tab. self.content.bind("", 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() + self._intent = UserIntentTracker() + self._fit = FitTarget( + canvas=self.canvas, + content=self.content, + content_id=self._content_id, + scrollbar=self._scrollbar, + center_when_fits=self._center_when_fits, + ) + bind_scroll_keys(self.canvas, self.content) + self._intent.bind(viewport_targets(self.canvas, self.content)) def _on_content_resize(self, _event: tk.Event) -> None: """Keep the scroll region matched to the content's real size.""" @@ -181,193 +153,9 @@ class ScrollableSurface: 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. + Delegates to :func:`gatelock._scrollfit.apply_fit_state`. """ - 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("", self._note_key, add="+") - target.bind("