From 53ec4cf0864d671d659cadda12cbfec49dce1408 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 1 Jul 2026 13:54:45 +0300 Subject: [PATCH] gh-71808: Rebuild IDLE query dialogs on tkinter.simpledialog.Dialog Subclass Dialog instead of reimplementing its modal machinery. The query dialogs gain its keyboard equivalents -- , , and Alt-underlined-letter button accelerators (gh-71807). --- Lib/idlelib/idle_test/test_query.py | 16 +- Lib/idlelib/query.py | 153 ++++++++---------- Lib/tkinter/simpledialog.py | 7 + ...6-07-01-15-00-00.gh-issue-71808.Rk9pQ2.rst | 4 + 4 files changed, 81 insertions(+), 99 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-07-01-15-00-00.gh-issue-71808.Rk9pQ2.rst diff --git a/Lib/idlelib/idle_test/test_query.py b/Lib/idlelib/idle_test/test_query.py index a6ef858a8c954a2..6c9cdfb39dbf5c8 100644 --- a/Lib/idlelib/idle_test/test_query.py +++ b/Lib/idlelib/idle_test/test_query.py @@ -27,7 +27,7 @@ class QueryTest(unittest.TestCase): class Dummy_Query: # Test the following Query methods. entry_ok = query.Query.entry_ok - ok = query.Query.ok + validate = query.Query.validate cancel = query.Query.cancel # Add attributes and initialization needed for tests. def __init__(self, dummy_entry): @@ -53,18 +53,16 @@ def test_entry_ok_good(self): Equal((dialog.result, dialog.destroyed), (None, False)) Equal(dialog.entry_error['text'], '') - def test_ok_blank(self): + def test_validate_blank(self): dialog = self.Dummy_Query('') - dialog.entry.focus_set = mock.Mock() - self.assertEqual(dialog.ok(), None) - self.assertTrue(dialog.entry.focus_set.called) - del dialog.entry.focus_set + self.assertFalse(dialog.validate()) self.assertEqual((dialog.result, dialog.destroyed), (None, False)) - def test_ok_good(self): + def test_validate_good(self): dialog = self.Dummy_Query('good') - self.assertEqual(dialog.ok(), None) - self.assertEqual((dialog.result, dialog.destroyed), ('good', True)) + self.assertTrue(dialog.validate()) + # validate stores the result; Dialog.ok destroys the window. + self.assertEqual((dialog.result, dialog.destroyed), ('good', False)) def test_cancel(self): dialog = self.Dummy_Query('does not matter') diff --git a/Lib/idlelib/query.py b/Lib/idlelib/query.py index 5f9bdc031e544b7..918d3f2c9909128 100644 --- a/Lib/idlelib/query.py +++ b/Lib/idlelib/query.py @@ -24,23 +24,26 @@ import shlex from sys import executable, platform # Platform is set for one test. -from tkinter import Toplevel, StringVar, BooleanVar, W, E, S -from tkinter.ttk import Frame, Button, Entry, Label, Checkbutton +from tkinter import StringVar, BooleanVar, W, S, EW, LEFT +from tkinter.ttk import Button, Checkbutton, Entry, Label from tkinter import filedialog from tkinter.font import Font -from tkinter.simpledialog import _setup_dialog +from tkinter.simpledialog import Dialog -class Query(Toplevel): +class Query(Dialog): """Base class for getting verified answer from a user. For this base class, accept any non-blank string. + Built on tkinter.simpledialog.Dialog, which provides the modal + behavior and the OK/Cancel buttons (with , , and + Alt-underline keyboard equivalents). """ def __init__(self, parent, title, message, *, text0='', used_names={}, _htest=False, _utest=False): """Create modal popup, return when destroyed. - Additional subclass init must be done before this unless - _utest=True is passed to suppress wait_window(). + Additional subclass init must be done before calling this + unless _utest=True is passed to suppress wait_window(). title - string, title of popup dialog message - string, informational message to display @@ -49,78 +52,56 @@ def __init__(self, parent, title, message, *, text0='', used_names={}, _htest - bool, change box location when running htest _utest - bool, leave window hidden and not modal """ - self.parent = parent # Needed for Font call. self.message = message self.text0 = text0 self.used_names = used_names + self._htest = _htest + self._utest = _utest + super().__init__(parent, title, use_ttk=True) - Toplevel.__init__(self, parent) - self.withdraw() # Hide while configuring, especially geometry. - self.title(title) - self.transient(parent) - if not _utest: # Otherwise fail when directly run unittest. - self.grab_set() + def _show_modal(self): + "Suppress the modal wait when unit testing." + if not self._utest: + super()._show_modal() - _setup_dialog(self) - if self._windowingsystem == 'aqua': - self.bind("", self.cancel) - self.bind('', self.cancel) - self.protocol("WM_DELETE_WINDOW", self.cancel) - self.bind('', self.ok) - self.bind("", self.ok) - - self.create_widgets() - self.update_idletasks() # Need here for winfo_reqwidth below. - self.geometry( # Center dialog over parent (or below htest box). - "+%d+%d" % ( - parent.winfo_rootx() + - (parent.winfo_width()/2 - self.winfo_reqwidth()/2), - parent.winfo_rooty() + - ((parent.winfo_height()/2 - self.winfo_reqheight()/2) - if not _htest else 150) - ) ) - self.resizable(height=False, width=False) - - if not _utest: - self.deiconify() # Unhide now that geometry set. - self.entry.focus_set() - self.wait_window() - - def create_widgets(self, ok_text='OK'): # Do not replace. - """Create entry (rows, extras, buttons. + def body(self, master): # Do not replace. + """Create entry widgets; return the entry for initial focus. Entry stuff on rows 0-2, spanning cols 0-2. - Buttons on row 99, cols 1, 2. + Subclass extras (create_extra) go on rows 10-12. """ # Bind to self the widgets needed for entry_ok or unittest. - self.frame = frame = Frame(self, padding=10) - frame.grid(column=0, row=0, sticky='news') - frame.grid_columnconfigure(0, weight=1) + self.frame = master + master.columnconfigure(0, weight=1) - entrylabel = Label(frame, anchor='w', justify='left', - text=self.message) + entrylabel = Label(master, anchor=W, justify=LEFT, text=self.message) self.entryvar = StringVar(self, self.text0) - self.entry = Entry(frame, width=30, textvariable=self.entryvar) + self.entry = Entry(master, width=30, textvariable=self.entryvar) self.error_font = Font(name='TkCaptionFont', exists=True, root=self.parent) - self.entry_error = Label(frame, text=' ', foreground='red', + self.entry_error = Label(master, text=' ', foreground='red', font=self.error_font) # Display or blank error by setting ['text'] =. - entrylabel.grid(column=0, row=0, columnspan=3, padx=5, sticky=W) - self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E, - pady=[10,0]) - self.entry_error.grid(column=0, row=2, columnspan=3, padx=5, - sticky=W+E) + entrylabel.grid(column=0, row=0, columnspan=3, padx='2m', pady='2m', + sticky=EW) + self.entry.grid(column=0, row=1, columnspan=3, padx='2m', + pady=(0, '2m'), sticky=EW) + self.entry_error.grid(column=0, row=2, columnspan=3, padx='2m', + pady=(0, '2m'), sticky=EW) self.create_extra() - self.button_ok = Button( - frame, text=ok_text, default='active', command=self.ok) - self.button_cancel = Button( - frame, text='Cancel', command=self.cancel) + self.resizable(height=False, width=False) + if self._windowingsystem == 'aqua': + self.bind("", self.cancel) + self.bind("", self.ok) + return self.entry - self.button_ok.grid(column=1, row=99, padx=5) - self.button_cancel.grid(column=2, row=99, padx=5) + def buttonbox(self): # Do not replace. + "Add the standard buttons and expose them for unittest." + super().buttonbox() + self.button_ok = self.nametowidget('ok') + self.button_cancel = self.nametowidget('cancel') def create_extra(self): pass # Override to add widgets. @@ -136,28 +117,18 @@ def entry_ok(self): # Example: usually replace. return None return entry - def ok(self, event=None): # Do not replace. - '''If entry is valid, bind it to 'result' and destroy tk widget. + def validate(self): # Do not replace. + """If entry is valid, store it in 'result' and return True. - Otherwise leave dialog open for user to correct entry or cancel. - ''' + Otherwise show the error and leave the dialog open (Dialog.ok + puts the focus back on the entry). + """ self.entry_error['text'] = '' entry = self.entry_ok() - if entry is not None: - self.result = entry - self.destroy() - else: - # [Ok] moves focus. ( does not.) Move it back. - self.entry.focus_set() - - def cancel(self, event=None): # Do not replace. - "Set dialog result to None and destroy tk widget." - self.result = None - self.destroy() - - def destroy(self): - self.grab_release() - super().destroy() + if entry is None: + return False + self.result = entry + return True class SectionName(Query): @@ -260,9 +231,9 @@ def __init__(self, parent, title, *, menuitem='', filepath='', used_names=used_names, _htest=_htest, _utest=_utest) def create_extra(self): - "Add path widjets to rows 10-12." + "Add path widgets to rows 10-12." frame = self.frame - pathlabel = Label(frame, anchor='w', justify='left', + pathlabel = Label(frame, anchor=W, justify=LEFT, text='Help File Path: Enter URL or browse for file') self.pathvar = StringVar(self, self.filepath) self.path = Entry(frame, textvariable=self.pathvar, width=40) @@ -271,13 +242,14 @@ def create_extra(self): self.path_error = Label(frame, text=' ', foreground='red', font=self.error_font) - pathlabel.grid(column=0, row=10, columnspan=3, padx=5, pady=[10,0], - sticky=W) - self.path.grid(column=0, row=11, columnspan=2, padx=5, sticky=W+E, - pady=[10,0]) - browse.grid(column=2, row=11, padx=5, sticky=W+S) - self.path_error.grid(column=0, row=12, columnspan=3, padx=5, - sticky=W+E) + pathlabel.grid(column=0, row=10, columnspan=3, padx='2m', + pady=(0, '2m'), sticky=EW) + self.path.grid(column=0, row=11, columnspan=2, padx='2m', + pady=(0, '2m'), sticky=EW) + browse.grid(column=2, row=11, padx=(0, '2m'), pady=(0, '2m'), + sticky=W+S) + self.path_error.grid(column=0, row=12, columnspan=3, padx='2m', + pady=(0, '2m'), sticky=EW) def askfilename(self, filetypes, initdir, initfile): # htest # # Extracted from browse_file so can mock for unittests. @@ -361,9 +333,10 @@ def create_extra(self): self.args_error = Label(frame, text=' ', foreground='red', font=self.error_font) - restart.grid(column=0, row=10, columnspan=3, padx=5, sticky='w') - self.args_error.grid(column=0, row=12, columnspan=3, padx=5, - sticky='we') + restart.grid(column=0, row=10, columnspan=3, padx='2m', + pady=(0, '2m'), sticky=W) + self.args_error.grid(column=0, row=12, columnspan=3, padx='2m', + pady=(0, '2m'), sticky=EW) def cli_args_ok(self): "Return command line arg list or None if error." diff --git a/Lib/tkinter/simpledialog.py b/Lib/tkinter/simpledialog.py index c61881d48724217..8682f003dc052cd 100644 --- a/Lib/tkinter/simpledialog.py +++ b/Lib/tkinter/simpledialog.py @@ -328,6 +328,13 @@ def __init__(self, parent, title=None, *, use_ttk=False): _place_window(self, parent) + self._show_modal() + + def _show_modal(self): + '''Grab the focus and wait until the dialog is destroyed. + + Override to show the dialog without blocking (e.g. for testing). + ''' # wait for window to appear on screen before calling grab_set self.wait_visibility() # Dialog destroys itself in ok()/cancel(), so let _temp_grab_focus diff --git a/Misc/NEWS.d/next/IDLE/2026-07-01-15-00-00.gh-issue-71808.Rk9pQ2.rst b/Misc/NEWS.d/next/IDLE/2026-07-01-15-00-00.gh-issue-71808.Rk9pQ2.rst new file mode 100644 index 000000000000000..8e152c88c981025 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-07-01-15-00-00.gh-issue-71808.Rk9pQ2.rst @@ -0,0 +1,4 @@ +Rebuild the IDLE query dialogs (used for Open Module, custom Run, and new +config-section names) on ``tkinter.simpledialog.Dialog``. They gain its +standard keyboard behavior: ``Return``, ``Escape``, and Alt-underlined-letter +button accelerators.