From 758cdcaccab03c874997424ce6108a8de2a35c47 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 21 Jun 2026 01:58:02 +0300 Subject: [PATCH 1/4] gh-59396: Modernize tkinter.simpledialog Rework SimpleDialog and Dialog to match the look and feel of the native Tk dialogs ::tk_dialog and ::tk::MessageBox. * SimpleDialog is a Python port of ::tk_dialog and Dialog a base class modelled on ::tk::MessageBox. Both adopt the message-box keyboard conventions: button accelerators, a default ring that follows the keyboard focus, and a binding that invokes the focused button. * Both classes gain a use_ttk parameter that selects the classic Tk or the themed ttk widgets. It controls the widget set and the appearance that the two procedures style differently, but not the keyboard behaviour. * Update _place_window with the Tk 9.1 placement refinements. * The new helpers _temp_grab_focus (a modal grab/focus context manager), _underline_ampersand and _find_alt_key_target (ports of the Tk accelerator-key procedures) can be reused by other tkinter dialogs, as _setup_dialog already is. * Fix several defects uncovered while comparing with the Tcl sources. Co-Authored-By: Claude Opus 4.8 --- Doc/library/dialog.rst | 42 +- Doc/whatsnew/3.16.rst | 16 + Lib/test/test_tkinter/test_simpledialog.py | 491 ++++++++++++++++- Lib/tkinter/simpledialog.py | 501 +++++++++++++++--- ...6-06-20-22-55-22.gh-issue-59396.kT9wPq.rst | 22 + 5 files changed, 968 insertions(+), 104 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-20-22-55-22.gh-issue-59396.kT9wPq.rst diff --git a/Doc/library/dialog.rst b/Doc/library/dialog.rst index 952fd1f0783671..916f32a88e2db7 100644 --- a/Doc/library/dialog.rst +++ b/Doc/library/dialog.rst @@ -21,10 +21,20 @@ functions for creating simple modal dialogs to get a value from the user. The above three functions provide dialogs that prompt the user to enter a value of the desired type. + They use the themed :mod:`tkinter.ttk` widgets; pass ``use_ttk=False`` for + the classic widgets. -.. class:: Dialog(parent, title=None) +.. class:: Dialog(parent, title=None, *, use_ttk=False) The base class for custom dialogs. + When *use_ttk* is false (the default), the dialog is built from the classic + :mod:`tkinter` widgets, modelled on the classic ``tk_dialog``; when true, + from the themed :mod:`tkinter.ttk` widgets, modelled on the Tk message box. + The default is classic for compatibility, since the themed widgets set a + themed background that classic widgets added in :meth:`body` would not match. + + .. versionchanged:: next + Added the *use_ttk* parameter. .. method:: body(master) @@ -58,14 +68,32 @@ functions for creating simple modal dialogs to get a value from the user. the initial focus. -.. class:: SimpleDialog(master, text='', buttons=[], default=None, cancel=None, title=None, class_=None) +.. class:: SimpleDialog(master, text='', buttons=[], default=None, cancel=None, title=None, class_=None, *, bitmap=None, detail='', use_ttk=True) A simple modal dialog that displays the message *text* above a row of push - buttons whose labels are given by *buttons*, and returns the index of the - button the user presses. - *default* is the index of the button activated by the Return key, *cancel* - the index returned when the window is closed through the window manager, - *title* the window title, and *class_* the Tk class name of the window. + buttons given by *buttons*, and returns the index of the button the user + presses. + Each entry of *buttons* is either a button label, or a mapping of button + options such as ``{'text': 'OK', 'underline': 0}``; an ``underline`` option + makes :kbd:`Alt` plus the underlined character invoke the button. + *default* is the index of the default button, activated by the Return key + when no button has the focus, *cancel* the index returned when the window is + closed through the window manager, *title* the window title, and *class_* + the Tk class name of the window. + *bitmap* is the name of a bitmap displayed beside the message + (for example ``'warning'`` or ``'question'``); the standard names + ``'error'``, ``'info'``, ``'question'`` and ``'warning'`` are shown as + themed icons when *use_ttk* is true. + *detail* is a secondary message displayed below *text*. + When *use_ttk* is true (the default), the dialog is built from the themed + :mod:`tkinter.ttk` widgets, modelled on the Tk message box; when false, from + the classic :mod:`tkinter` widgets, modelled on ``tk_dialog``. + + .. versionchanged:: next + The dialog is now built from the themed :mod:`tkinter.ttk` widgets by + default, instead of the classic :mod:`tkinter` widgets. + Added the *bitmap*, *detail* and *use_ttk* parameters. + Entries of *buttons* may be mappings of button options. .. method:: go() diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index aa7b9b2223ec5e..26c3db27be63d1 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -157,6 +157,22 @@ tkinter synchronization of the displayed view with the underlying text. (Contributed by Serhiy Storchaka in :gh:`151675`.) +* The :mod:`tkinter.simpledialog` dialogs were modernized to match the look + and feel of the native Tk dialogs. + :class:`!tkinter.simpledialog.SimpleDialog` and the + :func:`~tkinter.simpledialog.askinteger`, + :func:`~tkinter.simpledialog.askfloat` and + :func:`~tkinter.simpledialog.askstring` dialogs are now built from the themed + :mod:`tkinter.ttk` widgets instead of the classic :mod:`tkinter` widgets; + the :class:`!tkinter.simpledialog.Dialog` base class still defaults to the + classic widgets for compatibility. Both :class:`!Dialog` and + :class:`!SimpleDialog` gained a *use_ttk* parameter that selects between the + classic Tk widgets and the themed ttk widgets. :class:`!SimpleDialog` also + gained *bitmap* and + *detail* parameters, draws the standard icons with themed images in the + ttk version, and accepts mappings of button options as *buttons* entries. + (Contributed by Serhiy Storchaka in :gh:`59396`.) + xml --- diff --git a/Lib/test/test_tkinter/test_simpledialog.py b/Lib/test/test_tkinter/test_simpledialog.py index 6cf57fde8d4c56..8817c2231b331c 100644 --- a/Lib/test/test_tkinter/test_simpledialog.py +++ b/Lib/test/test_tkinter/test_simpledialog.py @@ -1,37 +1,392 @@ import unittest import tkinter -from tkinter import messagebox +from tkinter import messagebox, ttk from test.support import requires, swap_attr from test.test_tkinter.support import setUpModule # noqa: F401 from test.test_tkinter.support import AbstractDefaultRootTest, AbstractTkTest -from tkinter.simpledialog import (Dialog, askinteger, - _QueryInteger, _QueryFloat, _QueryString) +from tkinter.simpledialog import (Dialog, SimpleDialog, + askinteger, askfloat, askstring, + _QueryInteger, _QueryFloat, _QueryString, + _underline_ampersand, _find_alt_key_target) requires('gui') -class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase): +class SimpleDialogTest(AbstractTkTest, unittest.TestCase): + # SimpleDialog's modal loop is in go(); its bindings are exercised here by + # generating events on the constructed dialog, without entering the loop. - def test_askinteger(self): - @staticmethod - def mock_wait_window(w): - nonlocal ismapped - ismapped = w.master.winfo_ismapped() - w.destroy() + def create(self, **kw): + kw.setdefault('text', 'Question?') + kw.setdefault('buttons', ['Yes', 'No']) + kw.setdefault('default', 0) + kw.setdefault('cancel', 1) + d = SimpleDialog(self.root, **kw) + self.addCleanup(lambda: d.root.winfo_exists() and d.root.destroy()) + return d - with swap_attr(Dialog, 'wait_window', mock_wait_window): - ismapped = None - askinteger("Go To Line", "Line number") - self.assertEqual(ismapped, False) + # --- Widget set and appearance --- - root = tkinter.Tk() - ismapped = None - askinteger("Go To Line", "Line number") - self.assertEqual(ismapped, True) - root.destroy() + def test_use_ttk(self): + # By default SimpleDialog uses the themed (ttk) widgets (tk::MessageBox). + d = self.create(buttons=['OK'], bitmap='warning') + self.assertEqual(d._buttons[0].winfo_class(), 'TButton') + self.assertEqual(d.message.winfo_class(), 'TLabel') + self.assertEqual(str(d.message.cget('anchor')), 'nw') # cf. MessageBox + # The standard icons are drawn with themed images (cf. MessageBox). + self.assertEqual(d.bitmap.winfo_class(), 'TLabel') + self.assertIn('::tk::icons::warning', str(d.bitmap.cget('image'))) + # tk::MessageBox makes the buttons equal width. + self.assertEqual( + str(d.root.children['bot'].grid_columnconfigure(0)['uniform']), + 'buttons') + # The dialog uses the themed background colour (cf. MessageBox). + self.assertEqual(str(d.root.cget('background')), + ttk.Style(d.root).lookup('.', 'background')) + # The bindings work with the themed buttons too. + d._buttons[0].focus_force() + d.root.update() + d.root.event_generate('') + d.root.update() + self.assertEqual(d.num, 0) - tkinter.NoDefaultRoot() - self.assertRaises(RuntimeError, askinteger, "Go To Line", "Line number") + def test_use_classic(self): + # use_ttk=False uses the classic Tk widgets, modelled on tk_dialog. + d = self.create(buttons=['OK'], bitmap='warning', use_ttk=False) + self.assertEqual(d._buttons[0].winfo_class(), 'Button') + self.assertEqual(d.message.winfo_class(), 'Label') + if d.root._windowingsystem == 'x11': + self.assertEqual(str(d.frame.cget('relief')), 'raised') + # tk_dialog does not make the buttons equal width. + self.assertIsNone(d.root.children['bot'].grid_columnconfigure(0)['uniform']) + # The bitmap is a classic monochrome label. + self.assertEqual(str(d.bitmap.cget('bitmap')), 'warning') + + def test_class_name(self): + # class_ sets the Tk class of the dialog window (default 'Dialog'). + self.assertEqual(self.create().root.winfo_class(), 'Dialog') + d = self.create(class_='MyDialog') + self.assertEqual(d.root.winfo_class(), 'MyDialog') + + # --- Message, detail and bitmap content --- + + def test_no_detail(self): + # Without a detail message the message label expands. + d = self.create() + self.assertIsNone(d.detail) + self.assertEqual(int(d.message.pack_info()['expand']), 1) + + def test_detail(self): + # The detail message is shown below the main message. + d = self.create(detail='More information.') + self.assertEqual(d.detail.winfo_class(), 'TLabel') + self.assertEqual(str(d.detail.cget('text')), 'More information.') + self.assertEqual(str(d.detail.cget('anchor')), 'nw') # cf. MessageBox + # With a detail message it expands and the main message does not. + self.assertEqual(int(d.message.pack_info()['expand']), 0) + self.assertEqual(int(d.detail.pack_info()['expand']), 1) + + def test_bitmap_fallback(self): + # A non-standard bitmap has no themed image, so even the ttk version + # falls back to a classic bitmap label. + d = self.create(buttons=['OK'], bitmap='questhead') + self.assertEqual(d.bitmap.winfo_class(), 'Label') + self.assertEqual(str(d.bitmap.cget('bitmap')), 'questhead') + + def test_bitmap_detail_layout(self): + # The bitmap is packed first to claim the whole left side; the message + # and detail stack on its right, as in tk::MessageBox. + d = self.create(buttons=['OK'], bitmap='warning', detail='More.') + top = d.root.children['top'] + layout = [(w.winfo_name(), str(w.pack_info()['side'])) + for w in top.pack_slaves()] + self.assertEqual(layout, + [('bitmap', 'left'), ('msg', 'top'), ('dtl', 'top')]) + + # --- Buttons and keyboard --- + + def test_button_options(self): + # A button entry can be a mapping of options, not just a label (like + # the "[name ?-option value ...?]" button specs in tk::MessageBox). + d = self.create(buttons=['Yes', + {'text': 'No', 'underline': 0, 'width': 12}]) + yes, no = d._buttons + self.assertEqual(str(yes.cget('text')), 'Yes') + self.assertEqual(str(no.cget('text')), 'No') + self.assertEqual(int(no.cget('underline')), 0) + self.assertEqual(str(no.cget('width')), '12') + # The dialog still controls the default ring (default=0) ... + self.assertEqual(str(yes.cget('default')), 'active') + self.assertEqual(str(no.cget('default')), 'normal') + # ... and the command, which records the button index. + no.invoke() + self.assertEqual(d.num, 1) + + def test_default_ring(self): + # The default ring follows the keyboard focus among the buttons + # (cf. tk::MessageBox). + d = self.create() # buttons ['Yes', 'No'], default 0 + b0, b1 = d._buttons + self.assertEqual(str(b1.cget('default')), 'normal') + b1.focus_force() + d.root.update() + self.assertEqual(str(b1.cget('default')), 'active') # focused -> ring + b0.focus_force() + d.root.update() + self.assertEqual(str(b1.cget('default')), 'normal') # unfocused -> none + + def test_alt_key(self): + # Alt + an underlined character (the "underline" button option) invokes + # the matching button (cf. tk::AmpWidget in tk::MessageBox). + d = self.create(buttons=['Yes', {'text': 'No', 'underline': 0}]) + d._buttons[0].focus_force() + d.root.update() + d.root.event_generate('') # "No" -> underline 0 -> "N" + d.root.update() + self.assertEqual(d.num, 1) + + def test_return_invokes_focused_button(self): + # invokes the button with the focus, even if it is not the + # default and the focus was not moved by keyboard traversal. + d = self.create(buttons=['Yes', 'No']) # default 0 + d._buttons[1].focus_force() + d.root.update() + d.root.event_generate('') + d.root.update() + self.assertEqual(d.num, 1) + + def test_focus_next_then_return(self): + # moves the focus to the next button; invokes it. + d = self.create(buttons=['Yes', 'No']) + d._buttons[0].focus_force() + d.root.update() + d._buttons[0].event_generate('') + d.root.update() + d.root.event_generate('') + d.root.update() + self.assertEqual(d.num, 1) + + def test_focus_prev_then_return(self): + # moves the focus to the previous button. + d = self.create(buttons=['Yes', 'No']) + d._buttons[1].focus_force() + d.root.update() + d._buttons[1].event_generate('') + d.root.update() + d.root.event_generate('') + d.root.update() + self.assertEqual(d.num, 0) + + def test_return_activates_default(self): + # with the focus off the buttons invokes the default button. + d = self.create() # default 0 + d.root.focus_force() # the dialog, not a button, has the focus + d.root.update() + d.root.event_generate('') + d.root.update() + self.assertEqual(d.num, 0) + + def test_return_no_default(self): + # With no default button, off the buttons rings the bell and + # leaves the dialog open instead of activating a button. + d = self.create(default=None) + d.root.focus_force() # the dialog, not a button, has the focus + d.root.update() + bells = [] + with swap_attr(d.root, 'bell', lambda *a, **k: bells.append(True)): + d.root.event_generate('') + d.root.update() + self.assertTrue(bells) # rang the bell + self.assertIsNone(d.num) + self.assertTrue(d.root.winfo_exists()) + + # --- Modal lifecycle --- + + def test_destroy_cancels(self): + # Destroying the window records the cancel index. + d = self.create() + d.root.update() + d.root.destroy() + self.assertEqual(d.num, 1) + + def test_wm_delete_cancels(self): + # Closing the window through the window manager records the cancel index. + d = self.create() # cancel 1 + d.wm_delete_window() + self.assertEqual(d.num, 1) + + def test_wm_delete_no_cancel(self): + # With no cancel index, closing the window through the window manager + # rings the bell and leaves the dialog open instead of recording an + # index. + d = self.create(default=None, cancel=None) + d.root.update() + bells = [] + with swap_attr(d.root, 'bell', lambda *a, **k: bells.append(True)): + d.wm_delete_window() + d.root.update() + self.assertTrue(bells) # rang the bell + self.assertIsNone(d.num) + self.assertTrue(d.root.winfo_exists()) + + def test_go(self): + # go() runs the modal loop and returns the chosen button's index. + d = self.create() + d.root.after(1, lambda: d._buttons[0].invoke()) + self.assertEqual(d.go(), 0) + + +class DialogTest(AbstractTkTest, unittest.TestCase): + # Dialog's button box is modelled on tk::MessageBox. + + def open(self, **kw): + with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)): + d = _QueryInteger('Title', 'Prompt', parent=self.root, **kw) + self.addCleanup(lambda: d.winfo_exists() and d.destroy()) + return d + + # --- Widget set and appearance --- + + def test_use_ttk(self): + # The query dialogs use the themed (ttk) widgets by default. + d = self.open() + self.assertEqual(d.children['ok'].winfo_class(), 'TButton') + self.assertEqual(d.entry.winfo_class(), 'TEntry') + # tk::MessageBox makes the buttons equal width. + self.assertEqual( + str(d.children['bot'].grid_columnconfigure(0)['uniform']), 'buttons') + + def test_use_classic(self): + # use_ttk=False uses the classic Tk widgets, modelled on tk_dialog. + d = self.open(use_ttk=False) + self.assertEqual(d.children['ok'].winfo_class(), 'Button') + self.assertEqual(d.entry.winfo_class(), 'Entry') + if d._windowingsystem == 'x11': + self.assertEqual(str(d.children['bot'].cget('relief')), 'raised') + # tk_dialog does not make the buttons equal width. + self.assertIsNone(d.children['bot'].grid_columnconfigure(0)['uniform']) + # The bindings work with the classic buttons too. + invoked = [] + cancel = d.children['cancel'] + cancel.configure(command=lambda: invoked.append(True)) + cancel.focus_force() + d.update() + d.event_generate('') + d.update() + self.assertTrue(invoked) + + def test_background(self): + d = self.open() + self.assertEqual(str(d.cget('background')), + ttk.Style(d).lookup('.', 'background')) + + def test_base_classic_by_default(self): + # The Dialog base defaults to classic widgets so that subclasses adding + # classic widgets keep their look; only the query dialogs opt into ttk. + class MyDialog(Dialog): + def body(self, master): + pass + with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)): + d = MyDialog(self.root, 'Title') + self.addCleanup(lambda: d.winfo_exists() and d.destroy()) + self.assertEqual(d.children['ok'].winfo_class(), 'Button') + + # --- Buttons and keyboard --- + + def test_button_default(self): + d = self.open() + self.assertEqual(str(d.children['ok'].cget('default')), 'active') + self.assertEqual(str(d.children['cancel'].cget('default')), 'normal') + + def test_underline_ampersand(self): + self.assertEqual(_underline_ampersand('Yes'), ('Yes', -1)) + self.assertEqual(_underline_ampersand('&Yes'), ('Yes', 0)) + self.assertEqual(_underline_ampersand('Save &As'), ('Save As', 5)) + self.assertEqual(_underline_ampersand('A&&B'), ('A&B', -1)) + self.assertEqual(_underline_ampersand('&a&b'), ('ab', 0)) + + def test_button_accelerator(self): + # The buttons' "&" accelerators are parsed (cf. tk::AmpWidget). + d = self.open() + ok = d.children['ok'] # "&OK" -> underline 0 -> "O" + self.assertEqual(str(ok.cget('text')), 'OK') + self.assertEqual(int(ok.cget('underline')), 0) + + def test_default_ring(self): + # The default ring follows the keyboard focus among the buttons. + d = self.open() + cancel = d.children['cancel'] + self.assertEqual(str(cancel.cget('default')), 'normal') + cancel.focus_force() + d.update() + self.assertEqual(str(cancel.cget('default')), 'active') + d.children['ok'].focus_force() + d.update() + self.assertEqual(str(cancel.cget('default')), 'normal') + + def test_find_alt_key_target(self): + d = self.open() + ok = d.children['ok'] # "&OK" -> "O" + cancel = d.children['cancel'] # "&Cancel" -> "C" + self.assertIs(_find_alt_key_target(d, 'o'), ok) + self.assertIs(_find_alt_key_target(d, 'O'), ok) # case-insensitive + self.assertIs(_find_alt_key_target(d, 'c'), cancel) + self.assertIsNone(_find_alt_key_target(d, 'q')) + + def test_alt_key(self): + # The accelerator key (Alt + the underlined letter) invokes the button: + # -> _alt_key -> the button's <> -> invoke. + d = self.open() + invoked = [] + cancel = d.children['cancel'] # "&Cancel" + cancel.configure(command=lambda: invoked.append(True)) + d.focus_force() + d.update() + d.event_generate('') + d.update() + self.assertTrue(invoked) + + def test_return_invokes_focused_button(self): + # invokes the focused button. + d = self.open() + invoked = [] + cancel = d.children['cancel'] + cancel.configure(command=lambda: invoked.append(True)) + cancel.focus_force() + d.update() + d.event_generate('') + d.update() + self.assertEqual(invoked, [True]) + + def test_focus_next_then_return(self): + # moves the focus to the next button; invokes it. + d = self.open() + invoked = [] + for name in ('ok', 'cancel'): + d.children[name].configure(command=lambda name=name: invoked.append(name)) + ok = d.children['ok'] + ok.focus_force() + d.update() + ok.event_generate('') # OK -> Cancel + d.update() + d.event_generate('') + d.update() + self.assertEqual(invoked, ['cancel']) + + def test_focus_prev_then_return(self): + # moves the focus to the previous button. + d = self.open() + invoked = [] + for name in ('ok', 'cancel'): + d.children[name].configure(command=lambda name=name: invoked.append(name)) + cancel = d.children['cancel'] + cancel.focus_force() + d.update() + cancel.event_generate('') # Cancel -> OK + d.update() + d.event_generate('') + d.update() + self.assertEqual(invoked, ['ok']) class QueryDialogTest(AbstractTkTest, unittest.TestCase): @@ -41,7 +396,7 @@ class QueryDialogTest(AbstractTkTest, unittest.TestCase): def open(self, query, **kw): with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)): - d = query("Title", "Prompt", parent=self.root, **kw) + d = query('Title', 'Prompt', parent=self.root, **kw) self.addCleanup(lambda: d.winfo_exists() and d.destroy()) d.focus_force() d.update() @@ -53,6 +408,37 @@ def enter(self, d, value, key=''): d.event_generate(key) d.update() + # --- Prompt and entry --- + + def test_prompt_wraplength(self): + # A long prompt wraps instead of widening the dialog (cf. MessageBox). + d = self.open(_QueryInteger) + body = d.children['top'] + label = [w for w in body.winfo_children() if w is not d.entry][0] + self.assertEqual(str(label.cget('wraplength')), '3i') + + def test_prompt_column_expands(self): + # The prompt and entry column expands to the full width, like the + # weighted message column in tk::MessageBox. + d = self.open(_QueryInteger) + body = d.children['top'] + self.assertEqual(body.grid_columnconfigure(0)['weight'], 1) + + def test_initialvalue(self): + # The entry is pre-filled with the initial value, which is accepted. + d = self.open(_QueryInteger, initialvalue=42) + self.assertEqual(d.entry.get(), '42') + d.event_generate('') + d.update() + self.assertEqual(d.result, 42) + + def test_show(self): + # _QueryString hides the entered text when show is given. + d = self.open(_QueryString, show='*') + self.assertEqual(str(d.entry.cget('show')), '*') + + # --- Accept and cancel --- + def test_return_accepts(self): for query, value, expected in [ (_QueryInteger, '42', 42), @@ -71,6 +457,8 @@ def test_escape_cancels(self): self.assertIsNone(d.result) self.assertFalse(d.winfo_exists()) + # --- Validation --- + def test_invalid_value(self): warnings = [] d = self.open(_QueryInteger) @@ -93,6 +481,63 @@ def test_out_of_range(self): self.assertTrue(d.winfo_exists()) self.assertEqual(len(warnings), 2) + def test_boundary_values_accepted(self): + # The min/max checks are inclusive: a value equal to a bound passes. + d = self.open(_QueryInteger, minvalue=10, maxvalue=20) + self.enter(d, '10') # Exactly the minimum. + self.assertEqual(d.result, 10) + self.assertFalse(d.winfo_exists()) + + d = self.open(_QueryInteger, minvalue=10, maxvalue=20) + self.enter(d, '20') # Exactly the maximum. + self.assertEqual(d.result, 20) + self.assertFalse(d.winfo_exists()) + + # --- Convenience ask* functions --- + + def run_ask(self, ask, value, **kw): + # Drive a modal ask* function: enter a value and accept it. + def accept(d): + d.entry.delete(0, 'end') + d.entry.insert(0, value) + d.ok() + with swap_attr(Dialog, 'wait_window', staticmethod(accept)): + return ask('Title', 'Prompt', parent=self.root, **kw) + + def test_ask_functions(self): + self.assertEqual(self.run_ask(askinteger, '42'), 42) + self.assertEqual(self.run_ask(askfloat, '1.5'), 1.5) + self.assertEqual(self.run_ask(askstring, 'spam'), 'spam') + + def test_ask_cancelled(self): + # A cancelled ask* returns None. + with swap_attr(Dialog, 'wait_window', staticmethod(lambda d: d.cancel())): + self.assertIsNone(askstring('Title', 'Prompt', parent=self.root)) + + +class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase): + + def test_askinteger(self): + @staticmethod + def mock_wait_window(w): + nonlocal ismapped + ismapped = w.master.winfo_ismapped() + w.destroy() + + with swap_attr(Dialog, 'wait_window', mock_wait_window): + ismapped = None + askinteger('Go To Line', 'Line number') + self.assertEqual(ismapped, False) + + root = tkinter.Tk() + ismapped = None + askinteger('Go To Line', 'Line number') + self.assertEqual(ismapped, True) + root.destroy() + + tkinter.NoDefaultRoot() + self.assertRaises(RuntimeError, askinteger, 'Go To Line', 'Line number') + -if __name__ == "__main__": +if __name__ == '__main__': unittest.main() diff --git a/Lib/tkinter/simpledialog.py b/Lib/tkinter/simpledialog.py index 4f9eb44f034677..1a5ecf163ef683 100644 --- a/Lib/tkinter/simpledialog.py +++ b/Lib/tkinter/simpledialog.py @@ -23,53 +23,202 @@ askstring -- get a string from the user """ -from tkinter import Button, Entry, Frame, Label, Message, Tk, Toplevel +import tkinter +from tkinter import Button, Label, Tk, Toplevel, TclError from tkinter import _get_temp_root, _destroy_temp_root from tkinter import messagebox -from tkinter.constants import ACTIVE, BOTH, END, LEFT, RIDGE, W, E +from tkinter import ttk +from tkinter.constants import * +import contextlib __all__ = ["SimpleDialog", "Dialog", "askinteger", "askfloat", "askstring"] +# The standard dialog icons, which tk::MessageBox draws with themed images +# instead of the classic monochrome bitmaps. +_ICON_IMAGES = { + 'error': '::tk::icons::error', + 'info': '::tk::icons::information', + 'question': '::tk::icons::question', + 'warning': '::tk::icons::warning', +} + + +# Based on the Tk ::tk_dialog procedure, themed like ::tk::MessageBox by +# default. class SimpleDialog: + def _widget(self, klass, master, **kw): + # Create a themed (ttk) or classic (tkinter) widget. + return getattr(ttk if self.use_ttk else tkinter, klass)(master, **kw) + def __init__(self, master, text='', buttons=[], default=None, cancel=None, - title=None, class_=None): - if class_: - self.root = Toplevel(master, class_=class_) - else: - self.root = Toplevel(master) - if title: - self.root.title(title) - self.root.iconname(title) - + title=None, class_=None, *, bitmap=None, detail='', + use_ttk=True): + # Use the themed (ttk) widgets (modelled on tk::MessageBox) by default, + # or the classic Tk widgets (tk_dialog) if use_ttk is false. + self.use_ttk = use_ttk + + # 1. Create the top-level window and divide it into top + # and bottom parts. + + class_ = class_ or 'Dialog' + self.root = Toplevel(master, class_=class_) + # The default value of the title is space (" ") not the empty string + # because for some window managers, a + # w.title("") + # causes the window title to be w._name instead of the empty string. + self.root.title(title or ' ') + self.root.iconname(class_) + toplevel = master.winfo_toplevel() + if toplevel.winfo_viewable(): + self.root.wm_transient(toplevel) _setup_dialog(self.root) - - self.message = Message(self.root, text=text, aspect=400) - self.message.pack(expand=1, fill=BOTH) - self.frame = Frame(self.root) - self.frame.pack() + if self.use_ttk: + self.root.configure( + background=ttk.Style(self.root).lookup('.', 'background')) + + bot = self._widget('Frame', self.root, name='bot') + top = self._widget('Frame', self.root, name='top') + # The classic dialog (tk_dialog) gives its frames a raised border on + # X11; the themed one (tk::MessageBox) does not. + if not self.use_ttk and self.root._windowingsystem == 'x11': + bot.configure(relief=RAISED, bd=1) + top.configure(relief=RAISED, bd=1) + bot.pack(side=BOTTOM, fill=BOTH) + top.pack(side=TOP, fill=BOTH, expand=1) + bot.grid_anchor(CENTER) + + # 2. Fill the top part with bitmap and message (use the option + # database for -wraplength and -font so that they can be + # overridden by the caller). + + master.option_add(f'*{class_}.msg.wrapLength', '3i', 'widgetDefault') + master.option_add(f'*{class_}.msg.font', 'TkCaptionFont', 'widgetDefault') + master.option_add(f'*{class_}.dtl.wrapLength', '3i', 'widgetDefault') + master.option_add(f'*{class_}.dtl.font', 'TkDefaultFont', 'widgetDefault') + + # tk::MessageBox and tk_dialog pad the top part differently. + pad = '2m' if self.use_ttk else '3m' + + # The bitmap is packed first to claim the whole left side; the message + # and detail stack on its right, as in tk::MessageBox. + if bitmap: + if self.root._windowingsystem == 'aqua' and bitmap == 'error': + bitmap = 'stop' + image = _ICON_IMAGES.get(bitmap) if self.use_ttk else None + if image is not None and self.root.winfo_depth() >= 4: + # tk::MessageBox draws the standard icons with themed images. + self.bitmap = ttk.Label(self.root, name='bitmap', image=image) + else: + # ttk.Label has no -bitmap option, so use a classic label. + self.bitmap = Label(self.root, name='bitmap', bitmap=bitmap) + # The themed dialog anchors the icon to the top (like the bitmap's + # "nw" sticky in tk::MessageBox); the classic one centers it. + anchor = N if self.use_ttk else CENTER + self.bitmap.pack(in_=top, side=LEFT, anchor=anchor, + padx=pad, pady=pad) + + self.message = self._widget('Label', self.root, name='msg', + justify=LEFT, text=text) + self.detail = None + if self.use_ttk: + # tk::MessageBox anchors the message to the top-left corner. + self.message.configure(anchor=NW) + # The message expands to fill the space, unless there is a detail + # message below it which takes the extra space instead (cf. + # tk::MessageBox). + self.message.pack(in_=top, side=TOP, expand=not detail, fill=BOTH, + padx=pad, pady=pad) + if detail: + self.detail = self._widget('Label', self.root, name='dtl', + justify=LEFT, text=detail) + if self.use_ttk: + self.detail.configure(anchor=NW) + self.detail.pack(in_=top, side=TOP, expand=1, fill=BOTH, + padx=pad, pady=(0, pad)) + + self.frame = bot self.num = default self.cancel = cancel self.default = default - self.root.bind('', self.return_event) - for num in range(len(buttons)): - s = buttons[num] - b = Button(self.frame, text=s, - command=(lambda self=self, num=num: self.done(num))) - if num == default: - b.config(relief=RIDGE, borderwidth=8) - b.pack(side=LEFT, fill=BOTH, expand=1) + + # 3. Create a row of buttons at the bottom of the dialog. Each entry + # of "buttons" is either a label, or a mapping of button options -- like + # the "[name ?-option value ...?]" button specs in tk::MessageBox. + + # tk::MessageBox and tk_dialog space the buttons differently. + padx, pady = ('3m', '2m') if self.use_ttk else ('7.5p', '3p') + self._buttons = [] + for i, but in enumerate(buttons): + opts = {'text': but} if isinstance(but, str) else dict(but) + b = self._widget('Button', self.root, name=f'button{i}', **opts) + # The dialog controls the command and the default ring, overriding + # anything set in the button options (cf. tk::MessageBox). + b.configure(command=(lambda self=self, i=i: self.done(i)), + default=ACTIVE if i == default else NORMAL) + # Alt + the underlined character (an "underline" button option) + # invokes the button (cf. tk::AmpWidget in tk::MessageBox). + b.bind('<>', lambda e: e.widget.invoke()) + b.grid(in_=bot, column=i, row=0, sticky=EW, padx=padx, pady=pady) + # tk::MessageBox makes the buttons equal width; tk_dialog does not. + bot.grid_columnconfigure(i, uniform='buttons' if self.use_ttk else '') + # We boost the size of some Mac buttons for l&f + if self.root._windowingsystem == 'aqua': + if str(opts.get('text', '')).lower() in ('ok', 'cancel'): + bot.grid_columnconfigure(i, minsize=90) + b.grid_configure(pady=7) + self._buttons.append(b) + + # 4. Bind to invoke the focused button, or the default button + # if the focus is elsewhere. Unlike tk_dialog (which tracks the focus + # by rebinding on <>/<>), this reads + # the live focus, like tk::MessageBox, so it also works with the mouse. + + def on_return(event): + if event.widget.winfo_class() in ('Button', 'TButton'): + event.widget.invoke() + else: + self.return_event(event) + self.root.bind('', on_return) + # Alt + an underlined character invokes the matching button (cf. + # ::tk::AltKeyInDialog, bound by tk::MessageBox). + self.root.bind('', self._alt_key) + # The default ring follows the keyboard focus among the buttons + # (cf. tk::MessageBox). + self.root.bind('', lambda e: self._set_default(e.widget, ACTIVE)) + self.root.bind('', lambda e: self._set_default(e.widget, NORMAL)) + + # 5. Bind to record the cancel index, in case the window is + # destroyed by something else (e.g. its parent being destroyed). + + def on_destroy(event): + self.num = cancel + self.root.quit() + self.root.bind('', on_destroy) + self.root.protocol('WM_DELETE_WINDOW', self.wm_delete_window) - self.root.transient(master) _place_window(self.root, master) def go(self): self.root.wait_visibility() - self.root.grab_set() - self.root.mainloop() - self.root.destroy() + if self.default is not None: + focus = self._buttons[self.default] + else: + focus = self.root + + with _temp_grab_focus(self.root, focus): + try: + self.root.mainloop() + finally: + try: + # It's possible that the window has already been destroyed, + # hence this "try/except". Delete the Destroy handler so that + # self.num doesn't get reset by it. + self.root.bind('', '') + except TclError: + pass return self.num def return_event(self, event): @@ -88,7 +237,20 @@ def done(self, num): self.num = num self.root.quit() + def _alt_key(self, event): + # Invoke the button whose accelerator matches the Alt key. + target = _find_alt_key_target(self.root, event.char) + if target is not None: + target.event_generate('<>') + def _set_default(self, widget, state): + # Set a button's default ring. + if widget.winfo_class() in ('Button', 'TButton'): + widget.configure(default=state) + + +# A base class for custom dialogs, with a button box modelled on +# ::tk::MessageBox. class Dialog(Toplevel): '''Class to open dialogs. @@ -96,7 +258,19 @@ class Dialog(Toplevel): This class is intended as a base class for custom dialogs ''' - def __init__(self, parent, title = None): + def _widget(self, klass, master, **kw): + # Create a themed (ttk) or classic (tkinter) widget. + return getattr(ttk if self.use_ttk else tkinter, klass)(master, **kw) + + def _frame(self, name): + # The classic dialog (tk_dialog) gives its frames a raised border on + # X11; the themed one (tk::MessageBox) does not. + frame = self._widget('Frame', self, name=name) + if not self.use_ttk and self._windowingsystem == 'x11': + frame.configure(relief=RAISED, bd=1) + return frame + + def __init__(self, parent, title=None, *, use_ttk=False): '''Initialize a dialog. Arguments: @@ -104,12 +278,26 @@ def __init__(self, parent, title = None): parent -- a parent window (the application window) title -- the dialog title + + use_ttk -- use the classic Tk widgets (the default), or the themed + (ttk) widgets if true ''' + # Use the classic Tk widgets by default, for compatibility: the themed + # (ttk) widgets set a themed background that classic widgets added by a + # subclass in body() would not match. The query dialogs opt into ttk. + self.use_ttk = use_ttk + master = parent if master is None: master = _get_temp_root() - Toplevel.__init__(self, master) + Toplevel.__init__(self, master, class_='Dialog') + + if self.use_ttk: + # Use a single background colour for the whole dialog so that it + # blends with the ttk widgets (cf. tk::MessageBox). + self.configure( + background=ttk.Style(self).lookup('.', 'background')) self.withdraw() # remain invisible for now # If the parent is not viewable, don't @@ -118,8 +306,8 @@ def __init__(self, parent, title = None): if parent is not None and parent.winfo_viewable(): self.transient(parent) - if title: - self.title(title) + self.title(title or ' ') + self.iconname('Dialog') _setup_dialog(self) @@ -127,9 +315,9 @@ def __init__(self, parent, title = None): self.result = None - body = Frame(self) + body = self._frame('top') self.initial_focus = self.body(body) - body.pack(padx=5, pady=5) + body.pack(side=TOP, fill=BOTH, expand=1) self.buttonbox() @@ -140,12 +328,12 @@ def __init__(self, parent, title = None): _place_window(self, parent) - self.initial_focus.focus_set() - # wait for window to appear on screen before calling grab_set self.wait_visibility() - self.grab_set() - self.wait_window(self) + # Dialog destroys itself in ok()/cancel(), so let _temp_grab_focus + # save/restore the focus and grab without destroying the window. + with _temp_grab_focus(self, self.initial_focus, destroy=False): + self.wait_window(self) def destroy(self): '''Destroy the window''' @@ -171,17 +359,58 @@ def buttonbox(self): override if you do not want the standard buttons ''' - box = Frame(self) - - w = Button(box, text="OK", width=10, command=self.ok, default=ACTIVE) - w.pack(side=LEFT, padx=5, pady=5) - w = Button(box, text="Cancel", width=10, command=self.cancel) - w.pack(side=LEFT, padx=5, pady=5) - - self.bind("", self.ok) - self.bind("", self.cancel) + box = self._frame('bot') + + # tk::MessageBox and tk_dialog space the buttons differently. + padx, pady = ('3m', '2m') if self.use_ttk else ('7.5p', '3p') + for i, (name, label, command) in enumerate( + (('ok', '&OK', self.ok), ('cancel', '&Cancel', self.cancel))): + # Create a button with an accelerator key marked by "&" in the text + # (cf. tk::AmpWidget). + text, underline = _underline_ampersand(label) + b = self._widget('Button', self, name=name, text=text, + underline=underline, command=command, + default=ACTIVE if name == 'ok' else NORMAL) + b.bind('<>', lambda e: e.widget.invoke()) + b.grid(in_=box, column=i, row=0, sticky=EW, padx=padx, pady=pady) + # tk::MessageBox makes the buttons equal width; tk_dialog does not. + box.grid_columnconfigure(i, uniform='buttons' if self.use_ttk else '') + if self._windowingsystem == 'aqua': + box.grid_columnconfigure(i, minsize=90) + b.grid_configure(pady=7) + + # Alt + an underlined character invokes the matching button (cf. + # ::tk::AltKeyInDialog, bound by tk::MessageBox). + self.bind('', self._alt_key) + # The default ring follows the keyboard focus among the buttons + # (cf. tk::MessageBox). + self.bind('', lambda e: self._set_default(e.widget, ACTIVE)) + self.bind('', lambda e: self._set_default(e.widget, NORMAL)) + self.bind('', self._return_event) + self.bind('', self.cancel) + + box.pack(side=BOTTOM, fill=BOTH) + box.grid_anchor(CENTER) + + def _set_default(self, widget, state): + # Set a button's default ring. + if widget.winfo_class() in ('Button', 'TButton'): + widget.configure(default=state) + + def _return_event(self, event): + # Invoke the focused button, or accept the dialog if the focus is + # elsewhere (e.g. in an entry). + widget = event.widget + if widget.winfo_class() in ('Button', 'TButton'): + widget.invoke() + else: + self.ok() - box.pack() + def _alt_key(self, event): + # Invoke the button whose accelerator matches the Alt key. + target = _find_alt_key_target(self, event.char) + if target is not None: + target.event_generate('<>') # # standard button semantics @@ -201,10 +430,6 @@ def ok(self, event=None): self.cancel() def cancel(self, event=None): - - # put focus back to the parent window - if self.parent is not None: - self.parent.focus_set() self.destroy() # @@ -217,7 +442,7 @@ def validate(self): dialog is destroyed. By default, it always validates OK. ''' - return 1 # override + return True # override def apply(self): '''process the data @@ -230,40 +455,114 @@ def apply(self): # Place a toplevel window at the center of parent or screen -# It is a Python implementation of ::tk::PlaceWindow. +# This is a Python implementation of ::tk::PlaceWindow. +def _wm_dimension(w, command): + # tk::WMFrameWidth and tk::WMTitleHeight (added in Tk 9.1) return the size + # of the window manager decoration. They are 0 except in SDL2 builds of + # Tk, and are missing in older versions. + try: + return int(w.tk.call(command)) + except TclError: + return 0 + + def _place_window(w, parent=None): w.wm_withdraw() # Remain invisible while we figure out the geometry w.update_idletasks() # Actualize geometry information + screenwidth = w.winfo_screenwidth() + screenheight = w.winfo_screenheight() minwidth = w.winfo_reqwidth() minheight = w.winfo_reqheight() maxwidth = w.winfo_vrootwidth() maxheight = w.winfo_vrootheight() + # "wm geometry" operates in window manager coordinates and thus includes a + # possible decoration frame and the title bar. + framewidth = _wm_dimension(w, '::tk::WMFrameWidth') + titleheight = _wm_dimension(w, '::tk::WMTitleHeight') + constrain = False + if minwidth + 2*framewidth > screenwidth: + minwidth = screenwidth - 2*framewidth + constrain = True + if minheight + titleheight + framewidth > screenheight: + minheight = screenheight - titleheight - framewidth + constrain = True + if parent is not None and parent.winfo_ismapped(): + # Center the window over the parent (which must be mapped). x = parent.winfo_rootx() + (parent.winfo_width() - minwidth) // 2 y = parent.winfo_rooty() + (parent.winfo_height() - minheight) // 2 + # Make sure that the window is on the screen and does not cover the + # window manager decoration. vrootx = w.winfo_vrootx() vrooty = w.winfo_vrooty() - x = min(x, vrootx + maxwidth - minwidth) - x = max(x, vrootx) - y = min(y, vrooty + maxheight - minheight) - y = max(y, vrooty) + x = min(x, vrootx + maxwidth - minwidth - framewidth) + x = max(x, vrootx + framewidth) + y = min(y, vrooty + maxheight - minheight - framewidth) + y = max(y, vrooty + titleheight) if w._windowingsystem == 'aqua': # Avoid the native menu bar which sits on top of everything. - y = max(y, 22) + y = max(y, 22 + titleheight) else: - x = (w.winfo_screenwidth() - minwidth) // 2 - y = (w.winfo_screenheight() - minheight) // 2 + # Center the window on the screen. + x = (screenwidth - minwidth) // 2 + y = (screenheight - minheight) // 2 w.wm_maxsize(maxwidth, maxheight) - w.wm_geometry('+%d+%d' % (x, y)) + geometry = f'{minwidth}x{minheight}' if constrain else '' + geometry += '+%d+%d' % (x - framewidth, y - titleheight) + w.wm_geometry(geometry) w.wm_deiconify() # Become visible at the desired location +def _underline_ampersand(text): + # Like tk::UnderlineAmpersand: "&&" is a literal "&"; a single "&" marks + # the following character as the underlined accelerator. Return the text + # without the markers and the index of the accelerator (-1 if none). + chars = [] + underline = -1 + i = 0 + while i < len(text): + if text[i] == '&': + if text[i+1:i+2] == '&': + chars.append('&') + i += 2 + continue + if underline < 0: + underline = len(chars) + else: + chars.append(text[i]) + i += 1 + return ''.join(chars), underline + + +def _find_alt_key_target(widget, char): + # Like tk::FindAltKeyTarget: find the widget whose underlined character + # matches CHAR, searching the widget and its descendants. + if widget.winfo_class() in ('Button', 'Checkbutton', 'Label', 'Radiobutton', + 'TButton', 'TCheckbutton', 'TLabel', + 'TRadiobutton'): + try: + under = int(widget.cget('underline')) + except (TclError, ValueError): + under = -1 + text = str(widget.cget('text')) + if 0 <= under < len(text) and char.lower() == text[under].lower(): + return widget + for child in widget.winfo_children(): + target = _find_alt_key_target(child, char) + if target is not None: + return target + return None + + def _setup_dialog(w): if w._windowingsystem == "aqua": - w.tk.call("::tk::unsupported::MacWindowStyle", "style", - w, "moveableModal", "") + if w.info_patchlevel() >= (9, 1): + w.wm_attributes(stylemask='titled') + else: + w.tk.call('::tk::unsupported::MacWindowStyle', 'style', + w, 'moveableModal', '') elif w._windowingsystem == "x11": w.wm_attributes(type="dialog") @@ -275,7 +574,7 @@ class _QueryDialog(Dialog): def __init__(self, title, prompt, initialvalue=None, minvalue = None, maxvalue = None, - parent = None): + parent = None, *, use_ttk=True): self.prompt = prompt self.minvalue = minvalue @@ -283,7 +582,7 @@ def __init__(self, title, prompt, self.initialvalue = initialvalue - Dialog.__init__(self, parent, title) + Dialog.__init__(self, parent, title, use_ttk=use_ttk) def destroy(self): self.entry = None @@ -291,11 +590,17 @@ def destroy(self): def body(self, master): - w = Label(master, text=self.prompt, justify=LEFT) - w.grid(row=0, padx=5, sticky=W) + # Wrap a long prompt, like tk::MessageBox wraps its message at 3 inches. + w = self._widget('Label', master, anchor=NW, text=self.prompt, + justify=LEFT, wraplength='3i') + w.grid(in_=master, padx='2m', pady='2m', sticky=NSEW) - self.entry = Entry(master, name="entry") - self.entry.grid(row=1, padx=5, sticky=W+E) + self.entry = self._widget('Entry', master, name='entry') + self.entry.grid(row=1, in_=master, padx='2m', pady=(0, '2m'), sticky=NSEW) + master.grid_rowconfigure(1, weight=1) + # The prompt and entry expand to the full width, like tk::MessageBox + # gives weight to its message column. + master.grid_columnconfigure(0, weight=1) if self.initialvalue is not None: self.entry.insert(0, self.initialvalue) @@ -312,7 +617,7 @@ def validate(self): self.errormessage + "\nPlease try again", parent = self ) - return 0 + return False if self.minvalue is not None and result < self.minvalue: messagebox.showwarning( @@ -321,7 +626,7 @@ def validate(self): "Please try again." % self.minvalue, parent = self ) - return 0 + return False if self.maxvalue is not None and result > self.maxvalue: messagebox.showwarning( @@ -330,11 +635,11 @@ def validate(self): "Please try again." % self.maxvalue, parent = self ) - return 0 + return False self.result = result - return 1 + return True class _QueryInteger(_QueryDialog): @@ -415,6 +720,54 @@ def askstring(title, prompt, **kw): return d.result +@contextlib.contextmanager +def _temp_grab_focus(grab, focus=None, destroy=True): + old_focus = grab.focus_get() + old_grab = grab.grab_current() + if old_grab is not None and old_grab.winfo_exists(): + old_status = old_grab.grab_status() + else: + old_status = None + # The "grab" command will fail if another application + # already holds the grab. So catch it. + try: + grab.grab_set() + except TclError: + pass + try: + if focus is not None and focus.winfo_exists(): + focus.focus_set() + + yield + + finally: + if old_focus is not None: + try: + old_focus.focus_set() + except TclError: + pass + try: + grab.grab_release() + except TclError: + pass + if destroy: + try: + grab.destroy() + except TclError: + pass + if (old_grab is not None and old_grab.winfo_exists() + and old_grab.winfo_ismapped()): + # The "grab" command will fail if another application + # already holds the grab. So catch it. + try: + if old_status == 'global': + old_grab.grab_set_global() + else: + old_grab.grab_set() + except TclError: + pass + + if __name__ == '__main__': def test(): diff --git a/Misc/NEWS.d/next/Library/2026-06-20-22-55-22.gh-issue-59396.kT9wPq.rst b/Misc/NEWS.d/next/Library/2026-06-20-22-55-22.gh-issue-59396.kT9wPq.rst new file mode 100644 index 00000000000000..550620a9c8276c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-20-22-55-22.gh-issue-59396.kT9wPq.rst @@ -0,0 +1,22 @@ +The :mod:`tkinter.simpledialog` dialogs were modernized to match the look and +feel of the native Tk dialogs. +:class:`!tkinter.simpledialog.SimpleDialog` and the +:func:`~tkinter.simpledialog.askinteger`, +:func:`~tkinter.simpledialog.askfloat` and +:func:`~tkinter.simpledialog.askstring` dialogs are now built from +the themed :mod:`tkinter.ttk` widgets instead of the classic :mod:`tkinter` +widgets; the :class:`!tkinter.simpledialog.Dialog` base class still defaults to +the classic widgets for compatibility. Both :class:`!Dialog` and +:class:`!SimpleDialog` gained a *use_ttk* +parameter that selects between the classic Tk widgets and the themed ttk +widgets. :class:`!SimpleDialog` also gained *bitmap* and *detail* parameters, +draws the standard icons with themed images in the ttk version, and accepts +mappings of button options as *buttons* entries, where an ``underline`` option +adds an :kbd:`Alt` key accelerator. +The font and wrap length of the message and the detail message are taken from +the Tk option database and can be overridden by the application. +The dialogs also follow the keyboard conventions of the Tk message box: the +default ring follows the keyboard focus, the Return key activates the focused +button, and the :class:`!Dialog` OK and Cancel buttons gained :kbd:`Alt` key +accelerators. +Several bugs were also fixed. From 3a8c80804461cd6a1dd1cb195c57a5a9465ace08 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 21 Jun 2026 18:59:14 +0300 Subject: [PATCH 2/4] Reorder tests. --- Lib/test/test_tkinter/test_simpledialog.py | 52 +++++++++++----------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/Lib/test/test_tkinter/test_simpledialog.py b/Lib/test/test_tkinter/test_simpledialog.py index 8817c2231b331c..7df3f5cbb961c1 100644 --- a/Lib/test/test_tkinter/test_simpledialog.py +++ b/Lib/test/test_tkinter/test_simpledialog.py @@ -389,6 +389,30 @@ def test_focus_prev_then_return(self): self.assertEqual(invoked, ['ok']) +class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase): + + def test_askinteger(self): + @staticmethod + def mock_wait_window(w): + nonlocal ismapped + ismapped = w.master.winfo_ismapped() + w.destroy() + + with swap_attr(Dialog, 'wait_window', mock_wait_window): + ismapped = None + askinteger("Go To Line", "Line number") + self.assertEqual(ismapped, False) + + root = tkinter.Tk() + ismapped = None + askinteger("Go To Line", "Line number") + self.assertEqual(ismapped, True) + root.destroy() + + tkinter.NoDefaultRoot() + self.assertRaises(RuntimeError, askinteger, "Go To Line", "Line number") + + class QueryDialogTest(AbstractTkTest, unittest.TestCase): # The query dialogs are modal: their __init__ blocks in wait_window(). # Mock that out so the dialog stays alive and can be driven with generated @@ -396,7 +420,7 @@ class QueryDialogTest(AbstractTkTest, unittest.TestCase): def open(self, query, **kw): with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)): - d = query('Title', 'Prompt', parent=self.root, **kw) + d = query("Title", "Prompt", parent=self.root, **kw) self.addCleanup(lambda: d.winfo_exists() and d.destroy()) d.focus_force() d.update() @@ -515,29 +539,5 @@ def test_ask_cancelled(self): self.assertIsNone(askstring('Title', 'Prompt', parent=self.root)) -class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase): - - def test_askinteger(self): - @staticmethod - def mock_wait_window(w): - nonlocal ismapped - ismapped = w.master.winfo_ismapped() - w.destroy() - - with swap_attr(Dialog, 'wait_window', mock_wait_window): - ismapped = None - askinteger('Go To Line', 'Line number') - self.assertEqual(ismapped, False) - - root = tkinter.Tk() - ismapped = None - askinteger('Go To Line', 'Line number') - self.assertEqual(ismapped, True) - root.destroy() - - tkinter.NoDefaultRoot() - self.assertRaises(RuntimeError, askinteger, 'Go To Line', 'Line number') - - -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() From 530eec39eb90258bcefe5937fedd1e8445a29d2e Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 21 Jun 2026 19:28:53 +0300 Subject: [PATCH 3/4] Strengthen the test_background test. Co-Authored-By: Claude Opus 4.8 --- Lib/test/test_tkinter/test_simpledialog.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_tkinter/test_simpledialog.py b/Lib/test/test_tkinter/test_simpledialog.py index 7df3f5cbb961c1..5c739d9ad6e642 100644 --- a/Lib/test/test_tkinter/test_simpledialog.py +++ b/Lib/test/test_tkinter/test_simpledialog.py @@ -276,9 +276,18 @@ def test_use_classic(self): self.assertTrue(invoked) def test_background(self): + # The ttk dialog adopts the ttk background, even a customized one, + # while the classic dialog keeps the default Toplevel background. + style = ttk.Style(self.root) + old = style.lookup('.', 'background') + style.configure('.', background='#123456') + self.addCleanup(style.configure, '.', background=old) d = self.open() - self.assertEqual(str(d.cget('background')), - ttk.Style(d).lookup('.', 'background')) + self.assertEqual(str(d.cget('background')), '#123456') + d = self.open(use_ttk=False) + ref = tkinter.Toplevel(self.root) + self.addCleanup(ref.destroy) + self.assertEqual(str(d.cget('background')), str(ref.cget('background'))) def test_base_classic_by_default(self): # The Dialog base defaults to classic widgets so that subclasses adding From 2a0ad97fc39d10157ba1cf0ab466fda7e3d94881 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 21 Jun 2026 20:00:35 +0300 Subject: [PATCH 4/4] Improve the simpledialog demo. Add a button per dialog, a checkbox to toggle classic/themed widgets, and show a bitmap, a detail message and Alt-key button accelerators in the SimpleDialog example. Co-Authored-By: Claude Opus 4.8 --- Lib/tkinter/simpledialog.py | 40 +++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/Lib/tkinter/simpledialog.py b/Lib/tkinter/simpledialog.py index 1a5ecf163ef683..c61881d4872421 100644 --- a/Lib/tkinter/simpledialog.py +++ b/Lib/tkinter/simpledialog.py @@ -772,26 +772,44 @@ def _temp_grab_focus(grab, focus=None, destroy=True): def test(): root = Tk() - def doit(root=root): + use_ttk = tkinter.BooleanVar(root, value=True) + + def test_dialog(): d = SimpleDialog(root, text="This is a test dialog. " "Would this have been an actual dialog, " "the buttons below would have been glowing " "in soft pink light.\n" "Do you believe this?", - buttons=["Yes", "No", "Cancel"], + buttons=[{'text': 'Yes', 'underline': 0}, + {'text': 'No', 'underline': 0}, + {'text': 'Cancel', 'underline': 0}], default=0, cancel=2, - title="Test Dialog") + title="Test Dialog", + bitmap='question', + detail="Alt+Y, Alt+N and Alt+C work too.", + use_ttk=use_ttk.get()) print(d.go()) - print(askinteger("Spam", "Egg count", initialvalue=12*12)) + + def test_integer(): + print(askinteger("Spam", "Egg count", initialvalue=12*12, + use_ttk=use_ttk.get())) + + def test_float(): print(askfloat("Spam", "Egg weight\n(in tons)", minvalue=1, - maxvalue=100)) - print(askstring("Spam", "Egg label")) - t = Button(root, text='Test', command=doit) - t.pack() - q = Button(root, text='Quit', command=t.quit) - q.pack() - t.mainloop() + maxvalue=100, use_ttk=use_ttk.get())) + + def test_string(): + print(askstring("Spam", "Egg label", use_ttk=use_ttk.get())) + + tkinter.Checkbutton(root, text='Use themed (ttk) widgets', + variable=use_ttk).pack(fill=X) + Button(root, text='SimpleDialog', command=test_dialog).pack(fill=X) + Button(root, text='askinteger', command=test_integer).pack(fill=X) + Button(root, text='askfloat', command=test_float).pack(fill=X) + Button(root, text='askstring', command=test_string).pack(fill=X) + Button(root, text='Quit', command=root.quit).pack(fill=X) + root.mainloop() test()