utils/gatelock/gatelock/_userintent.py
Krzysztof kuhy Rudnicki 6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
The three Python packages pinned ruff twice at different versions:
requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the
older one ever ran, so `select = ["ALL"]` was enforced two minor versions
behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x
reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and
no other rule.

Resolved by adding the notice rather than ignoring the rule, so the gate
keeps its teeth.

Two gatelock modules sat at exactly the 250-line cap, so a one-line header
tipped them to 251. Both are split along a real seam rather than shaved:

  _widget_fills.py    colour arithmetic over LockConfig, no tkinter import
  _surfaces_focus.py  selects among existing surfaces, never mutates them

The focus split keeps _surfaces.py's documented containment (it stays the
only module that creates, moves or destroys windows), and the static guard
in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is
covered with no list to extend. widgets.py re-exports the fills helpers,
which keeps gatelock.widgets the single public surface its tests import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:11:24 +02:00

108 lines
4.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""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