mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
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:
parent
940bf19d54
commit
e651b1af17
47
gatelock/docs/scrollable-viewport.md
Normal file
47
gatelock/docs/scrollable-viewport.md
Normal 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).
|
||||
@ -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.
|
||||
|
||||
183
gatelock/gatelock/_scrollfit.py
Normal file
183
gatelock/gatelock/_scrollfit.py
Normal 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,
|
||||
)
|
||||
180
gatelock/gatelock/_scrollkeys.py
Normal file
180
gatelock/gatelock/_scrollkeys.py
Normal 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)
|
||||
106
gatelock/gatelock/_userintent.py
Normal file
106
gatelock/gatelock/_userintent.py
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user