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
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 14:28:21 +02:00
parent 940bf19d54
commit e651b1af17
6 changed files with 559 additions and 293 deletions

View File

@ -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).

View File

@ -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 <MouseWheel>.
_WHEEL_UP = "<Button-4>"
_WHEEL_DOWN = "<Button-5>"
# 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("<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()
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("<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.
PageUp/PageDown/Home/End go on the *toplevel* where possible: in a
fullscreen lock those keys have no competing meaning, and binding there
means they work wherever focus currently sits rather than only when the
canvas holds it. Arrows bind to the canvas only -- bound globally they
would fight Listbox, Spinbox and Entry navigation.
"""
paging = (
("<Prior>", self._page_up),
("<Next>", self._page_down),
("<Home>", self._scroll_home),
("<End>", self._scroll_end),
)
target: tk.Misc = self.canvas
try:
target = self.canvas.winfo_toplevel()
except (tk.TclError, KeyError, AttributeError):
# Reachable when the widget tree's root is not a real toplevel --
# notably under tests that mock the Tk root. Degrade to binding on
# the canvas rather than failing to build the viewport at all:
# paging then works while the viewport has focus, which is strictly
# better than no keyboard scrolling. Logged rather than swallowed
# so a production occurrence is visible.
_logger.warning(
"no toplevel for scroll viewport; paging keys bound to the "
"canvas only, so they work only while it holds focus"
)
for seq, handler in paging:
target.bind(seq, handler, add="+")
self.canvas.bind("<Up>", self._line_up, add="+")
self.canvas.bind("<Down>", self._line_down, add="+")
for seq, handler in (
(_WHEEL_UP, self._line_up),
(_WHEEL_DOWN, self._line_down),
):
self.canvas.bind(seq, handler, add="+")
self.content.bind(seq, handler, add="+")
def _page_up(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(-1, "pages")
def _page_down(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(1, "pages")
def _line_up(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(-_ARROW_UNITS, "units")
def _line_down(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(_ARROW_UNITS, "units")
def _scroll_home(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_moveto(0.0)
def _scroll_end(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_moveto(1.0)
def _track_focus(self, parent: tk.Misc) -> None:
"""Bind <FocusIn> and the wheel on every descendant, each exactly once.
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.
The wheel has to be bound per descendant because Tk dispatches through
*bindtags*, not the parent chain: a child's tags are ``(itself, its
class, the toplevel, "all")``, so an event over a Label never reaches
a binding on ``content``. Content that fills its viewport covers that
frame completely, which means wheel-over-text -- the only way anyone
actually scrolls with a mouse -- would do nothing. The donor this
replaced reached for ``bind_all`` instead, which works but installs
the handler on *every* widget in the application, including other
windows' scroll regions.
"""
for child in parent.winfo_children():
if child not in self._focus_bound:
child.bind("<FocusIn>", self._on_descendant_focus, add="+")
child.bind(_WHEEL_UP, self._line_up, add="+")
child.bind(_WHEEL_DOWN, self._line_down, add="+")
self._focus_bound.add(child)
self._track_focus(child)
apply_fit_state(self._fit)
def _on_descendant_focus(self, event: tk.Event) -> None:
"""Reveal a widget the *user* just tabbed or clicked onto.
@ -389,7 +177,7 @@ class ScrollableSurface:
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):
if widget.winfo_children() or not self._intent.focus_is_user_driven(widget):
return
self.scroll_into_view(widget)
@ -400,7 +188,7 @@ class ScrollableSurface:
out yet (a zero height means Tk has not placed it).
"""
self.canvas.update_idletasks()
span = self._content_span()
span = content_span(self.canvas)
view_h = self.canvas.winfo_height()
if span <= view_h or widget.winfo_height() <= 0:
return
@ -416,11 +204,6 @@ class ScrollableSurface:
return
self.canvas.yview_moveto(target / span)
def _content_span(self) -> int:
"""Return the scrollable content height in px."""
box = self.canvas.bbox("all")
return (box[3] - box[1]) if box else 0
def finalize(self) -> None:
"""Settle geometry for freshly built content.
@ -439,48 +222,13 @@ class ScrollableSurface:
each consuming app asserts the same property before it ships.
"""
self.content.update_idletasks()
self._track_focus(self.content)
self._request_content_size()
track_focus(
self.content, self.canvas, self._on_descendant_focus, self._focus_bound
)
request_content_size(self._fit)
self._apply_fit_state()
self.canvas.yview_moveto(0.0)
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.
A ``Canvas`` holds its content in a canvas *item*, not as a
geometry-managed child, so it never propagates the content's requested
size into its own. A toplevel told to size itself (``geometry("")``)
therefore has nothing to grow to and stays at its minimum -- which is
how a 862px-wide button row ended up sheared inside a 700px window even
after a viewport was added.
Requesting the content's size fixes that where there is room, and the
clamp keeps a window from growing off a small display; anything still
too big scrolls instead of clipping. On a lock surface the container is
``place``d at ``relwidth/relheight=1``, which overrides this request --
correctly, since there the surface size is the screen.
"""
wanted_w = self.content.winfo_reqwidth()
wanted_h = self.content.winfo_reqheight()
bar = self._scrollbar.winfo_reqwidth() if self._scrollbar else 0
self.canvas.configure(
width=max(1, min(wanted_w, self.canvas.winfo_screenwidth() - bar)),
height=max(1, min(wanted_h, self.canvas.winfo_screenheight())),
)
report_overflow(self._fit)
def fits(self) -> bool:
"""Whether the content currently fits without scrolling.

View File

@ -0,0 +1,183 @@
"""Measuring a scroll viewport's content and switching its layout to match.
Split out of :mod:`gatelock._scrollable`, which keeps the widget wiring, focus
tracking and the public surface. Everything here answers one question -- does
the content fit? -- and applies the consequences: pinning or releasing the
scroll region, centring, showing or hiding the scrollbar, and asking for a
canvas big enough to avoid the problem in the first place.
Fitting is the normal, intended state. Scrolling exists only as the fallback
for content that genuinely does not fit, and that is a layout defect worth
reporting, not a mode to design for.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import tkinter as tk
_logger = logging.getLogger(__name__)
# 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
@dataclass
class FitTarget:
"""The viewport being measured, and the layout knobs that depend on it.
Attributes:
canvas: The scroll viewport.
content: The frame held inside it as a canvas item.
content_id: The canvas item id of ``content``, whose coords are moved
to centre the content.
scrollbar: The scrollbar to show and hide, or None when the surface
was built without one.
center_when_fits: Whether content that fits is centred vertically
rather than pinned to the top.
overflowing: Last known overflow state, or None before the first
measurement. Kept so the scrollbar is only re-packed on a genuine
change rather than on every resize event.
"""
canvas: tk.Canvas
content: tk.Frame
content_id: int
scrollbar: tk.Scrollbar | None
center_when_fits: bool
overflowing: bool | None = field(default=None)
def border_px(canvas: tk.Canvas) -> int:
"""Width of the canvas's own edge: focus ring plus border."""
return int(canvas.cget("highlightthickness")) + int(canvas.cget("borderwidth"))
def content_span(canvas: tk.Canvas) -> int:
"""Return the scrollable content height in px."""
box = canvas.bbox("all")
return (box[3] - box[1]) if box else 0
def show_scrollbar(target: FitTarget, *, visible: bool) -> None:
"""Show the scrollbar only while there is something to scroll.
Args:
target: The viewport whose scrollbar is being toggled.
visible: Whether the scrollbar should be packed.
"""
if target.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.
target.scrollbar.pack(side="right", fill="y")
target.canvas.pack_forget()
target.canvas.pack(side="left", fill="both", expand=True)
else:
target.scrollbar.pack_forget()
def apply_fit_state(target: FitTarget) -> 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:`gatelock._scrollable.ScrollableSurface.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
``finalize`` then scrolled to that top edge -- putting the content
back flush against the screen's top edge on every repaint.
Args:
target: The viewport to measure and re-lay out.
"""
view_h = target.canvas.winfo_height()
view_w = target.canvas.winfo_width()
wanted_h = target.content.winfo_reqheight()
overflowing = wanted_h > view_h
if overflowing:
target.canvas.coords(target.content_id, view_w / 2, 0)
target.canvas.configure(scrollregion=target.canvas.bbox("all"))
else:
top = max(0, (view_h - wanted_h) // 2) if target.center_when_fits else 0
target.canvas.coords(target.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 * border_px(target.canvas)
target.canvas.configure(
scrollregion=(0, 0, max(1, view_w - inset), max(1, view_h - inset))
)
if overflowing != target.overflowing:
target.overflowing = overflowing
show_scrollbar(target, visible=overflowing)
def request_content_size(target: FitTarget) -> None:
"""Ask for a canvas big enough for the content, clamped to the screen.
A ``Canvas`` holds its content in a canvas *item*, not as a
geometry-managed child, so it never propagates the content's requested
size into its own. A toplevel told to size itself (``geometry("")``)
therefore has nothing to grow to and stays at its minimum -- which is
how a 862px-wide button row ended up sheared inside a 700px window even
after a viewport was added.
Requesting the content's size fixes that where there is room, and the
clamp keeps a window from growing off a small display; anything still
too big scrolls instead of clipping. On a lock surface the container is
``place``d at ``relwidth/relheight=1``, which overrides this request --
correctly, since there the surface size is the screen.
Args:
target: The viewport to resize.
"""
wanted_w = target.content.winfo_reqwidth()
wanted_h = target.content.winfo_reqheight()
bar = target.scrollbar.winfo_reqwidth() if target.scrollbar else 0
target.canvas.configure(
width=max(1, min(wanted_w, target.canvas.winfo_screenwidth() - bar)),
height=max(1, min(wanted_h, target.canvas.winfo_screenheight())),
)
def report_overflow(target: FitTarget) -> None:
"""Log a warning when the content does not fit its viewport.
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.
Args:
target: The viewport to measure.
"""
wanted_h = target.content.winfo_reqheight()
view_h = target.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,
)

View File

@ -0,0 +1,180 @@
"""Keyboard and wheel scrolling for a :class:`~gatelock._scrollable.ScrollableSurface`.
Split out of :mod:`gatelock._scrollable`, which keeps the viewport's geometry
and fit logic. Everything here is about *driving* an existing viewport: the
key and wheel bindings, and the six one-line scroll commands they call.
The commands take the canvas directly rather than the surface, so this module
knows nothing about fit state, focus tracking or scrollbars.
"""
from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable, MutableSet
_logger = logging.getLogger(__name__)
# Tk reports wheel notches as Button-4/5 on X11 rather than <MouseWheel>.
_WHEEL_UP = "<Button-4>"
_WHEEL_DOWN = "<Button-5>"
# Lines moved per arrow keypress / wheel notch. Three matches the scroll step
# of a typical toolkit list and keeps a long form from crawling.
_ARROW_UNITS = 3
def page_up(canvas: tk.Canvas) -> None:
"""Scroll up one viewport height."""
canvas.yview_scroll(-1, "pages")
def page_down(canvas: tk.Canvas) -> None:
"""Scroll down one viewport height."""
canvas.yview_scroll(1, "pages")
def line_up(canvas: tk.Canvas) -> None:
"""Scroll up one arrow/wheel step."""
canvas.yview_scroll(-_ARROW_UNITS, "units")
def line_down(canvas: tk.Canvas) -> None:
"""Scroll down one arrow/wheel step."""
canvas.yview_scroll(_ARROW_UNITS, "units")
def scroll_home(canvas: tk.Canvas) -> None:
"""Jump to the top of the content."""
canvas.yview_moveto(0.0)
def scroll_end(canvas: tk.Canvas) -> None:
"""Jump to the bottom of the content."""
canvas.yview_moveto(1.0)
def bind_scroll_keys(canvas: tk.Canvas, content: tk.Misc) -> None:
"""Bind keyboard and wheel scrolling.
PageUp/PageDown/Home/End go on the *toplevel* where possible: in a
fullscreen lock those keys have no competing meaning, and binding there
means they work wherever focus currently sits rather than only when the
canvas holds it. Arrows bind to the canvas only -- bound globally they
would fight Listbox, Spinbox and Entry navigation.
Args:
canvas: The scroll viewport.
content: The frame scrolled inside it, bound for the wheel so a notch
over the content scrolls rather than falling through.
"""
def on_page_up(_event: tk.Event | None = None) -> None:
page_up(canvas)
def on_page_down(_event: tk.Event | None = None) -> None:
page_down(canvas)
def on_home(_event: tk.Event | None = None) -> None:
scroll_home(canvas)
def on_end(_event: tk.Event | None = None) -> None:
scroll_end(canvas)
def on_line_up(_event: tk.Event | None = None) -> None:
line_up(canvas)
def on_line_down(_event: tk.Event | None = None) -> None:
line_down(canvas)
paging = (
("<Prior>", on_page_up),
("<Next>", on_page_down),
("<Home>", on_home),
("<End>", on_end),
)
target: tk.Misc = canvas
try:
target = canvas.winfo_toplevel()
except (tk.TclError, KeyError, AttributeError):
# Reachable when the widget tree's root is not a real toplevel --
# notably under tests that mock the Tk root. Degrade to binding on
# the canvas rather than failing to build the viewport at all:
# paging then works while the viewport has focus, which is strictly
# better than no keyboard scrolling. Logged rather than swallowed
# so a production occurrence is visible.
_logger.warning(
"no toplevel for scroll viewport; paging keys bound to the "
"canvas only, so they work only while it holds focus"
)
for seq, handler in paging:
target.bind(seq, handler, add="+")
canvas.bind("<Up>", on_line_up, add="+")
canvas.bind("<Down>", on_line_down, add="+")
for seq, handler in ((_WHEEL_UP, on_line_up), (_WHEEL_DOWN, on_line_down)):
canvas.bind(seq, handler, add="+")
content.bind(seq, handler, add="+")
def bind_wheel(widget: tk.Misc, canvas: tk.Canvas) -> None:
"""Bind wheel scrolling on one descendant of a viewport.
A widget that handles its own events (a Listbox, an Entry) otherwise
swallows the notch, so the wheel appears dead over exactly the parts of a
form the user is most likely to be pointing at.
Args:
widget: The descendant to bind on.
canvas: The viewport the notch should scroll.
"""
def on_line_up(_event: tk.Event | None = None) -> None:
line_up(canvas)
def on_line_down(_event: tk.Event | None = None) -> None:
line_down(canvas)
widget.bind(_WHEEL_UP, on_line_up, add="+")
widget.bind(_WHEEL_DOWN, on_line_down, add="+")
def track_focus(
parent: tk.Misc,
canvas: tk.Canvas,
on_focus: Callable[[tk.Event], None],
bound: MutableSet[tk.Misc],
) -> None:
"""Bind <FocusIn> and the wheel on every descendant, each exactly once.
A repaint destroys and rebuilds the widgets, so this re-runs per screen;
``bound`` is what keeps a second ``finalize`` on the *same* widgets from
stacking a second handler on each of them.
The wheel has to be bound per descendant because Tk dispatches through
*bindtags*, not the parent chain: a child's tags are ``(itself, its
class, the toplevel, "all")``, so an event over a Label never reaches
a binding on ``content``. Content that fills its viewport covers that
frame completely, which means wheel-over-text -- the only way anyone
actually scrolls with a mouse -- would do nothing. The donor this
replaced reached for ``bind_all`` instead, which works but installs
the handler on *every* widget in the application, including other
windows' scroll regions.
Args:
parent: Widget whose descendants are bound, recursively.
canvas: The viewport the wheel should scroll.
on_focus: Handler bound to each descendant's ``<FocusIn>``.
bound: Widgets already bound; a WeakSet, so entries drop as widgets
are destroyed. Mutated in place.
"""
for child in parent.winfo_children():
if child not in bound:
child.bind("<FocusIn>", on_focus, add="+")
bind_wheel(child, canvas)
bound.add(child)
track_focus(child, canvas, on_focus, bound)

View File

@ -0,0 +1,106 @@
"""Deciding whether a focus change was driven by the user or by the app.
Split out of :mod:`gatelock._scrollable`, which owns the viewport itself.
Focus-following scrolling has to answer one question -- "did the user just
do something?" -- and getting it wrong is what moves the screen under
someone who has touched nothing.
The tracker owns only the two facts needed to answer it, so the viewport
does not have to carry input bookkeeping alongside its geometry.
"""
from __future__ import annotations
import logging
import time
import tkinter as tk
_logger = logging.getLogger(__name__)
# How long after a keypress or click a focus change still counts as the
# user's doing. Long enough to cover the focus that follows an event,
# short enough not to adopt the next repaint.
_USER_INPUT_WINDOW_S = 0.25
class UserIntentTracker:
"""Records recent user input so focus moves can be attributed to it."""
def __init__(self) -> None:
"""Start with no recorded input, so nothing counts as user-driven."""
self._last_user_input = 0.0
self._clicked: tk.Misc | None = None
def bind(self, targets: list[tk.Misc]) -> 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.
Args:
targets: Widgets to bind on. Bindings are added (``add="+"``) so
they never displace the app's own handlers.
"""
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.
Args:
focused: The widget that just took focus.
Returns:
True when the focus move should be followed by the viewport.
"""
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 viewport_targets(canvas: tk.Misc, content: tk.Misc) -> list[tk.Misc]:
"""The widgets a viewport should watch for user input.
The toplevel is included when it can be reached, so input anywhere in the
lock counts; without it, only events on the viewport itself do.
Args:
canvas: The scroll viewport.
content: The frame scrolled inside it.
Returns:
The bind targets, most-specific first.
"""
targets: list[tk.Misc] = [canvas, content]
try:
targets.append(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"
)
return targets

View File

@ -20,6 +20,8 @@ import tkinter as tk
import pytest
from gatelock._scrollable import ScrollableSurface
from gatelock._scrollkeys import bind_scroll_keys
from gatelock._userintent import viewport_targets
from gatelock._window import LockConfig
pytestmark = pytest.mark.skipif(
@ -275,8 +277,8 @@ def test_paging_degrades_to_the_canvas_without_a_toplevel(
surface.canvas.winfo_toplevel = _no_toplevel
with caplog.at_level("WARNING"):
surface._bind_scroll_keys()
surface._bind_user_input()
bind_scroll_keys(surface.canvas, surface.content)
surface._intent.bind(viewport_targets(surface.canvas, surface.content))
assert "paging keys bound to the canvas only" in caplog.text
assert "user-input tracking is bound to the viewport only" in caplog.text