gatelock: bind the wheel per descendant in ScrollableSurface

Wheel-over-text did not scroll. Tk dispatches through *bindtags*, not the
parent chain -- a child's tags are (itself, its class, the toplevel, "all") --
so binding <Button-4>/<Button-5> on `content` was never consulted for an event
over a Label sitting on top of it. Content that fills its viewport covers that
frame completely, which is every screen this class exists for, so the mouse
wheel was dead exactly where a user points it.

Found while retiring leetcode-guard's hand-rolled _scrollable, which got this
right by accident: it used bind_all, which works but installs the handler on
every widget in the application, including other windows' scroll regions.
Binding per descendant inside the existing _track_focus walk keeps it scoped,
and reuses that walk's WeakSet so a repaint cannot stack duplicate handlers.

Verified both directions under xvfb (Button-5 scrolls down, Button-4 back up)
from an event generated on a Label, not on the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 14:12:30 +02:00
parent c963cd88a8
commit d82107ab9a
2 changed files with 47 additions and 1 deletions

View File

@ -344,16 +344,28 @@ class ScrollableSurface:
self.canvas.yview_moveto(1.0)
def _track_focus(self, parent: tk.Misc) -> None:
"""Bind <FocusIn> on every descendant, each exactly once.
"""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)

View File

@ -327,3 +327,37 @@ def test_scroll_into_view_holds_still_for_a_widget_in_the_middle(
surface.scroll_into_view(middle)
assert _offset(surface) == before
def test_the_wheel_scrolls_from_over_the_content(surface: ScrollableSurface) -> None:
"""Wheel-over-text scrolls, which is the only way anyone uses a mouse here.
Tk dispatches through *bindtags*, not the parent chain: a child's tags are
``(itself, its class, the toplevel, "all")``, so a binding on ``content``
is never consulted for an event over a Label sitting on top of it. Content
that fills the viewport covers that frame entirely, so binding the wheel
only on the canvas and the content frame makes wheel scrolling dead
everywhere the user would actually point.
"""
child = surface.content.winfo_children()[10]
before = surface.canvas.yview()[0]
child.event_generate("<Button-5>")
surface.canvas.update()
assert surface.canvas.yview()[0] > before
def test_the_wheel_scrolls_back_up_from_over_the_content(
surface: ScrollableSurface,
) -> None:
"""The up notch is bound on descendants too, not just the down one."""
child = surface.content.winfo_children()[10]
child.event_generate("<Button-5>")
surface.canvas.update()
scrolled = surface.canvas.yview()[0]
child.event_generate("<Button-4>")
surface.canvas.update()
assert surface.canvas.yview()[0] < scrolled