Skip to content

Commit 87411d0

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>
1 parent c5530cc commit 87411d0

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
@@ -2082,9 +2082,13 @@ def test_configure_to(self):
20822082
def test_identify(self):
20832083
widget = self.create()
20842084
widget.pack()
2085-
widget.update_idletasks()
2086-
self.assertIn(widget.identify(5, 5),
2087-
('slider', 'trough1', 'trough2', ''))
2085+
# Probe a point on the trough centreline (Scale.coords()) rather than
2086+
# a fixed pixel: (5, 5) lies outside the trough and always identifies
2087+
# as '', so it would not actually exercise identify().
2088+
if wait_until_mapped(widget):
2089+
x, y = widget.coords()
2090+
self.assertIn(widget.identify(int(x), int(y)),
2091+
('slider', 'trough1', 'trough2'))
20882092
self.assertRaises(TclError, widget.identify, 'a', 'b')
20892093

20902094

Lib/test/test_ttk/test_widgets.py

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

354+
def _arrow_x(self, widget, y, arrow):
355+
# Return an x coordinate on the up/down *arrow* element in row y,
356+
# found by scanning inward from the right edge of the widget. A
357+
# fixed inset such as width - 5 lands in the arrow element's
358+
# right-hand border, which scales with the display scaling, so at a
359+
# high DPI identify() there returns 'field' instead of the arrow.
360+
self.assertTrue(wait_until_mapped(widget, full_size=True))
361+
width = widget.winfo_width()
362+
for x in range(width - 1, width // 2, -1):
363+
if widget.identify(x, y).endswith(arrow):
364+
return x
365+
self.fail(f'no {arrow} found in row {y}')
366+
354367
def test_configure_invalidcommand(self):
355368
widget = self.create()
356369
self.checkCommandParam(widget, 'invalidcommand')
@@ -385,7 +398,12 @@ def test_identify(self):
385398
# Identifying the element under a point requires the widget to be
386399
# mapped with a real size; the rest of the checks do not.
387400
if wait_until_mapped(self.entry):
388-
self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS)
401+
# Probe the centre of the widget: a fixed pixel such as (5, 5)
402+
# lands in the field at a normal scaling but in the surrounding
403+
# padding at a high DPI.
404+
x = self.entry.winfo_width() // 2
405+
y = self.entry.winfo_height() // 2
406+
self.assertIn(self.entry.identify(x, y), self.IDENTIFY_AS)
389407
self.assertEqual(self.entry.identify(-1, -1), "")
390408

391409
self.assertRaises(tkinter.TclError, self.entry.identify, None, 5)
@@ -486,10 +504,11 @@ def test_configure_height(self):
486504
self.checkParams(widget, 'height', 100, 101.2, 102.6, -100, 0, '1i')
487505

488506
def _show_drop_down_listbox(self):
489-
width = self.combo.winfo_width()
490-
x, y = width - 5, 5
507+
y = 5
491508
if sys.platform != 'darwin': # there's no down arrow on macOS
492-
self.assertRegex(self.combo.identify(x, y), r'.*downarrow\z')
509+
x = self._arrow_x(self.combo, y, 'downarrow')
510+
else:
511+
x = self.combo.winfo_width() - 5
493512
self.combo.event_generate('<Button-1>', x=x, y=y)
494513
self.combo.event_generate('<ButtonRelease-1>', x=x, y=y)
495514

@@ -1062,21 +1081,15 @@ def test_tab_identifiers(self):
10621081

10631082
self.nb.pack()
10641083
self.nb.update()
1065-
if sys.platform == 'darwin':
1066-
tb_idx = "@20,5"
1067-
else:
1068-
tb_idx = "@5,5"
1069-
self.assertEqual(self.nb.tab(tb_idx), self.nb.tab('current'))
1070-
1071-
for i in range(5, 100, 5):
1072-
try:
1073-
if self.nb.tab('@%d, 5' % i, text=None) == 'a':
1074-
break
1075-
except tkinter.TclError:
1076-
pass
1084+
# Address a tab by a point taken from Tk's own geometry rather than a
1085+
# fixed pixel, which can land on a different tab or miss the tabs
1086+
# entirely (e.g. on Aqua the tabs are inset) as the display scaling
1087+
# changes.
1088+
x, y = self._tab_point(self.nb.index('current'))
1089+
self.assertEqual(self.nb.tab(f'@{x},{y}'), self.nb.tab('current'))
10771090

1078-
else:
1079-
self.fail("Tab with text 'a' not found")
1091+
x, y = self._tab_point(self.nb.index(self.child1))
1092+
self.assertEqual(self.nb.tab(f'@{x},{y}', text=None), 'a')
10801093

10811094
def test_add_and_hidden(self):
10821095
self.assertRaises(tkinter.TclError, self.nb.hide, -1)
@@ -1206,23 +1219,43 @@ def test_configure_tabs(self):
12061219

12071220
self.assertEqual(self.nb.tabs(), ())
12081221

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

1213-
self.nb.select(0)
1252+
# A mouse click selects the tab it lands on.
1253+
self.nb.select(1)
1254+
self.assertEqual(self.nb.select(), str(self.child2))
1255+
simulate_mouse_click(self.nb, *self._tab_point(0))
1256+
self.assertEqual(self.nb.select(), str(self.child1))
12141257

1215-
if sys.platform == 'darwin':
1216-
focus_identify_as = ''
1217-
elif sys.platform == 'win32':
1218-
focus_identify_as = 'focus'
1219-
else:
1220-
focus_identify_as = 'focus' if tk_version < (8, 7) else 'padding'
1221-
# identify() at (5, 5) needs the tab realized there; under focus
1222-
# contention the mapped size can lag, so wait for the full size.
1223-
if wait_until_mapped(self.nb, full_size=True):
1224-
self.assertEqual(self.nb.identify(5, 5), focus_identify_as)
1225-
simulate_mouse_click(self.nb, 5, 5)
1258+
# Control-Tab and Shift-Control-Tab traverse the tabs.
12261259
self.nb.focus_force()
12271260
self.nb.event_generate('<Control-Tab>')
12281261
self.assertEqual(self.nb.select(), str(self.child2))
@@ -1237,21 +1270,25 @@ def test_traversal(self):
12371270
self.nb.tab(self.child2, text='e', underline=0)
12381271
self.nb.enable_traversal()
12391272
self.nb.focus_force()
1240-
if wait_until_mapped(self.nb, full_size=True):
1241-
self.assertEqual(self.nb.identify(5, 5), focus_identify_as)
1242-
simulate_mouse_click(self.nb, 5, 5)
1243-
# on macOS Emacs-style keyboard shortcuts are region-dependent;
1244-
# let's use the regular arrow keys instead
1273+
1274+
# A click still selects the tab it lands on after enable_traversal().
1275+
self.nb.select(1)
1276+
self.assertEqual(self.nb.select(), str(self.child2))
1277+
simulate_mouse_click(self.nb, *self._tab_point(0))
1278+
self.assertEqual(self.nb.select(), str(self.child1))
1279+
1280+
# Mnemonics traverse the tabs (macOS uses region-dependent
1281+
# Emacs-style shortcuts, so use the regular arrow keys there).
12451282
if sys.platform == 'darwin':
12461283
begin = '<Left>'
12471284
end = '<Right>'
12481285
else:
12491286
begin = '<Alt-a>'
12501287
end = '<Alt-e>'
1251-
self.nb.event_generate(begin)
1252-
self.assertEqual(self.nb.select(), str(self.child1))
12531288
self.nb.event_generate(end)
12541289
self.assertEqual(self.nb.select(), str(self.child2))
1290+
self.nb.event_generate(begin)
1291+
self.assertEqual(self.nb.select(), str(self.child1))
12551292

12561293

12571294
@add_configure_tests(IntegerSizeTests, StandardTtkOptionsTests)
@@ -1277,22 +1314,16 @@ def create(self, **kwargs):
12771314

12781315
def _click_increment_arrow(self):
12791316
self.require_mapped(self.spin)
1280-
width = self.spin.winfo_width()
1281-
height = self.spin.winfo_height()
1282-
x = width - 5
1283-
y = height//2 - 5
1284-
self.assertRegex(self.spin.identify(x, y), r'.*uparrow\z')
1317+
y = self.spin.winfo_height()//2 - 5
1318+
x = self._arrow_x(self.spin, y, 'uparrow')
12851319
self.spin.event_generate('<ButtonPress-1>', x=x, y=y)
12861320
self.spin.event_generate('<ButtonRelease-1>', x=x, y=y)
12871321
self.spin.update_idletasks()
12881322

12891323
def _click_decrement_arrow(self):
12901324
self.require_mapped(self.spin)
1291-
width = self.spin.winfo_width()
1292-
height = self.spin.winfo_height()
1293-
x = width - 5
1294-
y = height//2 + 4
1295-
self.assertRegex(self.spin.identify(x, y), r'.*downarrow\z')
1325+
y = self.spin.winfo_height()//2 + 4
1326+
x = self._arrow_x(self.spin, y, 'downarrow')
12961327
self.spin.event_generate('<ButtonPress-1>', x=x, y=y)
12971328
self.spin.event_generate('<ButtonRelease-1>', x=x, y=y)
12981329
self.spin.update_idletasks()
@@ -2062,14 +2093,17 @@ def test_tag_bind(self):
20622093
self.tv.pack()
20632094
self.tv.update()
20642095

2096+
# Find the y coordinate of each item. Scan the whole height of the
2097+
# widget rather than a fixed pixel range, since the row height grows
2098+
# with the display scaling.
20652099
pos_y = set()
20662100
found = set()
2067-
for i in range(0, 100, 10):
2101+
for y in range(self.tv.winfo_height()):
20682102
if len(found) == 2: # item1 and item2 already found
20692103
break
2070-
item_id = self.tv.identify_row(i)
2104+
item_id = self.tv.identify_row(y)
20712105
if item_id and item_id not in found:
2072-
pos_y.add(i)
2106+
pos_y.add(y)
20732107
found.add(item_id)
20742108

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

0 commit comments

Comments
 (0)