https://github.com/python/cpython/commit/19693afe65ec2620e4930f5cad0fe9cb08364f6f
commit: 19693afe65ec2620e4930f5cad0fe9cb08364f6f
branch: 3.13
author: Miss Islington (bot) <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-09-06T10:44:05Z
summary:

[3.13] gh-154511: Consolidate IDLE's mouse wheel handling in util (GH-156974) 
(GH-157023)

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 57594aae5e6155d30b9192a4cd85333584339445)

Co-authored-by: Serhiy Storchaka <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>

files:
A Misc/NEWS.d/next/IDLE/2026-09-05-16-00-00.gh-issue-154511.hV2wQn.rst
M Lib/idlelib/editor.py
M Lib/idlelib/idle_test/test_sidebar.py
M Lib/idlelib/idle_test/test_tree.py
M Lib/idlelib/idle_test/test_util.py
M Lib/idlelib/tree.py
M Lib/idlelib/util.py

diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py
index 89e1725e15f30b0..2b4b95e053ba10b 100644
--- a/Lib/idlelib/editor.py
+++ b/Lib/idlelib/editor.py
@@ -26,8 +26,7 @@
 from idlelib import query
 from idlelib import replace
 from idlelib import search
-from idlelib.tree import wheel_event
-from idlelib.util import py_extensions
+from idlelib.util import bind_wheel, py_extensions, wheel_event
 from idlelib import window
 from idlelib.help import _get_dochome
 
@@ -115,10 +114,7 @@ def __init__(self, flist=None, filename=None, key=None, 
root=None):
             # Elsewhere, use right-click for popup menus.
             text.bind("<3>",self.right_menu_event)
 
-        text.bind('<MouseWheel>', wheel_event)
-        if text._windowingsystem == 'x11':
-            text.bind('<Button-4>', wheel_event)
-            text.bind('<Button-5>', wheel_event)
+        bind_wheel(text, wheel_event)
         text.bind('<Configure>', self.handle_winconfig)
         text.bind("<<cut>>", self.cut)
         text.bind("<<copy>>", self.copy)
diff --git a/Lib/idlelib/idle_test/test_sidebar.py 
b/Lib/idlelib/idle_test/test_sidebar.py
index 25723b0a6abdf76..52d588299b3451c 100644
--- a/Lib/idlelib/idle_test/test_sidebar.py
+++ b/Lib/idlelib/idle_test/test_sidebar.py
@@ -14,7 +14,8 @@
 from idlelib.percolator import Percolator
 import idlelib.pyshell
 from idlelib.pyshell import PyShell, PyShellFileList
-from idlelib.util import fix_scaling, fix_word_breaks, fix_x11_paste
+from idlelib.util import (fix_scaling, fix_word_breaks, fix_x11_paste,
+                          x11_buttons)
 import idlelib.sidebar
 from idlelib.sidebar import get_end_linenumber, get_lineno
 
@@ -689,15 +690,13 @@ def test_mousewheel(self):
         last_lineno = get_end_linenumber(text)
         self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
 
-        # Simulate a mouse wheel notch.  Tk 8.7 replaced the X11
-        # <Button-4>/<Button-5> wheel events with <MouseWheel> (whose delta is
-        # platform-dependent); older Tk on X11 still uses the button events.
-        x11_buttons = (sidebar.canvas._windowingsystem == 'x11'
-                       and tk.TkVersion < 8.7)
+        # Simulate a mouse wheel notch with the events that Tk sends for
+        # one; the delta of a <MouseWheel> event is platform-dependent.
+        buttons = x11_buttons(sidebar.canvas)
         delta = 1 if sidebar.canvas._windowingsystem == 'aqua' else 120
 
         # Scroll up.
-        if x11_buttons:
+        if buttons:
             sidebar.canvas.event_generate('<Button-4>', x=0, y=0)
         else:
             sidebar.canvas.event_generate('<MouseWheel>', x=0, y=0, 
delta=delta)
@@ -705,7 +704,7 @@ def test_mousewheel(self):
         self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
 
         # Scroll back down.
-        if x11_buttons:
+        if buttons:
             sidebar.canvas.event_generate('<Button-5>', x=0, y=0)
         else:
             sidebar.canvas.event_generate('<MouseWheel>', x=0, y=0, 
delta=-delta)
diff --git a/Lib/idlelib/idle_test/test_tree.py 
b/Lib/idlelib/idle_test/test_tree.py
index b3e4c10cf9e38e7..9be9abee361f083 100644
--- a/Lib/idlelib/idle_test/test_tree.py
+++ b/Lib/idlelib/idle_test/test_tree.py
@@ -4,7 +4,7 @@
 import unittest
 from test.support import requires
 requires('gui')
-from tkinter import Tk, EventType, SCROLL
+from tkinter import Tk
 
 
 class TreeTest(unittest.TestCase):
@@ -29,32 +29,5 @@ def test_init(self):
         node.expand()
 
 
-class TestScrollEvent(unittest.TestCase):
-
-    def test_wheel_event(self):
-        # Fake widget class containing `yview` only.
-        class _Widget:
-            def __init__(widget, *expected):
-                widget.expected = expected
-            def yview(widget, *args):
-                self.assertTupleEqual(widget.expected, args)
-        # Fake event class
-        class _Event:
-            pass
-        #        (type, delta, num, amount)
-        tests = ((EventType.MouseWheel, 120, -1, -5),
-                 (EventType.MouseWheel, -120, -1, 5),
-                 (EventType.ButtonPress, -1, 4, -5),
-                 (EventType.ButtonPress, -1, 5, 5))
-
-        event = _Event()
-        for ty, delta, num, amount in tests:
-            event.type = ty
-            event.delta = delta
-            event.num = num
-            res = tree.wheel_event(event, _Widget(SCROLL, amount, "units"))
-            self.assertEqual(res, "break")
-
-
 if __name__ == '__main__':
     unittest.main(verbosity=2)
diff --git a/Lib/idlelib/idle_test/test_util.py 
b/Lib/idlelib/idle_test/test_util.py
index 20721fe980c784e..0acf4f0fe5a2c0d 100644
--- a/Lib/idlelib/idle_test/test_util.py
+++ b/Lib/idlelib/idle_test/test_util.py
@@ -1,14 +1,167 @@
 """Test util, coverage 100%"""
 
+import sys
 import unittest
+from unittest import mock
+from test.support import requires
+from test.support.isolation import runInSubprocess
+import tkinter
+from tkinter import EventType
 from idlelib import util
+from idlelib.idle_test.mock_tk import Event
 
 
 class UtilTest(unittest.TestCase):
+
     def test_extensions(self):
         for extension in {'.pyi', '.py', '.pyw'}:
             self.assertIn(extension, util.py_extensions)
 
+    @unittest.skipUnless(sys.platform == 'win32', 'Windows only')
+    @runInSubprocess()
+    def test_fix_win_hidpi(self):
+        # Awareness is process-wide and cannot be undone.
+        import ctypes
+        PROCESS_DPI_UNAWARE = 0
+        util.fix_win_hidpi()
+        awareness = ctypes.c_int()
+        ctypes.OleDLL('shcore').GetProcessDpiAwareness(
+                None, ctypes.byref(awareness))
+        self.assertNotEqual(awareness.value, PROCESS_DPI_UNAWARE)
+
+
+class WheelTest(unittest.TestCase):
+    "Test the wheel functions with a widget on this display."
+
+    @classmethod
+    def setUpClass(cls):
+        requires('gui')
+        cls.root = tkinter.Tk()
+        cls.root.withdraw()
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.root.destroy()
+        del cls.root
+
+    def setUp(self):
+        self.text = tkinter.Text(self.root)
+        self.addCleanup(self.text.destroy)
+
+    def test_x11_buttons(self):
+        # Only X11 before Tk 8.7 sends the wheel as button events.
+        text = self.text
+        if text._windowingsystem == 'x11' and tkinter.TkVersion < 8.7:
+            self.assertTrue(util.x11_buttons(text))
+        else:
+            self.assertFalse(util.x11_buttons(text))
+
+    def test_bind_wheel(self):
+        # The events Tk sends here are the ones bound.
+        text = self.text
+        util.bind_wheel(text, util.wheel_event)
+        if util.x11_buttons(text):
+            self.assertEqual(sorted(text.bind()),
+                             ['<Button-4>', '<Button-5>'])
+        else:
+            self.assertEqual(sorted(text.bind()), ['<MouseWheel>'])
+
+
+class WheelEventTest(unittest.TestCase):
+    "Test the direction and the amount of the scroll."
+
+    # An unmapped widget has no height and does not scroll by lines,
+    # so record the yview call instead of a real scroll.
+    def event(self, event_type, delta=0, num='??'):
+        # Tk leaves num '??' for a wheel event and delta 0 for a button.
+        return Event(type=event_type, delta=delta, num=num,
+                     widget=mock.Mock())
+
+    def scroll(self, event, widget=None):
+        "Return the arguments of the yview call."
+        self.assertEqual(util.wheel_event(event, widget), 'break')
+        scrolled = event.widget if widget is None else widget
+        scrolled.yview.assert_called_once()
+        return scrolled.yview.call_args.args
+
+    def test_mousewheel(self):
+        # Delta is positive for up on all systems.
+        for delta in 120, 1, 1200:
+            self.assertEqual(self.scroll(self.event(EventType.MouseWheel,
+                                                    delta)),
+                             ('scroll', -5, 'units'))
+            self.assertEqual(self.scroll(self.event(EventType.MouseWheel,
+                                                    -delta)),
+                             ('scroll', 5, 'units'))
+
+    def test_buttons(self):
+        self.assertEqual(self.scroll(self.event(EventType.ButtonPress, num=4)),
+                         ('scroll', -5, 'units'))
+        self.assertEqual(self.scroll(self.event(EventType.ButtonPress, num=5)),
+                         ('scroll', 5, 'units'))
+
+    def test_widget_argument(self):
+        # A tree label scrolls the canvas, not itself.
+        event = self.event(EventType.MouseWheel, 120)
+        canvas = mock.Mock()
+        self.assertEqual(self.scroll(event, canvas), ('scroll', -5, 'units'))
+        event.widget.yview.assert_not_called()
+
+
+class FixTest(unittest.TestCase):
+    "Test the fix_ functions, which need a display."
+
+    @classmethod
+    def setUpClass(cls):
+        requires('gui')
+        cls.root = tkinter.Tk()
+        cls.root.withdraw()
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.root.destroy()
+        del cls.root
+
+    def test_fix_scaling(self):
+        from tkinter import font
+        root = self.root
+        scaling = root.tk.call('tk', 'scaling')  # No Misc.tk_scaling yet.
+        self.addCleanup(root.tk.call, 'tk', 'scaling', scaling)
+        # Both fonts go with the root; Font.delete_font is a flag.
+        pixels = font.Font(root=root, name='TestPixelFont', size=-16)
+        points = font.Font(root=root, name='TestPointFont', size=12)
+
+        root.tk.call('tk', 'scaling', 1.0)
+        util.fix_scaling(root)  # No scaling, no change.
+        self.assertEqual(int(pixels['size']), -16)
+
+        root.tk.call('tk', 'scaling', 2.0)
+        util.fix_scaling(root)  # A size in pixels becomes one in points.
+        self.assertEqual(int(pixels['size']), 12)  # round(-0.75 * -16)
+        self.assertEqual(int(points['size']), 12)  # Points are left alone.
+
+    def test_fix_word_breaks(self):
+        root = self.root
+        util.fix_word_breaks(root)
+        self.assertEqual(root.tk.call('set', 'tcl_wordchars'), r'\w')
+        self.assertEqual(root.tk.call('set', 'tcl_nonwordchars'), r'\W')
+
+    def test_fix_x11_paste(self):
+        root = self.root
+        classes = 'Text', 'Entry', 'Spinbox'
+        before = {cls: root.bind_class(cls, '<<Paste>>') for cls in classes}
+        util.fix_x11_paste(root)
+        for cls in classes:
+            with self.subTest(cls=cls):
+                after = root.bind_class(cls, '<<Paste>>')
+                if root._windowingsystem == 'x11':
+                    # Deleting the selection makes paste replace it.
+                    self.assertEqual(
+                        after,
+                        'catch {%W delete sel.first sel.last}\n' + before[cls])
+                else:
+                    self.assertEqual(after, before[cls])
+
 
 if __name__ == '__main__':
     unittest.main(verbosity=2)
diff --git a/Lib/idlelib/tree.py b/Lib/idlelib/tree.py
index 182ce7189614daf..cd32f04b7c34abe 100644
--- a/Lib/idlelib/tree.py
+++ b/Lib/idlelib/tree.py
@@ -20,6 +20,7 @@
 from tkinter.ttk import Frame, Scrollbar
 
 from idlelib.config import idleConf
+from idlelib.util import bind_wheel, wheel_event
 from idlelib import zoomheight
 
 ICONDIR = "Icons"
@@ -56,30 +57,6 @@ def listicons(icondir=ICONDIR):
             column = 0
     root.images = images
 
-def wheel_event(event, widget=None):
-    """Handle scrollwheel event.
-
-    For wheel up, event.delta = 120*n on Windows, -1*n on darwin,
-    where n can be > 1 if one scrolls fast.  Flicking the wheel
-    generates up to maybe 20 events with n up to 10 or more 1.
-    Macs use wheel down (delta = 1*n) to scroll up, so positive
-    delta means to scroll up on both systems.
-
-    X-11 sends Control-Button-4,5 events instead.
-
-    The widget parameter is needed so browser label bindings can pass
-    the underlying canvas.
-
-    This function depends on widget.yview to not be overridden by
-    a subclass.
-    """
-    up = {EventType.MouseWheel: event.delta > 0,
-          EventType.ButtonPress: event.num == 4}
-    lines = -5 if up[event.type] else 5
-    widget = event.widget if widget is None else widget
-    widget.yview(SCROLL, lines, 'units')
-    return 'break'
-
 
 class TreeNode:
 
@@ -285,10 +262,7 @@ def drawtext(self):
                                        anchor="nw", window=self.label)
         self.label.bind("<1>", self.select_or_edit)
         self.label.bind("<Double-1>", self.flip)
-        self.label.bind("<MouseWheel>", lambda e: wheel_event(e, self.canvas))
-        if self.label._windowingsystem == 'x11':
-            self.label.bind("<Button-4>", lambda e: wheel_event(e, 
self.canvas))
-            self.label.bind("<Button-5>", lambda e: wheel_event(e, 
self.canvas))
+        bind_wheel(self.label, lambda e: wheel_event(e, self.canvas))
         self.text_id = id
         if TreeNode.dy == 0:
             # The first row doesn't matter what the dy is, just measure its
@@ -466,10 +440,7 @@ def __init__(self, master, **opts):
         self.canvas.bind("<Key-Next>", self.page_down)
         self.canvas.bind("<Key-Up>", self.unit_up)
         self.canvas.bind("<Key-Down>", self.unit_down)
-        self.canvas.bind("<MouseWheel>", wheel_event)
-        if self.canvas._windowingsystem == 'x11':
-            self.canvas.bind("<Button-4>", wheel_event)
-            self.canvas.bind("<Button-5>", wheel_event)
+        bind_wheel(self.canvas, wheel_event)
         #if isinstance(master, Toplevel) or isinstance(master, Tk):
         self.canvas.bind("<Alt-Key-2>", self.zoom_height)
         self.canvas.focus_set()
diff --git a/Lib/idlelib/util.py b/Lib/idlelib/util.py
index bf88c905e1d177d..22b7b0763f9b10a 100644
--- a/Lib/idlelib/util.py
+++ b/Lib/idlelib/util.py
@@ -65,6 +65,55 @@ def fix_x11_paste(root):
                         root.bind_class(cls, '<<Paste>>'))
 
 
+# Mouse wheel handling.
+
+def x11_buttons(widget):
+    """Return whether Tk reports wheel rotations to widget as button events.
+
+    On X11, Tk 8.6 and older report a mouse wheel rotation as a
+    <Button-4> or <Button-5> event.  Tk 8.7 and newer report it as a
+    <MouseWheel> event, as Tk always did on Windows and macOS.  Which of
+    the two a widget gets depends on its windowing system, which is a
+    property of its display, so a widget is needed, not just the version.
+    """
+    from tkinter import TkVersion
+    return TkVersion < 8.7 and widget._windowingsystem == 'x11'
+
+
+def bind_wheel(widget, func):  # Called in editor and tree.
+    "Bind func to the events that Tk sends widget for a wheel rotation."
+    if x11_buttons(widget):
+        widget.bind('<Button-4>', func)
+        widget.bind('<Button-5>', func)
+    else:
+        widget.bind('<MouseWheel>', func)
+
+
+def wheel_event(event, widget=None):
+    """Handle a scrollwheel event by scrolling 5 lines.
+
+    For a <MouseWheel> event, event.delta is 120*n on Windows and X11,
+    and -1*n on macOS, where n can be > 1 if one scrolls fast.  Flicking
+    the wheel generates up to maybe 20 events with n up to 10 or more.
+    Macs use wheel down (delta = 1*n) to scroll up, so positive delta
+    means to scroll up on all systems.
+
+    A <Button-4> or <Button-5> event (see x11_buttons) says up or down
+    by its number, and has no delta; a wheel event has no number.
+
+    The widget parameter is needed so tree label bindings can pass the
+    underlying canvas.  If tree is replaced by ttk.Treeview, it can go.
+
+    This function depends on widget.yview to not be overridden by
+    a subclass.
+    """
+    up = event.num == 4 if event.num in (4, 5) else event.delta > 0
+    lines = -5 if up else 5
+    widget = event.widget if widget is None else widget
+    widget.yview('scroll', lines, 'units')
+    return 'break'
+
+
 if __name__ == '__main__':
     from unittest import main
     main('idlelib.idle_test.test_util', verbosity=2)
diff --git 
a/Misc/NEWS.d/next/IDLE/2026-09-05-16-00-00.gh-issue-154511.hV2wQn.rst 
b/Misc/NEWS.d/next/IDLE/2026-09-05-16-00-00.gh-issue-154511.hV2wQn.rst
new file mode 100644
index 000000000000000..591c1582fa8d75a
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2026-09-05-16-00-00.gh-issue-154511.hV2wQn.rst
@@ -0,0 +1,5 @@
+Consolidate IDLE's mouse wheel handling in ``idlelib.util``.
+``wheel_event`` moves there from ``idlelib.tree`` and joins ``x11_buttons``,
+which tells whether Tk reports wheel rotations to a widget as
+``<Button-4>``/``<Button-5>`` events, and ``bind_wheel``, which binds
+whichever events Tk sends.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to