utils/gatelock/gatelock/_scrollkeys.py
Krzysztof kuhy Rudnicki 305e59b3e8
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Grade crdt-sync and gatelock against 3.14 too, not just run on them
Both libraries had already moved `requires-python` to 3.14, but ruff, mypy and
pylint were still grading against 3.10 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), a dead `timezone` import left behind by it, and ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That last one makes the source 3.14-only at the syntax level, which is what
`requires-python = ">=3.14"` already promised; kuhy chose it deliberately over
holding ruff a release back (2026-08-29).

These two are libraries the apps install, so the floor matters more here than
anywhere else in the fleet -- every consumer already pins >=3.14.

Per project: ruff, ruff-format, mypy --strict and pylint clean, crdt-sync 432
tests and gatelock 389 tests, both at 100% line and branch coverage.

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

181 lines
6.3 KiB
Python

"""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)