From ce147129c183b934800b539c9a85b7d6bf44ae5d Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 23 Jun 2026 09:17:16 +0300 Subject: [PATCH 1/4] gh-151678: Add tests for ttk Menubutton and OptionMenu widget options (GH-151960) Decorate ttk.MenubuttonTest with add_configure_tests() and make ttk.OptionMenuTest inherit it to cover the standard widget options. --- Lib/test/test_ttk/test_extensions.py | 49 ++++++++++++++++++++++++++-- Lib/test/test_ttk/test_widgets.py | 1 + 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_ttk/test_extensions.py b/Lib/test/test_ttk/test_extensions.py index 2765b226e271f9e..0460f1b9aa31172 100644 --- a/Lib/test/test_ttk/test_extensions.py +++ b/Lib/test/test_ttk/test_extensions.py @@ -4,10 +4,14 @@ from tkinter import ttk from test.support import requires, gc_collect from test.test_tkinter.support import setUpModule # noqa: F401 -from test.test_tkinter.support import AbstractTkTest, AbstractDefaultRootTest +from test.test_tkinter.support import (AbstractTkTest, AbstractDefaultRootTest, + get_tk_patchlevel, widget_eq) requires('gui') +from test.test_ttk import test_widgets + + class LabeledScaleTest(AbstractTkTest, unittest.TestCase): def tearDown(self): @@ -193,7 +197,7 @@ def test_resize(self): x.destroy() -class OptionMenuTest(AbstractTkTest, unittest.TestCase): +class OptionMenuTest(test_widgets.MenubuttonTest, unittest.TestCase): def setUp(self): super().setUp() @@ -203,6 +207,47 @@ def tearDown(self): del self.textvar super().tearDown() + def create(self, default='b', values=('a', 'b', 'c'), **kwargs): + return ttk.OptionMenu(self.root, self.textvar, default, *values, **kwargs) + + def test_bad_kwarg(self): + with self.assertRaisesRegex(tkinter.TclError, r"^unknown option -image$"): + ttk.OptionMenu(self.root, self.textvar, 'b', image='') + + def test_configure_class(self): + # Unlike a plain Menubutton, OptionMenu does not accept a class at + # construction, so only the read-only nature of the option is tested. + widget = self.create() + self.assertEqual(widget['class'], '') + errmsg = 'attempt to change read-only option' + if get_tk_patchlevel(self.root) < (8, 6, 0, 'beta', 3): + errmsg = 'Attempt to change read-only option' + self.checkInvalidParam(widget, 'class', 'Foo', errmsg=errmsg) + + def test_configure_style(self): + # Like Menubutton, but OptionMenu does not accept a class at + # construction, so the custom-class part of the standard test is omitted. + widget = self.create() + self.assertEqual(widget['style'], '') + self.checkInvalidParam(widget, 'style', 'Foo', + errmsg='Layout Foo not found') + style = ttk.Style(self.root) + style.configure('Custom.TMenubutton') + self.checkParam(widget, 'style', 'Custom.TMenubutton') + + def test_configure_menu(self): + # OptionMenu manages its own menu; ['menu'] returns that Menu widget. + widget = self.create() + self.assertIsInstance(widget['menu'], tkinter.Menu) + self.checkParam(widget, 'menu', widget['menu'], eq=widget_eq) + + def test_configure_text(self): + # The displayed text is governed by the textvariable. + widget = self.create() + self.textvar.set('a') + self.assertEqual(widget['text'], 'a') + self.textvar.set('c') + self.assertEqual(widget['text'], 'c') def test_widget_destroy(self): var = tkinter.StringVar(self.root) diff --git a/Lib/test/test_ttk/test_widgets.py b/Lib/test/test_ttk/test_widgets.py index 16f086961966345..bffcfcff526c668 100644 --- a/Lib/test/test_ttk/test_widgets.py +++ b/Lib/test/test_ttk/test_widgets.py @@ -789,6 +789,7 @@ def cb_test(): self.assertEqual(str(cbtn['variable']), str(cbtn2['variable'])) +@add_configure_tests(StandardTtkOptionsTests) class MenubuttonTest(AbstractLabelTest, unittest.TestCase): OPTIONS = ( 'class', 'compound', 'cursor', 'direction', From 868d9a82cab556a8f5ad1f54e81d39dd325d3f52 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 23 Jun 2026 09:24:17 +0300 Subject: [PATCH 2/4] gh-101284: Allow passing Menubutton options to tkinter.OptionMenu (GH-151959) Arbitrary keyword arguments are now forwarded to the underlying Menubutton and can override OptionMenu's default appearance options. The positional API is unchanged. Co-authored-by: Claude Opus 4.8 --- Doc/library/tkinter.rst | 5 ++++ Doc/whatsnew/3.16.rst | 4 ++++ Lib/test/test_tkinter/test_widgets.py | 17 ++++++++++++-- Lib/tkinter/__init__.py | 23 +++++++++++-------- ...-06-23-12-00-00.gh-issue-101284.Ab3Xy9.rst | 2 ++ 5 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-23-12-00-00.gh-issue-101284.Ab3Xy9.rst diff --git a/Doc/library/tkinter.rst b/Doc/library/tkinter.rst index 029121894656592..cee84d67668a84c 100644 --- a/Doc/library/tkinter.rst +++ b/Doc/library/tkinter.rst @@ -4844,6 +4844,8 @@ Widget classes is the initial choice, and *values* are the remaining menu entries. The keyword argument *command* may be given a callback that is invoked with the selected value, and the keyword argument *name* sets the Tk widget name. + Other keyword arguments are passed to the underlying :class:`Menubutton` + and may override its default appearance. .. method:: destroy() @@ -4852,6 +4854,9 @@ Widget classes .. versionchanged:: 3.14 Added support for the *name* keyword argument. + .. versionchanged:: next + Other :class:`Menubutton` options can now be passed as keyword arguments. + .. class:: PanedWindow(master=None, cnf={}, **kw) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index f394298da96a7cd..445be69128eb190 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -216,6 +216,10 @@ tkinter dithered image when its data was supplied in pieces. (Contributed by Serhiy Storchaka in :gh:`151888`.) +* :class:`tkinter.OptionMenu` now accepts arbitrary :class:`!tkinter.Menubutton` + options as keyword arguments, which can also override its default appearance. + (Contributed by Serhiy Storchaka in :gh:`101284`.) + xml --- diff --git a/Lib/test/test_tkinter/test_widgets.py b/Lib/test/test_tkinter/test_widgets.py index e388f0e8ed8f93d..4b7a6acfad0b922 100644 --- a/Lib/test/test_tkinter/test_widgets.py +++ b/Lib/test/test_tkinter/test_widgets.py @@ -437,9 +437,22 @@ class OptionMenuTest(MenubuttonTest, unittest.TestCase): def create(self, default='b', values=('a', 'b', 'c'), **kwargs): return tkinter.OptionMenu(self.root, None, default, *values, **kwargs) + def test_kwargs(self): + # Menubutton options can be passed at construction (gh-101284). + widget = tkinter.OptionMenu(self.root, None, 'b', + width=10, direction='right') + self.assertEqual(int(widget['width']), 10) + self.assertEqual(str(widget['direction']), 'right') + # They override OptionMenu's own appearance defaults, + widget = tkinter.OptionMenu(self.root, None, 'b', relief='flat') + self.assertEqual(str(widget['relief']), 'flat') + # which otherwise keep their historical values. + widget = tkinter.OptionMenu(self.root, None, 'b') + self.assertEqual(str(widget['relief']), 'raised') + def test_bad_kwarg(self): - with self.assertRaisesRegex(TclError, r"^unknown option -image$"): - tkinter.OptionMenu(self.root, None, 'b', image='') + with self.assertRaisesRegex(TclError, r'^unknown option "-spam"$'): + tkinter.OptionMenu(self.root, None, 'b', spam='') def test_specify_name(self): widget = tkinter.OptionMenu(self.root, None, ':)', name="option_menu") diff --git a/Lib/tkinter/__init__.py b/Lib/tkinter/__init__.py index f940216253dee1e..122f4da25de5a2a 100644 --- a/Lib/tkinter/__init__.py +++ b/Lib/tkinter/__init__.py @@ -4516,20 +4516,23 @@ def __init__(self, master, variable, value, *values, **kwargs): """Construct an optionmenu widget with the parent MASTER, with the option textvariable set to VARIABLE, the initially selected value VALUE, the other menu values VALUES and an additional - keyword argument command.""" - kw = {"borderwidth": 2, "textvariable": variable, - "indicatoron": 1, "relief": RAISED, "anchor": "c", - "highlightthickness": 2, "name": kwargs.pop("name", None)} + keyword argument command. + + Other keyword arguments are passed to the underlying menubutton + and may override its default appearance.""" + name = kwargs.pop("name", None) + callback = kwargs.pop("command", None) + # Default appearance, which may be overridden by keyword arguments. + kw = {"borderwidth": 2, "indicatoron": 1, "relief": RAISED, + "anchor": "c", "highlightthickness": 2} + kw.update(kwargs) + # These options are controlled by OptionMenu itself. + kw["textvariable"] = variable + kw["name"] = name Widget.__init__(self, master, "menubutton", kw) self.widgetName = 'tk_optionMenu' menu = self.__menu = Menu(self, name="menu", tearoff=0) self.menuname = menu._w - # 'command' is the only supported keyword - callback = kwargs.get('command') - if 'command' in kwargs: - del kwargs['command'] - if kwargs: - raise TclError('unknown option -'+next(iter(kwargs))) menu.add_command(label=value, command=_setit(variable, value, callback)) for v in values: diff --git a/Misc/NEWS.d/next/Library/2026-06-23-12-00-00.gh-issue-101284.Ab3Xy9.rst b/Misc/NEWS.d/next/Library/2026-06-23-12-00-00.gh-issue-101284.Ab3Xy9.rst new file mode 100644 index 000000000000000..9afcf8dcf545ed5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-23-12-00-00.gh-issue-101284.Ab3Xy9.rst @@ -0,0 +1,2 @@ +:class:`tkinter.OptionMenu` now accepts arbitrary :class:`!tkinter.Menubutton` +options as keyword arguments and uses them to override its default appearance. From ad38cf82926c0bf41a677da39f0007f77eb6fc3f Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 23 Jun 2026 11:01:10 +0300 Subject: [PATCH 3/4] gh-59396: Modernize tkinter.simpledialog (GH-151848) 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. * Improve the simpledialog demo. Co-authored-by: Claude Opus 4.8 --- Doc/library/dialog.rst | 48 +- Doc/whatsnew/3.16.rst | 16 + Lib/test/test_tkinter/test_simpledialog.py | 369 ++++++++++-- Lib/tkinter/simpledialog.py | 541 +++++++++++++++--- ...6-06-20-22-55-22.gh-issue-59396.kT9wPq.rst | 22 + 5 files changed, 856 insertions(+), 140 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 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. From da99711d37dba3413af05207ea8b12cb06041c0f Mon Sep 17 00:00:00 2001 From: Shardul Deshpande Date: Tue, 23 Jun 2026 15:02:31 +0530 Subject: [PATCH 4/4] gh-151497: Avoid huge pre-allocation for oversized tarfile extended headers (GH-151498) tarfile reads a member's extended header (a GNU long name/link or a pax header) with a single read sized by the header's size field: buf = tarfile.fileobj.read(self._block(self.size)) The size is taken from the archive and is not validated, so a ~512-byte crafted file can claim several gigabytes (or, via base-256 encoding, far more) and make read() pre-allocate that much memory -- on open/iterate, before any extraction filter runs. Read the extended-header data in bounded chunks instead, so an oversized or truncated header can no longer force a huge allocation. The bytes returned for valid archives are unchanged. --- Lib/tarfile.py | 30 +++++++++++- Lib/test/test_tarfile.py | 47 +++++++++++++++++++ ...-06-15-15-32-36.gh-issue-151497.1cfmSV.rst | 4 ++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-15-15-32-36.gh-issue-151497.1cfmSV.rst diff --git a/Lib/tarfile.py b/Lib/tarfile.py index a293a0492472749..52c0d990d0a5f6b 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -256,6 +256,32 @@ def copyfileobj(src, dst, length=None, exception=OSError, bufsize=None): dst.write(buf) return +# Maximum number of bytes read in a single call when reading a member's +# extended header (a GNU long name/link or a pax header). The size of such +# a header is taken from the archive and is not trustworthy, so it is read in +# bounded chunks to avoid a huge up-front allocation when a crafted or +# truncated archive claims far more data than the file actually contains +# (gh-151497). +_EXTHEADER_READ_CHUNK = 1024 * 1024 # 1 MiB + +def _safe_read(fileobj, size): + """Read up to *size* bytes from *fileobj* in bounded chunks. + + Returns the same bytes as ``fileobj.read(size)`` would (including a short + result at end of file), but limits pre-allocation, so an + oversized size field in a crafted header cannot force a huge allocation. + """ + if size <= _EXTHEADER_READ_CHUNK: + return fileobj.read(size) + chunks = [] + while size > 0: + chunk = fileobj.read(min(size, _EXTHEADER_READ_CHUNK)) + if not chunk: + break + chunks.append(chunk) + size -= len(chunk) + return b"".join(chunks) + def _safe_print(s): encoding = getattr(sys.stdout, 'encoding', None) if encoding is not None: @@ -1424,7 +1450,7 @@ def _proc_gnulong(self, tarfile): """Process the blocks that hold a GNU longname or longlink member. """ - buf = tarfile.fileobj.read(self._block(self.size)) + buf = _safe_read(tarfile.fileobj, self._block(self.size)) # Fetch the next header and process it. try: @@ -1480,7 +1506,7 @@ def _proc_pax(self, tarfile): POSIX.1-2008. """ # Read the header information. - buf = tarfile.fileobj.read(self._block(self.size)) + buf = _safe_read(tarfile.fileobj, self._block(self.size)) # A pax header stores supplemental information for either # the following file (extended) or all following files diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 62a262740a7efa8..e0d06be57ccbb34 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -550,6 +550,53 @@ def test_extractfile_attrs(self): self.assertIs(fobj.seekable(), True) +class ReadSizeRecorder(io.BytesIO): + # Records the largest size ever passed to read(), so a test can check + # that tarfile does not request far more data than the archive holds + # (which on a real file would pre-allocate it). + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.max_read_size = 0 + + def read(self, size=-1): + if size is not None and size >= 0: + self.max_read_size = max(self.max_read_size, size) + return super().read(size) + + +@support.cpython_only +class ExtendedHeaderMemoryTest(unittest.TestCase): + # gh-151497: the size of a GNU long name/link or a pax extended header is + # read from the archive and is untrusted. A crafted header can claim a + # size far larger than the file actually contains; opening such an archive + # must not try to read (and so pre-allocate) the claimed size in one go. + + def crafted_archive(self, hdrtype): + tarinfo = tarfile.TarInfo("A") + tarinfo.type = hdrtype + tarinfo.size = 0xFFFFFFFF # ~4 GiB claimed in a 512-byte header + return tarinfo.tobuf(format=tarfile.GNU_FORMAT) + + def check(self, hdrtype): + fobj = ReadSizeRecorder(self.crafted_archive(hdrtype)) + try: + with tarfile.open(fileobj=fobj, mode="r:") as tar: + tar.getmembers() + except tarfile.ReadError: + pass # a truncated header is fine; we only check the allocation + # The bogus ~4 GiB size must never reach a single read() call. + self.assertLessEqual(fobj.max_read_size, tarfile._EXTHEADER_READ_CHUNK) + + def test_gnu_longname_oversized_size(self): + self.check(tarfile.GNUTYPE_LONGNAME) + + def test_gnu_longlink_oversized_size(self): + self.check(tarfile.GNUTYPE_LONGLINK) + + def test_pax_header_oversized_size(self): + self.check(tarfile.XHDTYPE) + + class MiscReadTestBase(CommonReadTest): is_stream = False diff --git a/Misc/NEWS.d/next/Library/2026-06-15-15-32-36.gh-issue-151497.1cfmSV.rst b/Misc/NEWS.d/next/Library/2026-06-15-15-32-36.gh-issue-151497.1cfmSV.rst new file mode 100644 index 000000000000000..a4c03c9d71d7618 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-15-15-32-36.gh-issue-151497.1cfmSV.rst @@ -0,0 +1,4 @@ +Opening a :mod:`tarfile` archive no longer attempts to pre-allocate a huge +buffer when a crafted or truncated member claims an oversized extended header +(a GNU long name/link or a pax header). The extended header is now read in +bounded chunks, so its size field can no longer trigger memory exhaustion.