Skip to content

Commit a9152fb

Browse files
miss-islingtonserhiy-storchakaclaude
authored
[3.14] gh-154511: Consolidate IDLE's mouse wheel handling in util (GH-156974) (GH-157022)
Move wheel_event there from idlelib.tree and add x11_buttons(widget) and bind_wheel(widget, func), used by the editor, the tree and test_sidebar. wheel_event now reads the direction from the event, not the platform. Move its test from test_tree, and test the fix_ functions of util too. (cherry picked from commit 57594aa) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b8ce418 commit a9152fb

7 files changed

Lines changed: 220 additions & 74 deletions

File tree

Lib/idlelib/editor.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@
2626
from idlelib import query
2727
from idlelib import replace
2828
from idlelib import search
29-
from idlelib.tree import wheel_event
30-
from idlelib.util import py_extensions
29+
from idlelib.util import bind_wheel, py_extensions, wheel_event
3130
from idlelib import window
3231
from idlelib.help import _get_dochome
3332

@@ -115,10 +114,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
115114
# Elsewhere, use right-click for popup menus.
116115
text.bind("<3>",self.right_menu_event)
117116

118-
text.bind('<MouseWheel>', wheel_event)
119-
if text._windowingsystem == 'x11':
120-
text.bind('<Button-4>', wheel_event)
121-
text.bind('<Button-5>', wheel_event)
117+
bind_wheel(text, wheel_event)
122118
text.bind('<Configure>', self.handle_winconfig)
123119
text.bind("<<cut>>", self.cut)
124120
text.bind("<<copy>>", self.copy)

Lib/idlelib/idle_test/test_sidebar.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from idlelib.percolator import Percolator
1515
import idlelib.pyshell
1616
from idlelib.pyshell import PyShell, PyShellFileList
17-
from idlelib.util import fix_scaling, fix_word_breaks, fix_x11_paste
17+
from idlelib.util import (fix_scaling, fix_word_breaks, fix_x11_paste,
18+
x11_buttons)
1819
import idlelib.sidebar
1920
from idlelib.sidebar import get_end_linenumber, get_lineno
2021

@@ -689,23 +690,21 @@ def test_mousewheel(self):
689690
last_lineno = get_end_linenumber(text)
690691
self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
691692

692-
# Simulate a mouse wheel notch. Tk 8.7 replaced the X11
693-
# <Button-4>/<Button-5> wheel events with <MouseWheel> (whose delta is
694-
# platform-dependent); older Tk on X11 still uses the button events.
695-
x11_buttons = (sidebar.canvas._windowingsystem == 'x11'
696-
and tk.TkVersion < 8.7)
693+
# Simulate a mouse wheel notch with the events that Tk sends for
694+
# one; the delta of a <MouseWheel> event is platform-dependent.
695+
buttons = x11_buttons(sidebar.canvas)
697696
delta = 1 if sidebar.canvas._windowingsystem == 'aqua' else 120
698697

699698
# Scroll up.
700-
if x11_buttons:
699+
if buttons:
701700
sidebar.canvas.event_generate('<Button-4>', x=0, y=0)
702701
else:
703702
sidebar.canvas.event_generate('<MouseWheel>', x=0, y=0, delta=delta)
704703
yield
705704
self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
706705

707706
# Scroll back down.
708-
if x11_buttons:
707+
if buttons:
709708
sidebar.canvas.event_generate('<Button-5>', x=0, y=0)
710709
else:
711710
sidebar.canvas.event_generate('<MouseWheel>', x=0, y=0, delta=-delta)

Lib/idlelib/idle_test/test_tree.py

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import unittest
55
from test.support import requires
66
requires('gui')
7-
from tkinter import Tk, EventType, SCROLL
7+
from tkinter import Tk
88

99

1010
class TreeTest(unittest.TestCase):
@@ -29,32 +29,5 @@ def test_init(self):
2929
node.expand()
3030

3131

32-
class TestScrollEvent(unittest.TestCase):
33-
34-
def test_wheel_event(self):
35-
# Fake widget class containing `yview` only.
36-
class _Widget:
37-
def __init__(widget, *expected):
38-
widget.expected = expected
39-
def yview(widget, *args):
40-
self.assertTupleEqual(widget.expected, args)
41-
# Fake event class
42-
class _Event:
43-
pass
44-
# (type, delta, num, amount)
45-
tests = ((EventType.MouseWheel, 120, -1, -5),
46-
(EventType.MouseWheel, -120, -1, 5),
47-
(EventType.ButtonPress, -1, 4, -5),
48-
(EventType.ButtonPress, -1, 5, 5))
49-
50-
event = _Event()
51-
for ty, delta, num, amount in tests:
52-
event.type = ty
53-
event.delta = delta
54-
event.num = num
55-
res = tree.wheel_event(event, _Widget(SCROLL, amount, "units"))
56-
self.assertEqual(res, "break")
57-
58-
5932
if __name__ == '__main__':
6033
unittest.main(verbosity=2)

Lib/idlelib/idle_test/test_util.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,167 @@
11
"""Test util, coverage 100%"""
22

3+
import sys
34
import unittest
5+
from unittest import mock
6+
from test.support import requires
7+
from test.support.isolation import runInSubprocess
8+
import tkinter
9+
from tkinter import EventType
410
from idlelib import util
11+
from idlelib.idle_test.mock_tk import Event
512

613

714
class UtilTest(unittest.TestCase):
15+
816
def test_extensions(self):
917
for extension in {'.pyi', '.py', '.pyw'}:
1018
self.assertIn(extension, util.py_extensions)
1119

20+
@unittest.skipUnless(sys.platform == 'win32', 'Windows only')
21+
@runInSubprocess()
22+
def test_fix_win_hidpi(self):
23+
# Awareness is process-wide and cannot be undone.
24+
import ctypes
25+
PROCESS_DPI_UNAWARE = 0
26+
util.fix_win_hidpi()
27+
awareness = ctypes.c_int()
28+
ctypes.OleDLL('shcore').GetProcessDpiAwareness(
29+
None, ctypes.byref(awareness))
30+
self.assertNotEqual(awareness.value, PROCESS_DPI_UNAWARE)
31+
32+
33+
class WheelTest(unittest.TestCase):
34+
"Test the wheel functions with a widget on this display."
35+
36+
@classmethod
37+
def setUpClass(cls):
38+
requires('gui')
39+
cls.root = tkinter.Tk()
40+
cls.root.withdraw()
41+
42+
@classmethod
43+
def tearDownClass(cls):
44+
cls.root.destroy()
45+
del cls.root
46+
47+
def setUp(self):
48+
self.text = tkinter.Text(self.root)
49+
self.addCleanup(self.text.destroy)
50+
51+
def test_x11_buttons(self):
52+
# Only X11 before Tk 8.7 sends the wheel as button events.
53+
text = self.text
54+
if text._windowingsystem == 'x11' and tkinter.TkVersion < 8.7:
55+
self.assertTrue(util.x11_buttons(text))
56+
else:
57+
self.assertFalse(util.x11_buttons(text))
58+
59+
def test_bind_wheel(self):
60+
# The events Tk sends here are the ones bound.
61+
text = self.text
62+
util.bind_wheel(text, util.wheel_event)
63+
if util.x11_buttons(text):
64+
self.assertEqual(sorted(text.bind()),
65+
['<Button-4>', '<Button-5>'])
66+
else:
67+
self.assertEqual(sorted(text.bind()), ['<MouseWheel>'])
68+
69+
70+
class WheelEventTest(unittest.TestCase):
71+
"Test the direction and the amount of the scroll."
72+
73+
# An unmapped widget has no height and does not scroll by lines,
74+
# so record the yview call instead of a real scroll.
75+
def event(self, event_type, delta=0, num='??'):
76+
# Tk leaves num '??' for a wheel event and delta 0 for a button.
77+
return Event(type=event_type, delta=delta, num=num,
78+
widget=mock.Mock())
79+
80+
def scroll(self, event, widget=None):
81+
"Return the arguments of the yview call."
82+
self.assertEqual(util.wheel_event(event, widget), 'break')
83+
scrolled = event.widget if widget is None else widget
84+
scrolled.yview.assert_called_once()
85+
return scrolled.yview.call_args.args
86+
87+
def test_mousewheel(self):
88+
# Delta is positive for up on all systems.
89+
for delta in 120, 1, 1200:
90+
self.assertEqual(self.scroll(self.event(EventType.MouseWheel,
91+
delta)),
92+
('scroll', -5, 'units'))
93+
self.assertEqual(self.scroll(self.event(EventType.MouseWheel,
94+
-delta)),
95+
('scroll', 5, 'units'))
96+
97+
def test_buttons(self):
98+
self.assertEqual(self.scroll(self.event(EventType.ButtonPress, num=4)),
99+
('scroll', -5, 'units'))
100+
self.assertEqual(self.scroll(self.event(EventType.ButtonPress, num=5)),
101+
('scroll', 5, 'units'))
102+
103+
def test_widget_argument(self):
104+
# A tree label scrolls the canvas, not itself.
105+
event = self.event(EventType.MouseWheel, 120)
106+
canvas = mock.Mock()
107+
self.assertEqual(self.scroll(event, canvas), ('scroll', -5, 'units'))
108+
event.widget.yview.assert_not_called()
109+
110+
111+
class FixTest(unittest.TestCase):
112+
"Test the fix_ functions, which need a display."
113+
114+
@classmethod
115+
def setUpClass(cls):
116+
requires('gui')
117+
cls.root = tkinter.Tk()
118+
cls.root.withdraw()
119+
120+
@classmethod
121+
def tearDownClass(cls):
122+
cls.root.destroy()
123+
del cls.root
124+
125+
def test_fix_scaling(self):
126+
from tkinter import font
127+
root = self.root
128+
scaling = root.tk.call('tk', 'scaling') # No Misc.tk_scaling yet.
129+
self.addCleanup(root.tk.call, 'tk', 'scaling', scaling)
130+
# Both fonts go with the root; Font.delete_font is a flag.
131+
pixels = font.Font(root=root, name='TestPixelFont', size=-16)
132+
points = font.Font(root=root, name='TestPointFont', size=12)
133+
134+
root.tk.call('tk', 'scaling', 1.0)
135+
util.fix_scaling(root) # No scaling, no change.
136+
self.assertEqual(int(pixels['size']), -16)
137+
138+
root.tk.call('tk', 'scaling', 2.0)
139+
util.fix_scaling(root) # A size in pixels becomes one in points.
140+
self.assertEqual(int(pixels['size']), 12) # round(-0.75 * -16)
141+
self.assertEqual(int(points['size']), 12) # Points are left alone.
142+
143+
def test_fix_word_breaks(self):
144+
root = self.root
145+
util.fix_word_breaks(root)
146+
self.assertEqual(root.tk.call('set', 'tcl_wordchars'), r'\w')
147+
self.assertEqual(root.tk.call('set', 'tcl_nonwordchars'), r'\W')
148+
149+
def test_fix_x11_paste(self):
150+
root = self.root
151+
classes = 'Text', 'Entry', 'Spinbox'
152+
before = {cls: root.bind_class(cls, '<<Paste>>') for cls in classes}
153+
util.fix_x11_paste(root)
154+
for cls in classes:
155+
with self.subTest(cls=cls):
156+
after = root.bind_class(cls, '<<Paste>>')
157+
if root._windowingsystem == 'x11':
158+
# Deleting the selection makes paste replace it.
159+
self.assertEqual(
160+
after,
161+
'catch {%W delete sel.first sel.last}\n' + before[cls])
162+
else:
163+
self.assertEqual(after, before[cls])
164+
12165

13166
if __name__ == '__main__':
14167
unittest.main(verbosity=2)

Lib/idlelib/tree.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from tkinter.ttk import Frame, Scrollbar
2121

2222
from idlelib.config import idleConf
23+
from idlelib.util import bind_wheel, wheel_event
2324
from idlelib import zoomheight
2425

2526
ICONDIR = "Icons"
@@ -56,30 +57,6 @@ def listicons(icondir=ICONDIR):
5657
column = 0
5758
root.images = images
5859

59-
def wheel_event(event, widget=None):
60-
"""Handle scrollwheel event.
61-
62-
For wheel up, event.delta = 120*n on Windows, -1*n on darwin,
63-
where n can be > 1 if one scrolls fast. Flicking the wheel
64-
generates up to maybe 20 events with n up to 10 or more 1.
65-
Macs use wheel down (delta = 1*n) to scroll up, so positive
66-
delta means to scroll up on both systems.
67-
68-
X-11 sends Control-Button-4,5 events instead.
69-
70-
The widget parameter is needed so browser label bindings can pass
71-
the underlying canvas.
72-
73-
This function depends on widget.yview to not be overridden by
74-
a subclass.
75-
"""
76-
up = {EventType.MouseWheel: event.delta > 0,
77-
EventType.ButtonPress: event.num == 4}
78-
lines = -5 if up[event.type] else 5
79-
widget = event.widget if widget is None else widget
80-
widget.yview(SCROLL, lines, 'units')
81-
return 'break'
82-
8360

8461
class TreeNode:
8562

@@ -285,10 +262,7 @@ def drawtext(self):
285262
anchor="nw", window=self.label)
286263
self.label.bind("<1>", self.select_or_edit)
287264
self.label.bind("<Double-1>", self.flip)
288-
self.label.bind("<MouseWheel>", lambda e: wheel_event(e, self.canvas))
289-
if self.label._windowingsystem == 'x11':
290-
self.label.bind("<Button-4>", lambda e: wheel_event(e, self.canvas))
291-
self.label.bind("<Button-5>", lambda e: wheel_event(e, self.canvas))
265+
bind_wheel(self.label, lambda e: wheel_event(e, self.canvas))
292266
self.text_id = id
293267
if TreeNode.dy == 0:
294268
# The first row doesn't matter what the dy is, just measure its
@@ -466,10 +440,7 @@ def __init__(self, master, **opts):
466440
self.canvas.bind("<Key-Next>", self.page_down)
467441
self.canvas.bind("<Key-Up>", self.unit_up)
468442
self.canvas.bind("<Key-Down>", self.unit_down)
469-
self.canvas.bind("<MouseWheel>", wheel_event)
470-
if self.canvas._windowingsystem == 'x11':
471-
self.canvas.bind("<Button-4>", wheel_event)
472-
self.canvas.bind("<Button-5>", wheel_event)
443+
bind_wheel(self.canvas, wheel_event)
473444
#if isinstance(master, Toplevel) or isinstance(master, Tk):
474445
self.canvas.bind("<Alt-Key-2>", self.zoom_height)
475446
self.canvas.focus_set()

Lib/idlelib/util.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,55 @@ def fix_x11_paste(root):
6565
root.bind_class(cls, '<<Paste>>'))
6666

6767

68+
# Mouse wheel handling.
69+
70+
def x11_buttons(widget):
71+
"""Return whether Tk reports wheel rotations to widget as button events.
72+
73+
On X11, Tk 8.6 and older report a mouse wheel rotation as a
74+
<Button-4> or <Button-5> event. Tk 8.7 and newer report it as a
75+
<MouseWheel> event, as Tk always did on Windows and macOS. Which of
76+
the two a widget gets depends on its windowing system, which is a
77+
property of its display, so a widget is needed, not just the version.
78+
"""
79+
from tkinter import TkVersion
80+
return TkVersion < 8.7 and widget._windowingsystem == 'x11'
81+
82+
83+
def bind_wheel(widget, func): # Called in editor and tree.
84+
"Bind func to the events that Tk sends widget for a wheel rotation."
85+
if x11_buttons(widget):
86+
widget.bind('<Button-4>', func)
87+
widget.bind('<Button-5>', func)
88+
else:
89+
widget.bind('<MouseWheel>', func)
90+
91+
92+
def wheel_event(event, widget=None):
93+
"""Handle a scrollwheel event by scrolling 5 lines.
94+
95+
For a <MouseWheel> event, event.delta is 120*n on Windows and X11,
96+
and -1*n on macOS, where n can be > 1 if one scrolls fast. Flicking
97+
the wheel generates up to maybe 20 events with n up to 10 or more.
98+
Macs use wheel down (delta = 1*n) to scroll up, so positive delta
99+
means to scroll up on all systems.
100+
101+
A <Button-4> or <Button-5> event (see x11_buttons) says up or down
102+
by its number, and has no delta; a wheel event has no number.
103+
104+
The widget parameter is needed so tree label bindings can pass the
105+
underlying canvas. If tree is replaced by ttk.Treeview, it can go.
106+
107+
This function depends on widget.yview to not be overridden by
108+
a subclass.
109+
"""
110+
up = event.num == 4 if event.num in (4, 5) else event.delta > 0
111+
lines = -5 if up else 5
112+
widget = event.widget if widget is None else widget
113+
widget.yview('scroll', lines, 'units')
114+
return 'break'
115+
116+
68117
if __name__ == '__main__':
69118
from unittest import main
70119
main('idlelib.idle_test.test_util', verbosity=2)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Consolidate IDLE's mouse wheel handling in ``idlelib.util``.
2+
``wheel_event`` moves there from ``idlelib.tree`` and joins ``x11_buttons``,
3+
which tells whether Tk reports wheel rotations to a widget as
4+
``<Button-4>``/``<Button-5>`` events, and ``bind_wheel``, which binds
5+
whichever events Tk sends.

0 commit comments

Comments
 (0)