Skip to content

Commit f98fb9d

Browse files
gh-153716: Harden ttk/tkinter GUI tests against display scaling (GH-153717)
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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 87411d0)
1 parent 2ec11e0 commit f98fb9d

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
@@ -2013,9 +2013,13 @@ def test_configure_to(self):
20132013
def test_identify(self):
20142014
widget = self.create()
20152015
widget.pack()
2016-
widget.update_idletasks()
2017-
self.assertIn(widget.identify(5, 5),
2018-
('slider', 'trough1', 'trough2', ''))
2016+
# Probe a point on the trough centreline (Scale.coords()) rather than
2017+
# a fixed pixel: (5, 5) lies outside the trough and always identifies
2018+
# as '', so it would not actually exercise identify().
2019+
if wait_until_mapped(widget):
2020+
x, y = widget.coords()
2021+
self.assertIn(widget.identify(int(x), int(y)),
2022+
('slider', 'trough1', 'trough2'))
20192023
self.assertRaises(TclError, widget.identify, 'a', 'b')
20202024

20212025

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')
@@ -392,7 +405,12 @@ def test_identify(self):
392405
# Identifying the element under a point requires the widget to be
393406
# mapped with a real size; the rest of the checks do not.
394407
if wait_until_mapped(self.entry):
395-
self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS)
408+
# Probe the centre of the widget: a fixed pixel such as (5, 5)
409+
# lands in the field at a normal scaling but in the surrounding
410+
# padding at a high DPI.
411+
x = self.entry.winfo_width() // 2
412+
y = self.entry.winfo_height() // 2
413+
self.assertIn(self.entry.identify(x, y), self.IDENTIFY_AS)
396414
self.assertEqual(self.entry.identify(-1, -1), "")
397415

398416
self.assertRaises(tkinter.TclError, self.entry.identify, None, 5)
@@ -493,10 +511,11 @@ def test_configure_height(self):
493511
self.checkParams(widget, 'height', 100, 101.2, 102.6, -100, 0, '1i')
494512

495513
def _show_drop_down_listbox(self):
496-
width = self.combo.winfo_width()
497-
x, y = width - 5, 5
514+
y = 5
498515
if sys.platform != 'darwin': # there's no down arrow on macOS
499-
self.assertRegex(self.combo.identify(x, y), r'.*downarrow\Z')
516+
x = self._arrow_x(self.combo, y, 'downarrow')
517+
else:
518+
x = self.combo.winfo_width() - 5
500519
self.combo.event_generate('<Button-1>', x=x, y=y)
501520
self.combo.event_generate('<ButtonRelease-1>', x=x, y=y)
502521

@@ -1069,21 +1088,15 @@ def test_tab_identifiers(self):
10691088

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

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

10881101
def test_add_and_hidden(self):
10891102
self.assertRaises(tkinter.TclError, self.nb.hide, -1)
@@ -1213,23 +1226,43 @@ def test_configure_tabs(self):
12131226

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

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

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

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

12631300

12641301
@add_configure_tests(IntegerSizeTests, StandardTtkOptionsTests)
@@ -1284,22 +1321,16 @@ def create(self, **kwargs):
12841321

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

12961330
def _click_decrement_arrow(self):
12971331
self.require_mapped(self.spin)
1298-
width = self.spin.winfo_width()
1299-
height = self.spin.winfo_height()
1300-
x = width - 5
1301-
y = height//2 + 4
1302-
self.assertRegex(self.spin.identify(x, y), r'.*downarrow\Z')
1332+
y = self.spin.winfo_height()//2 + 4
1333+
x = self._arrow_x(self.spin, y, 'downarrow')
13031334
self.spin.event_generate('<ButtonPress-1>', x=x, y=y)
13041335
self.spin.event_generate('<ButtonRelease-1>', x=x, y=y)
13051336
self.spin.update_idletasks()
@@ -2069,14 +2100,17 @@ def test_tag_bind(self):
20692100
self.tv.pack()
20702101
self.tv.update()
20712102

2103+
# Find the y coordinate of each item. Scan the whole height of the
2104+
# widget rather than a fixed pixel range, since the row height grows
2105+
# with the display scaling.
20722106
pos_y = set()
20732107
found = set()
2074-
for i in range(0, 100, 10):
2108+
for y in range(self.tv.winfo_height()):
20752109
if len(found) == 2: # item1 and item2 already found
20762110
break
2077-
item_id = self.tv.identify_row(i)
2111+
item_id = self.tv.identify_row(y)
20782112
if item_id and item_id not in found:
2079-
pos_y.add(i)
2113+
pos_y.add(y)
20802114
found.add(item_id)
20812115

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

0 commit comments

Comments
 (0)