diff --git a/Doc/library/dialog.rst b/Doc/library/dialog.rst index 402cfcfe75369f8..74b23eaf87b4a0b 100644 --- a/Doc/library/dialog.rst +++ b/Doc/library/dialog.rst @@ -15,9 +15,9 @@ The :mod:`!tkinter.simpledialog` module contains convenience classes and functions for creating simple modal dialogs to get a value from the user. -.. function:: askfloat(title, prompt, *, initialvalue=None, minvalue=None, maxvalue=None, parent=None) - askinteger(title, prompt, *, initialvalue=None, minvalue=None, maxvalue=None, parent=None) - askstring(title, prompt, *, initialvalue=None, show=None, parent=None) +.. function:: askfloat(title, prompt, *, initialvalue=None, minvalue=None, maxvalue=None, parent=None, use_ttk=True) + askinteger(title, prompt, *, initialvalue=None, minvalue=None, maxvalue=None, parent=None, use_ttk=True) + askstring(title, prompt, *, initialvalue=None, show=None, parent=None, use_ttk=True) Prompt the user to enter a value of the desired type and return it, or ``None`` if the dialog is cancelled. @@ -29,12 +29,22 @@ functions for creating simple modal dialogs to get a value from the user. *maxvalue*, which bound the accepted value. :func:`askstring` also accepts *show*, a character used to mask the entered text, for example ``'*'`` to hide a password. + 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. Instantiating it shows the dialog modally and returns once the user closes it; the entered value is then available in the :attr:`!result` attribute. + 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. .. attribute:: result @@ -74,14 +84,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 445be69128eb190..64a986f2487d5cb 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -220,6 +220,22 @@ tkinter options as keyword arguments, which can also override its default appearance. (Contributed by Serhiy Storchaka in :gh:`101284`.) +* 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 942b7ebf7120b3a..5c739d9ad6e6422 100644 --- a/Lib/test/test_tkinter/test_simpledialog.py +++ b/Lib/test/test_tkinter/test_simpledialog.py @@ -1,12 +1,13 @@ 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, SimpleDialog, askinteger, askfloat, askstring, - _QueryInteger, _QueryFloat, _QueryString) + _QueryInteger, _QueryFloat, _QueryString, + _underline_ampersand, _find_alt_key_target) requires('gui') @@ -24,42 +25,172 @@ def create(self, **kw): self.addCleanup(lambda: d.root.winfo_exists() and d.root.destroy()) return d - def test_message(self): - # The text is shown in a message widget. - d = self.create(text='Hello?') - self.assertEqual(d.message.winfo_class(), 'Message') - self.assertEqual(str(d.message.cget('text')), 'Hello?') + # --- Widget set and appearance --- + + 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) + + 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. + # 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') - def test_button(self): - # Pressing a button records its index. + # --- 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.frame.winfo_children()[1].invoke() # "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_default_button(self): - # The default button is drawn with a raised border. - d = self.create(buttons=['Yes', 'No'], default=0) - self.assertEqual(str(d.frame.winfo_children()[0].cget('relief')), 'ridge') + 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): - # invokes the default button. + # with the focus off the buttons invokes the default button. d = self.create() # default 0 - d.root.focus_force() + 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, rings the bell and leaves the dialog - # open instead of activating a button. + # 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() + 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)): @@ -69,6 +200,15 @@ def test_return_no_default(self): 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 @@ -92,12 +232,12 @@ def test_wm_delete_no_cancel(self): 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.done(0)) + d.root.after(1, lambda: d._buttons[0].invoke()) self.assertEqual(d.go(), 0) class DialogTest(AbstractTkTest, unittest.TestCase): - # Dialog is a base class for custom dialogs; exercise it via _QueryInteger. + # Dialog's button box is modelled on tk::MessageBox. def open(self, **kw): with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)): @@ -105,40 +245,157 @@ def open(self, **kw): self.addCleanup(lambda: d.winfo_exists() and d.destroy()) return d - def buttons(self, d): - # Map the button box's buttons by their label. - return {str(b.cget('text')): b - for frame in d.winfo_children() - for b in frame.winfo_children() - if b.winfo_class() == 'Button'} + # --- 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): - # The classic dialog keeps the default Toplevel background. + # 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')), '#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_buttons(self): - # The button box has OK (the default) and Cancel buttons. - buttons = self.buttons(self.open()) - self.assertEqual(set(buttons), {'OK', 'Cancel'}) - self.assertEqual(str(buttons['OK'].cget('default')), 'active') + 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_ok(self): - # The OK button validates the entry and stores the result. + def test_button_default(self): d = self.open() - d.entry.insert(0, '42') - self.buttons(d)['OK'].invoke() - self.assertEqual(d.result, 42) - self.assertFalse(d.winfo_exists()) # The dialog closed. + 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_cancel(self): - # The Cancel button closes the dialog without a result. + def test_default_ring(self): + # The default ring follows the keyboard focus among the buttons. d = self.open() - self.buttons(d)['Cancel'].invoke() - self.assertIsNone(d.result) - self.assertFalse(d.winfo_exists()) + 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 DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase): @@ -184,6 +441,22 @@ 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) @@ -197,6 +470,8 @@ def test_show(self): 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), @@ -215,6 +490,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) @@ -249,6 +526,8 @@ def test_boundary_values_accepted(self): 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): diff --git a/Lib/tkinter/simpledialog.py b/Lib/tkinter/simpledialog.py index 4f9eb44f0346773..c61881d48724217 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,30 +720,96 @@ 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(): 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() 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 000000000000000..550620a9c8276cb --- /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.