Skip to content

Commit 2ec8fcf

Browse files
miss-islingtonserhiy-storchakaclaude
authored
[3.14] gh-153716: Harden ttk/tkinter GUI tests against display scaling (GH-153717) (GH-153738)
Compute probe coordinates from each widget's own realized geometry instead of hardcoding pixels such as (5, 5) or width - 5, which land on a different element, or miss it entirely, at a high display scaling. (cherry picked from commit 87411d0) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1290d4f commit 2ec8fcf

2 files changed

Lines changed: 91 additions & 53 deletions

File tree

Lib/test/test_tkinter/test_widgets.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2017,9 +2017,13 @@ def test_configure_to(self):
20172017
def test_identify(self):
20182018
widget = self.create()
20192019
widget.pack()
2020-
widget.update_idletasks()
2021-
self.assertIn(widget.identify(5, 5),
2022-
('slider', 'trough1', 'trough2', ''))
2020+
# Probe a point on the trough centreline (Scale.coords()) rather than
2021+
# a fixed pixel: (5, 5) lies outside the trough and always identifies
2022+
# as '', so it would not actually exercise identify().
2023+
if wait_until_mapped(widget):
2024+
x, y = widget.coords()
2025+
self.assertIn(widget.identify(int(x), int(y)),
2026+
('slider', 'trough1', 'trough2'))
20232027
self.assertRaises(TclError, widget.identify, 'a', 'b')
20242028

20252029

Lib/test/test_ttk/test_widgets.py

Lines changed: 84 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,19 @@ def setUp(self):
357357
def create(self, **kwargs):
358358
return ttk.Entry(self.root, **kwargs)
359359

360+
def _arrow_x(self, widget, y, arrow):
361+
# Return an x coordinate on the up/down *arrow* element in row y,
362+
# found by scanning inward from the right edge of the widget. A
363+
# fixed inset such as width - 5 lands in the arrow element's
364+
# right-hand border, which scales with the display scaling, so at a
365+
# high DPI identify() there returns 'field' instead of the arrow.
366+
self.assertTrue(wait_until_mapped(widget, full_size=True))
367+
width = widget.winfo_width()
368+
for x in range(width - 1, width // 2, -1):
369+
if widget.identify(x, y).endswith(arrow):
370+
return x
371+
self.fail(f'no {arrow} found in row {y}')
372+
360373
def test_configure_invalidcommand(self):
361374
widget = self.create()
362375
self.checkCommandParam(widget, 'invalidcommand')
@@ -391,7 +404,12 @@ def test_identify(self):
391404
# Identifying the element under a point requires the widget to be
392405
# mapped with a real size; the rest of the checks do not.
393406
if wait_until_mapped(self.entry):
394-
self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS)
407+
# Probe the centre of the widget: a fixed pixel such as (5, 5)
408+
# lands in the field at a normal scaling but in the surrounding
409+
# padding at a high DPI.
410+
x = self.entry.winfo_width() // 2
411+
y = self.entry.winfo_height() // 2
412+
self.assertIn(self.entry.identify(x, y), self.IDENTIFY_AS)
395413
self.assertEqual(self.entry.identify(-1, -1), "")
396414

397415
self.assertRaises(tkinter.TclError, self.entry.identify, None, 5)
@@ -492,10 +510,11 @@ def test_configure_height(self):
492510
self.checkParams(widget, 'height', 100, 101.2, 102.6, -100, 0, '1i')
493511

494512
def _show_drop_down_listbox(self):
495-
width = self.combo.winfo_width()
496-
x, y = width - 5, 5
513+
y = 5
497514
if sys.platform != 'darwin': # there's no down arrow on macOS
498-
self.assertRegex(self.combo.identify(x, y), r'.*downarrow\z')
515+
x = self._arrow_x(self.combo, y, 'downarrow')
516+
else:
517+
x = self.combo.winfo_width() - 5
499518
self.combo.event_generate('<Button-1>', x=x, y=y)
500519
self.combo.event_generate('<ButtonRelease-1>', x=x, y=y)
501520

@@ -1068,21 +1087,15 @@ def test_tab_identifiers(self):
10681087

10691088
self.nb.pack()
10701089
self.nb.update()
1071-
if sys.platform == 'darwin':
1072-
tb_idx = "@20,5"
1073-
else:
1074-
tb_idx = "@5,5"
1075-
self.assertEqual(self.nb.tab(tb_idx), self.nb.tab('current'))
1076-
1077-
for i in range(5, 100, 5):
1078-
try:
1079-
if self.nb.tab('@%d, 5' % i, text=None) == 'a':
1080-
break
1081-
except tkinter.TclError:
1082-
pass
1090+
# Address a tab by a point taken from Tk's own geometry rather than a
1091+
# fixed pixel, which can land on a different tab or miss the tabs
1092+
# entirely (e.g. on Aqua the tabs are inset) as the display scaling
1093+
# changes.
1094+
x, y = self._tab_point(self.nb.index('current'))
1095+
self.assertEqual(self.nb.tab(f'@{x},{y}'), self.nb.tab('current'))
10831096

1084-
else:
1085-
self.fail("Tab with text 'a' not found")
1097+
x, y = self._tab_point(self.nb.index(self.child1))
1098+
self.assertEqual(self.nb.tab(f'@{x},{y}', text=None), 'a')
10861099

10871100
def test_add_and_hidden(self):
10881101
self.assertRaises(tkinter.TclError, self.nb.hide, -1)
@@ -1212,23 +1225,43 @@ def test_configure_tabs(self):
12121225

12131226
self.assertEqual(self.nb.tabs(), ())
12141227

1228+
def _tab_point(self, index):
1229+
# Return a window coordinate that really lies on tab *index*, taken
1230+
# from Tk's own geometry (ttk::notebook index @x,y), so that it is
1231+
# correct regardless of the theme, platform and display scaling. A
1232+
# fixed pixel such as (5, 5) can land on a different tab element
1233+
# depending on the DPI, or miss the tabs entirely (e.g. on Aqua the
1234+
# tabs are inset), which would make a click there test nothing.
1235+
self.assertTrue(wait_until_mapped(self.nb, full_size=True))
1236+
w, h = self.nb.winfo_width(), self.nb.winfo_height()
1237+
# The selected pane fills the widget except for the tab strip, so skip
1238+
# that large tab-free region instead of querying every pixel in it.
1239+
pane = self.nb.nametowidget(self.nb.select())
1240+
px, py = pane.winfo_x(), pane.winfo_y()
1241+
pw, ph = pane.winfo_width(), pane.winfo_height()
1242+
for y in range(h):
1243+
inside_y = py <= y < py + ph
1244+
for x in range(w):
1245+
if inside_y and px <= x < px + pw:
1246+
continue
1247+
try:
1248+
if self.nb.index(f'@{x},{y}') == index:
1249+
return x, y
1250+
except TclError:
1251+
continue
1252+
self.fail(f'no point found on tab {index}')
1253+
12151254
def test_traversal(self):
12161255
self.nb.pack()
12171256
self.nb.update()
12181257

1219-
self.nb.select(0)
1258+
# A mouse click selects the tab it lands on.
1259+
self.nb.select(1)
1260+
self.assertEqual(self.nb.select(), str(self.child2))
1261+
simulate_mouse_click(self.nb, *self._tab_point(0))
1262+
self.assertEqual(self.nb.select(), str(self.child1))
12201263

1221-
if sys.platform == 'darwin':
1222-
focus_identify_as = ''
1223-
elif sys.platform == 'win32':
1224-
focus_identify_as = 'focus'
1225-
else:
1226-
focus_identify_as = 'focus' if tk_version < (8, 7) else 'padding'
1227-
# identify() at (5, 5) needs the tab realized there; under focus
1228-
# contention the mapped size can lag, so wait for the full size.
1229-
if wait_until_mapped(self.nb, full_size=True):
1230-
self.assertEqual(self.nb.identify(5, 5), focus_identify_as)
1231-
simulate_mouse_click(self.nb, 5, 5)
1264+
# Control-Tab and Shift-Control-Tab traverse the tabs.
12321265
self.nb.focus_force()
12331266
self.nb.event_generate('<Control-Tab>')
12341267
self.assertEqual(self.nb.select(), str(self.child2))
@@ -1243,21 +1276,25 @@ def test_traversal(self):
12431276
self.nb.tab(self.child2, text='e', underline=0)
12441277
self.nb.enable_traversal()
12451278
self.nb.focus_force()
1246-
if wait_until_mapped(self.nb, full_size=True):
1247-
self.assertEqual(self.nb.identify(5, 5), focus_identify_as)
1248-
simulate_mouse_click(self.nb, 5, 5)
1249-
# on macOS Emacs-style keyboard shortcuts are region-dependent;
1250-
# let's use the regular arrow keys instead
1279+
1280+
# A click still selects the tab it lands on after enable_traversal().
1281+
self.nb.select(1)
1282+
self.assertEqual(self.nb.select(), str(self.child2))
1283+
simulate_mouse_click(self.nb, *self._tab_point(0))
1284+
self.assertEqual(self.nb.select(), str(self.child1))
1285+
1286+
# Mnemonics traverse the tabs (macOS uses region-dependent
1287+
# Emacs-style shortcuts, so use the regular arrow keys there).
12511288
if sys.platform == 'darwin':
12521289
begin = '<Left>'
12531290
end = '<Right>'
12541291
else:
12551292
begin = '<Alt-a>'
12561293
end = '<Alt-e>'
1257-
self.nb.event_generate(begin)
1258-
self.assertEqual(self.nb.select(), str(self.child1))
12591294
self.nb.event_generate(end)
12601295
self.assertEqual(self.nb.select(), str(self.child2))
1296+
self.nb.event_generate(begin)
1297+
self.assertEqual(self.nb.select(), str(self.child1))
12611298

12621299

12631300
@add_configure_tests(IntegerSizeTests, StandardTtkOptionsTests)
@@ -1283,22 +1320,16 @@ def create(self, **kwargs):
12831320

12841321
def _click_increment_arrow(self):
12851322
self.require_mapped(self.spin)
1286-
width = self.spin.winfo_width()
1287-
height = self.spin.winfo_height()
1288-
x = width - 5
1289-
y = height//2 - 5
1290-
self.assertRegex(self.spin.identify(x, y), r'.*uparrow\z')
1323+
y = self.spin.winfo_height()//2 - 5
1324+
x = self._arrow_x(self.spin, y, 'uparrow')
12911325
self.spin.event_generate('<ButtonPress-1>', x=x, y=y)
12921326
self.spin.event_generate('<ButtonRelease-1>', x=x, y=y)
12931327
self.spin.update_idletasks()
12941328

12951329
def _click_decrement_arrow(self):
12961330
self.require_mapped(self.spin)
1297-
width = self.spin.winfo_width()
1298-
height = self.spin.winfo_height()
1299-
x = width - 5
1300-
y = height//2 + 4
1301-
self.assertRegex(self.spin.identify(x, y), r'.*downarrow\z')
1331+
y = self.spin.winfo_height()//2 + 4
1332+
x = self._arrow_x(self.spin, y, 'downarrow')
13021333
self.spin.event_generate('<ButtonPress-1>', x=x, y=y)
13031334
self.spin.event_generate('<ButtonRelease-1>', x=x, y=y)
13041335
self.spin.update_idletasks()
@@ -2068,14 +2099,17 @@ def test_tag_bind(self):
20682099
self.tv.pack()
20692100
self.tv.update()
20702101

2102+
# Find the y coordinate of each item. Scan the whole height of the
2103+
# widget rather than a fixed pixel range, since the row height grows
2104+
# with the display scaling.
20712105
pos_y = set()
20722106
found = set()
2073-
for i in range(0, 100, 10):
2107+
for y in range(self.tv.winfo_height()):
20742108
if len(found) == 2: # item1 and item2 already found
20752109
break
2076-
item_id = self.tv.identify_row(i)
2110+
item_id = self.tv.identify_row(y)
20772111
if item_id and item_id not in found:
2078-
pos_y.add(i)
2112+
pos_y.add(y)
20792113
found.add(item_id)
20802114

20812115
self.assertEqual(len(pos_y), 2) # item1 and item2 y pos

0 commit comments

Comments
 (0)