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/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 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.