From 9fce9ed11841c9da2ba8f1f92a07268c2adfcb3a Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 21 Jun 2026 23:36:25 +0300 Subject: [PATCH 1/2] gh-59396: Use themed widgets in tkinter.filedialog The FileDialog, LoadFileDialog and SaveFileDialog dialogs are now built from the themed tkinter.ttk widgets by default instead of the classic tkinter widgets, and gained a use_ttk parameter that selects between the classic Tk widgets and the themed ttk widgets. They were also brought closer to the native Tk file dialog: the buttons and field labels gained Alt key accelerators, the default ring follows the keyboard focus, the Escape key cancels the dialog, the focus traverses the widgets in their visual order, and the directory and file lists gained a horizontal scrollbar and type-ahead selection. The dialog is now transient and centered over its parent, and the SaveFileDialog overwrite confirmation uses a themed message box. Co-Authored-By: Claude Opus 4.8 --- Doc/library/dialog.rst | 22 +- Doc/whatsnew/3.16.rst | 8 + Lib/test/test_tkinter/test_filedialog.py | 100 +++++ Lib/tkinter/filedialog.py | 355 +++++++++++++----- ...6-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst | 14 + 5 files changed, 392 insertions(+), 107 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst diff --git a/Doc/library/dialog.rst b/Doc/library/dialog.rst index 74b23eaf87b4a0..d9a24bbc6c6402 100644 --- a/Doc/library/dialog.rst +++ b/Doc/library/dialog.rst @@ -216,9 +216,19 @@ These do not emulate the native look-and-feel of the platform. .. note:: The *FileDialog* class should be subclassed for custom event handling and behaviour. -.. class:: FileDialog(master, title=None) +.. class:: FileDialog(master, title=None, *, use_ttk=True) Create a basic file selection dialog. + Its layout -- a filter entry, side-by-side directory and file lists, and a + selection entry -- follows the classic Motif file selection dialog. + When *use_ttk* is true (the default), the dialog is built from the themed + :mod:`tkinter.ttk` widgets; when false, from the classic :mod:`tkinter` + widgets. + + .. 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 *use_ttk* parameter. .. method:: cancel_command(event=None) @@ -280,21 +290,27 @@ These do not emulate the native look-and-feel of the platform. Update the current file selection to *file*. -.. class:: LoadFileDialog(master, title=None) +.. class:: LoadFileDialog(master, title=None, *, use_ttk=True) A subclass of FileDialog that creates a dialog window for selecting an existing file. + .. versionchanged:: next + Added the *use_ttk* parameter. + .. method:: ok_command() Test that a file is provided and that the selection indicates an already existing file. -.. class:: SaveFileDialog(master, title=None) +.. class:: SaveFileDialog(master, title=None, *, use_ttk=True) A subclass of FileDialog that creates a dialog window for selecting a destination file. + .. versionchanged:: next + Added the *use_ttk* parameter. + .. method:: ok_command() Test whether or not the selection points to a valid file that is not a diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 64a986f2487d5c..1c49730d142b68 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -236,6 +236,14 @@ tkinter ttk version, and accepts mappings of button options as *buttons* entries. (Contributed by Serhiy Storchaka in :gh:`59396`.) +* The :class:`!tkinter.filedialog.FileDialog` dialog and its + :class:`!tkinter.filedialog.LoadFileDialog` and + :class:`!tkinter.filedialog.SaveFileDialog` subclasses are now built from the + themed :mod:`tkinter.ttk` widgets by default instead of the classic + :mod:`tkinter` widgets, and gained a *use_ttk* parameter to select between + them. + (Contributed by Serhiy Storchaka in :gh:`59396`.) + xml --- diff --git a/Lib/test/test_tkinter/test_filedialog.py b/Lib/test/test_tkinter/test_filedialog.py index 054e719a0f883d..d3069d7908fd8c 100644 --- a/Lib/test/test_tkinter/test_filedialog.py +++ b/Lib/test/test_tkinter/test_filedialog.py @@ -1,6 +1,8 @@ import os import unittest +import tkinter from tkinter import filedialog +from tkinter import ttk from tkinter.commondialog import Dialog from test.support import requires, swap_attr from test.test_tkinter.support import setUpModule # noqa: F401 @@ -68,6 +70,104 @@ def test_subclasses(self): self.assertIsInstance(d, filedialog.FileDialog) self.assertEqual(d.top.title(), cls.title) + # --- Themed widgets and keyboard (modernization) --- + + def open(self, **kw): + d = filedialog.FileDialog(self.root, **kw) + self.addCleanup(lambda: d.top.winfo_exists() and d.top.destroy()) + d.top.deiconify() # __init__ leaves the dialog withdrawn until go() + d.top.update() + return d + + def test_use_ttk(self): + # The dialog uses the themed (ttk) widgets by default. + d = self.open() + self.assertEqual(d.ok_button.winfo_class(), 'TButton') + self.assertEqual(d.selection.winfo_class(), 'TEntry') + + def test_use_classic(self): + # use_ttk=False uses the classic Tk widgets. + d = self.open(use_ttk=False) + self.assertEqual(d.ok_button.winfo_class(), 'Button') + self.assertEqual(d.selection.winfo_class(), 'Entry') + if d.top._windowingsystem == 'x11': + self.assertEqual(str(d.botframe.cget('relief')), 'raised') + + def test_background(self): + # The ttk dialog adopts the ttk background, even a customized one, while + # the classic dialog keeps the default Toplevel background. + style = ttk.Style(self.root) + old = style.lookup('.', 'background') + style.configure('.', background='#123456') + self.addCleanup(style.configure, '.', background=old) + d = self.open() + self.assertEqual(str(d.top.cget('background')), '#123456') + d = self.open(use_ttk=False) + ref = tkinter.Toplevel(self.root) + self.addCleanup(ref.destroy) + self.assertEqual(str(d.top.cget('background')), + str(ref.cget('background'))) + + def test_button_accelerator(self): + # The buttons' "&" accelerators are parsed. + d = self.open() + self.assertEqual(str(d.ok_button.cget('text')), 'OK') + self.assertEqual(int(d.ok_button.cget('underline')), 0) + + def test_default_ring(self): + # The default ring follows the keyboard focus among the buttons. + d = self.open() + self.assertEqual(str(d.cancel_button.cget('default')), 'normal') + d.cancel_button.focus_force() + d.top.update() + self.assertEqual(str(d.cancel_button.cget('default')), 'active') + d.ok_button.focus_force() + d.top.update() + self.assertEqual(str(d.cancel_button.cget('default')), 'normal') + + def test_alt_key(self): + # Alt + the underlined letter invokes the matching button. + d = self.open() + invoked = [] + d.cancel_button.configure(command=lambda: invoked.append(True)) + d.top.focus_force() + d.top.update() + d.top.event_generate('') # "&Cancel" + d.top.update() + self.assertTrue(invoked) + + def test_escape_cancels(self): + # The Escape key cancels the dialog. + d = self.open() + d.how = 'spam' + d.top.focus_force() + d.top.update() + d.top.event_generate('') + d.top.update() + self.assertIsNone(d.how) + + def test_horizontal_scrollbars(self): + # Each list has a horizontal scrollbar besides the vertical one. + d = self.open() + self.assertEqual(str(d.dirshbar.cget('orient')), 'horizontal') + self.assertEqual(str(d.fileshbar.cget('orient')), 'horizontal') + self.assertTrue(d.dirs.cget('xscrollcommand')) + self.assertTrue(d.files.cget('xscrollcommand')) + + def test_type_ahead(self): + # Typing characters over a list jumps to a matching entry. + d = self.open() + d.directory = os.getcwd() # browsing the match fills the selection entry + d.files.delete(0, 'end') + for name in ('alpha', 'bravo', 'charlie'): + d.files.insert('end', name) + d.files.focus_force() + d.top.update() + d.files.event_generate('', keysym='c') + d.top.update() + sel = d.files.curselection() + self.assertEqual([d.files.get(i) for i in sel], ['charlie']) + if __name__ == "__main__": unittest.main() diff --git a/Lib/tkinter/filedialog.py b/Lib/tkinter/filedialog.py index e2eff98e601c07..617e22d5224766 100644 --- a/Lib/tkinter/filedialog.py +++ b/Lib/tkinter/filedialog.py @@ -18,13 +18,16 @@ import fnmatch import os +import tkinter from tkinter import ( - Frame, LEFT, YES, BOTTOM, Entry, TOP, Button, Tk, X, - Toplevel, RIGHT, Y, END, Listbox, BOTH, Scrollbar, + ACTIVE, CENTER, EW, NORMAL, NS, NSEW, RAISED, W, YES, BOTTOM, TOP, Tk, X, + Toplevel, END, Listbox, BOTH, ) -from tkinter.dialog import Dialog +from tkinter import ttk +from tkinter import messagebox from tkinter import commondialog -from tkinter.simpledialog import _setup_dialog +from tkinter.simpledialog import (_setup_dialog, _place_window, _temp_grab_focus, + _underline_ampersand, _find_alt_key_target) dialogstates = {} @@ -34,6 +37,8 @@ class FileDialog: """Standard file selection dialog -- no checks on selected file. + The layout and behavior follow the classic Motif file selection dialog. + Usage: d = FileDialog(master) @@ -55,69 +60,145 @@ class FileDialog: title = "File Selection Dialog" - def __init__(self, master, title=None): + def _widget(self, klass, master, **kw): + # Create a themed (ttk) or classic (tkinter) widget. ttk has no + # Listbox, so the directory and file lists stay classic. + return getattr(ttk if self.use_ttk else tkinter, klass)(master, **kw) + + def _frame(self, master): + # A structural frame. The classic file dialog gives its frames a + # raised border on X11; the themed one does not (cf. tk_dialog). + frame = self._widget('Frame', master) + if not self.use_ttk and self.top._windowingsystem == 'x11': + frame.configure(relief=RAISED, bd=1) + return frame + + def _make_list(self, column, label, browse, activate): + # Create a labelled listbox with vertical and horizontal scrollbars in + # the middle frame, like the Motif file dialog (cf. xmfbox.tcl + # MotifFDialog_MakeSList). + frame = self._widget('Frame', self.midframe) + frame.grid(row=0, column=column, sticky=NSEW, padx='3p', pady='3p') + frame.grid_rowconfigure(1, weight=1) + frame.grid_columnconfigure(0, weight=1) + vbar = self._widget('Scrollbar', frame, takefocus=0) + hbar = self._widget('Scrollbar', frame, orient='horizontal', takefocus=0) + listbox = Listbox(frame, exportselection=0, + xscrollcommand=(hbar, 'set'), + yscrollcommand=(vbar, 'set')) + vbar.configure(command=(listbox, 'yview')) + hbar.configure(command=(listbox, 'xview')) + self._label(frame, label, listbox).grid( + row=0, column=0, columnspan=2, sticky=EW, padx='1.5p', pady='1.5p') + listbox.grid(row=1, column=0, sticky=NSEW) + vbar.grid(row=1, column=1, sticky=NS) + hbar.grid(row=2, column=0, sticky=EW) + # Instance bindings fire after the Listbox class bindings, which have + # already updated the selection. + btags = listbox.bindtags() + listbox.bindtags(btags[1:] + btags[:1]) + listbox.bind('<>', browse) + listbox.bind('', activate) + listbox.bind('', lambda e: (browse(e), activate(e))) + # Type a few characters to jump to a matching entry, like the Motif + # file dialog (cf. xmfbox.tcl ListBoxKeyAccel). + listbox.bind('', self._listbox_keyaccel) + return listbox, vbar, hbar + + def __init__(self, master, title=None, *, use_ttk=True): if title is None: title = self.title self.master = master + self.use_ttk = use_ttk self.directory = None + self._keyaccel = {} # listbox -> recently typed prefix + self._keyaccel_after = {} # listbox -> pending reset callback id self.top = Toplevel(master) + self.top.withdraw() # remain invisible until placed by go() self.top.title(title) self.top.iconname(title) + # Keep the dialog above its parent, like SimpleDialog and Dialog (cf. + # xmfbox.tcl). Skip it when the master is not viewable, or the dialog + # would itself be opened withdrawn. + if master.winfo_viewable(): + self.top.transient(master) _setup_dialog(self.top) - - self.botframe = Frame(self.top) - self.botframe.pack(side=BOTTOM, fill=X) - - self.selection = Entry(self.top) - self.selection.pack(side=BOTTOM, fill=X) - self.selection.bind('', self.ok_event) - - self.filter = Entry(self.top) - self.filter.pack(side=TOP, fill=X) + if self.use_ttk: + # Use a single themed background for the whole dialog so it blends + # with the ttk widgets (cf. tk::MessageBox). + self.top.configure( + background=ttk.Style(self.top).lookup('.', 'background')) + + # Tk traverses focus in widget-creation order, so the widgets are + # created top to bottom -- filter, lists, selection, buttons -- to make + # Tab follow the visual layout, like the Motif file dialog (cf. + # xmfbox.tcl). + self.filter = self._widget('Entry', self.top) self.filter.bind('', self.filter_command) - - self.midframe = Frame(self.top) - self.midframe.pack(expand=YES, fill=BOTH) - - self.filesbar = Scrollbar(self.midframe) - self.filesbar.pack(side=RIGHT, fill=Y) - self.files = Listbox(self.midframe, exportselection=0, - yscrollcommand=(self.filesbar, 'set')) - self.files.pack(side=RIGHT, expand=YES, fill=BOTH) - btags = self.files.bindtags() - self.files.bindtags(btags[1:] + btags[:1]) - self.files.bind('', self.files_select_event) - self.files.bind('', self.files_double_event) - self.filesbar.config(command=(self.files, 'yview')) - - self.dirsbar = Scrollbar(self.midframe) - self.dirsbar.pack(side=LEFT, fill=Y) - self.dirs = Listbox(self.midframe, exportselection=0, - yscrollcommand=(self.dirsbar, 'set')) - self.dirs.pack(side=LEFT, expand=YES, fill=BOTH) - self.dirsbar.config(command=(self.dirs, 'yview')) - btags = self.dirs.bindtags() - self.dirs.bindtags(btags[1:] + btags[:1]) - self.dirs.bind('', self.dirs_select_event) - self.dirs.bind('', self.dirs_double_event) - - self.ok_button = Button(self.botframe, - text="OK", - command=self.ok_command) - self.ok_button.pack(side=LEFT) - self.filter_button = Button(self.botframe, - text="Filter", - command=self.filter_command) - self.filter_button.pack(side=LEFT, expand=YES) - self.cancel_button = Button(self.botframe, - text="Cancel", - command=self.cancel_command) - self.cancel_button.pack(side=RIGHT) + self.filter_label = self._label(self.top, 'Fil&ter:', self.filter) + self.filter_label.pack(side=TOP, fill=X, padx='4.5p', pady='3p') + self.filter.pack(side=TOP, fill=X, padx='3p') + + self.midframe = self._frame(self.top) + self.midframe.pack(side=TOP, expand=YES, fill=BOTH) + + # Directory list (left) and file list (right), each with a label and + # vertical and horizontal scrollbars, like the Motif file dialog (cf. + # xmfbox.tcl). + self.dirs, self.dirsbar, self.dirshbar = self._make_list( + 0, '&Directory:', self.dirs_select_event, self.dirs_double_event) + self.files, self.filesbar, self.fileshbar = self._make_list( + 1, 'Fi&les:', self.files_select_event, self.files_double_event) + + # Give the file list twice the width of the directory list, like the + # Motif file dialog (cf. xmfbox.tcl). + self.midframe.grid_rowconfigure(0, weight=1) + self.midframe.grid_columnconfigure(0, weight=1) + self.midframe.grid_columnconfigure(1, minsize=150, weight=2) + + self.selection = self._widget('Entry', self.top) + self.selection.bind('', self.ok_event) + self.selection_label = self._label(self.top, '&Selection:', self.selection) + self.selection.pack(side=BOTTOM, fill=X, padx='3p', pady='3p') + self.selection_label.pack(side=BOTTOM, fill=X, padx='4.5p') + + # Created last so the buttons traverse last, but packed below the + # selection field. + self.botframe = self._frame(self.top) + self.botframe.pack(side=BOTTOM, fill=X, before=self.selection) + + # 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_command), + ('filter', '&Filter', self.filter_command), + ('cancel', '&Cancel', self.cancel_command))): + # Create a button with an accelerator key marked by "&" in the text, + # like SimpleDialog and Dialog (cf. tk::AmpWidget). + text, underline = _underline_ampersand(label) + button = self._widget('Button', self.botframe, name=name, text=text, + underline=underline, command=command, + default=ACTIVE if name == 'ok' else NORMAL) + button.bind('<>', lambda e: e.widget.invoke()) + button.grid(column=i, row=0, sticky=EW, padx=padx, pady=pady) + # tk::MessageBox makes the buttons equal width; tk_dialog does not. + self.botframe.grid_columnconfigure( + i, uniform='buttons' if self.use_ttk else '') + if self.top._windowingsystem == 'aqua': + self.botframe.grid_columnconfigure(i, minsize=90) + button.grid_configure(pady=7) + setattr(self, name + '_button', button) + self.botframe.grid_anchor(CENTER) self.top.protocol('WM_DELETE_WINDOW', self.cancel_command) - # XXX Are the following okay for a general audience? - self.top.bind('', self.cancel_command) - self.top.bind('', self.cancel_command) + # Alt + an underlined character invokes the matching button (cf. + # ::tk::AltKeyInDialog), like SimpleDialog and Dialog. + self.top.bind('', self._alt_key) + # The default ring follows the keyboard focus among the buttons (cf. + # tk::MessageBox), like SimpleDialog and Dialog. + self.top.bind('', lambda e: self._set_default(e.widget, ACTIVE)) + self.top.bind('', lambda e: self._set_default(e.widget, NORMAL)) + self.top.bind('', self.cancel_command) def go(self, dir_or_file=os.curdir, pattern="*", default="", key=None): if key and key in dialogstates: @@ -131,11 +212,17 @@ def go(self, dir_or_file=os.curdir, pattern="*", default="", key=None): self.set_filter(self.directory, pattern) self.set_selection(default) self.filter_command() - self.selection.focus_set() + # Center the dialog over its parent and make it visible, like + # SimpleDialog and Dialog (cf. xmfbox.tcl). + _place_window(self.top, self.master) + self.selection.select_range(0, END) # so the user can type a new name self.top.wait_visibility() # window needs to be visible for the grab - self.top.grab_set() self.how = None - self.master.mainloop() # Exited by self.quit(how) + # Grab the input and restore the previous focus and grab afterwards, + # like SimpleDialog and Dialog. go() destroys the window itself below, + # so leave it in place here. + with _temp_grab_focus(self.top, self.selection, destroy=False): + self.master.mainloop() # Exited by self.quit(how) if key: directory, pattern = self.get_filter() if self.how: @@ -148,21 +235,91 @@ def quit(self, how=None): self.how = how self.master.quit() # Exit mainloop() + def _alt_key(self, event): + # Invoke the button whose accelerator matches the Alt key (cf. + # SimpleDialog and Dialog). + target = _find_alt_key_target(self.top, 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) + + def _label(self, master, label, target): + # Create a field label whose "&" accelerator focuses the target widget, + # like the labels of the Motif file dialog (cf. xmfbox.tcl). + text, underline = _underline_ampersand(label) + widget = self._widget('Label', master, text=text, underline=underline, + anchor=W) + widget.bind('<>', lambda e: target.focus_set()) + return widget + + def _listbox_keyaccel(self, event): + # Append the typed character and jump to a matching entry, resetting + # after a short pause (cf. xmfbox.tcl ListBoxKeyAccel_Key). + char = event.char + if not char or not char.isprintable(): + return + listbox = event.widget + prefix = self._keyaccel.get(listbox, '') + char + self._keyaccel[listbox] = prefix + self._listbox_keyaccel_goto(listbox, prefix) + after_id = self._keyaccel_after.get(listbox) + if after_id is not None: + listbox.after_cancel(after_id) + self._keyaccel_after[listbox] = listbox.after( + 500, lambda: self._keyaccel.pop(listbox, None)) + + def _listbox_keyaccel_goto(self, listbox, prefix): + # Select the first entry not preceding the typed prefix (cf. xmfbox.tcl + # ListBoxKeyAccel_Goto). + prefix = prefix.lower() + index = -1 + for i in range(listbox.size()): + item = listbox.get(i).lower() + if prefix >= item: + index = i + if prefix <= item: + index = i + break + if index >= 0: + listbox.selection_clear(0, END) + listbox.selection_set(index) + listbox.activate(index) + listbox.see(index) + listbox.event_generate('<>') + def dirs_double_event(self, event): self.filter_command() + # Highlight an entry so the directory list stays keyboard-navigable + # after entering a directory, like the Motif file dialog (cf. + # xmfbox.tcl); prefer the first subdirectory over the ".." entry. + index = 1 if self.dirs.size() > 1 else 0 + self.dirs.selection_set(index) + self.dirs.activate(index) def dirs_select_event(self, event): + # Show a selection in only one list at a time (cf. xmfbox.tcl). + self.files.selection_clear(0, END) dir, pat = self.get_filter() subdir = self.dirs.get('active') dir = os.path.normpath(os.path.join(self.directory, subdir)) self.set_filter(dir, pat) + # Show the end of a long path, like the Motif file dialog (cf. + # xmfbox.tcl). + self.filter.xview(END) def files_double_event(self, event): self.ok_command() def files_select_event(self, event): + # Show a selection in only one list at a time (cf. xmfbox.tcl). + self.dirs.selection_clear(0, END) file = self.files.get('active') self.set_selection(file) + self.selection.xview(END) def ok_event(self, event): self.ok_command() @@ -256,13 +413,10 @@ def ok_command(self): if os.path.isdir(file): self.master.bell() return - d = Dialog(self.top, - title="Overwrite Existing File Question", - text="Overwrite existing file %r?" % (file,), - bitmap='questhead', - default=1, - strings=("Yes", "Cancel")) - if d.num != 0: + if not messagebox.askyesno( + "Overwrite Existing File", + "Overwrite existing file %r?" % (file,), + icon=messagebox.WARNING, parent=self.top): return else: head, tail = os.path.split(file) @@ -448,44 +602,37 @@ def askdirectory (**options): def test(): """Simple test program.""" root = Tk() - root.withdraw() - fd = LoadFileDialog(root) - loadfile = fd.go(key="test") - fd = SaveFileDialog(root) - savefile = fd.go(key="test") - print(loadfile, savefile) - - # Since the file name may contain non-ASCII characters, we need - # to find an encoding that likely supports the file name, and - # displays correctly on the terminal. - - # Start off with UTF-8 - enc = "utf-8" - - # See whether CODESET is defined - try: - import locale - locale.setlocale(locale.LC_ALL,'') - enc = locale.nl_langinfo(locale.CODESET) - except (ImportError, AttributeError): - pass - - # dialog for opening files - - openfilename=askopenfilename(filetypes=[("all files", "*")]) - try: - fp=open(openfilename,"r") - fp.close() - except BaseException as exc: - print("Could not open File: ") - print(exc) - - print("open", openfilename.encode(enc)) - - # dialog for saving files - - saveasfilename=asksaveasfilename() - print("saveas", saveasfilename.encode(enc)) + use_ttk = tkinter.BooleanVar(root, value=True) + + def test_load(): + print('load:', LoadFileDialog(root, use_ttk=use_ttk.get()).go(key='test')) + + def test_save(): + print('save:', SaveFileDialog(root, use_ttk=use_ttk.get()).go(key='test')) + + def test_open(): + print('open:', askopenfilename(filetypes=[('all files', '*')])) + + def test_saveas(): + print('saveas:', asksaveasfilename()) + + def test_directory(): + print('directory:', askdirectory()) + + def test_directory_mustexist(): + print('directory (mustexist):', askdirectory(mustexist=True)) + + tkinter.Checkbutton(root, text='Use themed (ttk) widgets', + variable=use_ttk).pack(fill=X) + tkinter.Button(root, text='LoadFileDialog', command=test_load).pack(fill=X) + tkinter.Button(root, text='SaveFileDialog', command=test_save).pack(fill=X) + tkinter.Button(root, text='askopenfilename', command=test_open).pack(fill=X) + tkinter.Button(root, text='asksaveasfilename', command=test_saveas).pack(fill=X) + tkinter.Button(root, text='askdirectory', command=test_directory).pack(fill=X) + tkinter.Button(root, text='askdirectory (mustexist)', + command=test_directory_mustexist).pack(fill=X) + tkinter.Button(root, text='Quit', command=root.quit).pack(fill=X) + root.mainloop() if __name__ == '__main__': diff --git a/Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst b/Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst new file mode 100644 index 00000000000000..890a29cb7adf95 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst @@ -0,0 +1,14 @@ +The :class:`!tkinter.filedialog.FileDialog` dialog and its +:class:`!tkinter.filedialog.LoadFileDialog` and +:class:`!tkinter.filedialog.SaveFileDialog` subclasses, which follow the layout +of the classic Motif file selection dialog, were modernized to match its look +and feel more closely. +They are now built from the themed :mod:`tkinter.ttk` widgets instead of the +classic :mod:`tkinter` widgets, and gained a *use_ttk* parameter that selects +between the classic Tk widgets and the themed ttk widgets. +The buttons and field labels gained :kbd:`Alt` key accelerators, the default +ring follows the keyboard focus, and the :kbd:`Escape` key cancels the dialog. +The directory and file lists gained a horizontal scrollbar and type-ahead +selection. +The dialog is now centered over its parent, and the overwrite confirmation of +:class:`!SaveFileDialog` uses a themed message box. From 7a97e4bdc5bee55526a1321b11912bd9abbf02aa Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 27 Jun 2026 11:09:03 +0300 Subject: [PATCH 2/2] gh-59396: Tolerate absent tkinter dialog submodules in idlelib.run filedialog no longer imports tkinter.dialog, so that submodule is not always present when run.py undoes idlelib's tkinter imports; skip the ones that are missing instead of raising. Co-Authored-By: Claude Opus 4.8 --- Lib/idlelib/run.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/idlelib/run.py b/Lib/idlelib/run.py index e1c40fee8f4805..acbed3d84162d4 100644 --- a/Lib/idlelib/run.py +++ b/Lib/idlelib/run.py @@ -30,11 +30,17 @@ import tkinter # Use tcl and, if startup fails, messagebox. if not hasattr(sys.modules['idlelib.run'], 'firstrun'): # Undo modifications of tkinter by idlelib imports; see bpo-25507. + # Which of these submodules got imported (and thus added as a tkinter + # attribute) depends on what idlelib pulled in, so tolerate missing + # ones rather than assuming a fixed set; see gh-59396. for mod in ('simpledialog', 'messagebox', 'font', 'dialog', 'filedialog', 'commondialog', 'ttk'): - delattr(tkinter, mod) - del sys.modules['tkinter.' + mod] + try: + delattr(tkinter, mod) + del sys.modules['tkinter.' + mod] + except (AttributeError, KeyError): + pass # Avoid AttributeError if run again; see bpo-37038. sys.modules['idlelib.run'].firstrun = False