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('', wheel_event) - if text._windowingsystem == 'x11': - text.bind('', wheel_event) - text.bind('', wheel_event) + bind_wheel(text, wheel_event) text.bind('', self.handle_winconfig) text.bind("<>", self.cut) text.bind("<>", 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 - # / wheel events with (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 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('', x=0, y=0) else: sidebar.canvas.event_generate('', 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('', x=0, y=0) else: sidebar.canvas.event_generate('', 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()), + ['', '']) + else: + self.assertEqual(sorted(text.bind()), ['']) + + +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, '<>') for cls in classes} + util.fix_x11_paste(root) + for cls in classes: + with self.subTest(cls=cls): + after = root.bind_class(cls, '<>') + 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("", self.flip) - self.label.bind("", lambda e: wheel_event(e, self.canvas)) - if self.label._windowingsystem == 'x11': - self.label.bind("", lambda e: wheel_event(e, self.canvas)) - self.label.bind("", 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("", self.page_down) self.canvas.bind("", self.unit_up) self.canvas.bind("", self.unit_down) - self.canvas.bind("", wheel_event) - if self.canvas._windowingsystem == 'x11': - self.canvas.bind("", wheel_event) - self.canvas.bind("", wheel_event) + bind_wheel(self.canvas, wheel_event) #if isinstance(master, Toplevel) or isinstance(master, Tk): self.canvas.bind("", 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, '<>')) +# 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 + or event. Tk 8.7 and newer report it as a + 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('', func) + widget.bind('', func) + else: + widget.bind('', func) + + +def wheel_event(event, widget=None): + """Handle a scrollwheel event by scrolling 5 lines. + + For a 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 or 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 +````/```` events, and ``bind_wheel``, which binds +whichever events Tk sends.