[3.13] gh-154511: Consolidate IDLE's mouse wheel handling in util (GH-156974) by miss-islington · Pull Request #157023 · python/cpython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions Lib/idlelib/editor.py
15 changes: 7 additions & 8 deletions Lib/idlelib/idle_test/test_sidebar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -689,23 +690,21 @@ 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)
yield
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)
Expand Down
29 changes: 1 addition & 28 deletions Lib/idlelib/idle_test/test_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
153 changes: 153 additions & 0 deletions Lib/idlelib/idle_test/test_util.py
Original file line number Diff line number Diff line change
@@ -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)
35 changes: 3 additions & 32 deletions Lib/idlelib/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
49 changes: 49 additions & 0 deletions Lib/idlelib/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading