https://github.com/python/cpython/commit/3d9d6c8ccbc2a041b8d29c67516d1728442e4f41
commit: 3d9d6c8ccbc2a041b8d29c67516d1728442e4f41
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-07-05T14:27:51+03:00
summary:

gh-59396: Use themed widgets in tkinter.filedialog (GH-152036)

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 <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst
M Doc/library/dialog.rst
M Doc/whatsnew/3.16.rst
M Lib/idlelib/run.py
M Lib/test/test_tkinter/test_filedialog.py
M Lib/tkinter/filedialog.py

diff --git a/Doc/library/dialog.rst b/Doc/library/dialog.rst
index a576cdcfaa2b1e9..06f2d6ddbf4f48b 100644
--- a/Doc/library/dialog.rst
+++ b/Doc/library/dialog.rst
@@ -217,9 +217,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)
 
@@ -281,21 +291,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 1a73a79a58b78b1..8219700cc1e8385 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -383,6 +383,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 f5564ccf90f7f46..802f0248e4e4594 100644
--- a/Lib/idlelib/run.py
+++ b/Lib/idlelib/run.py
@@ -31,11 +31,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 758ef1479ef2cfa..b89862d3fcdcb2b 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
@@ -101,6 +103,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('<Alt-c>')  # "&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('<Escape>')
+        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('<Key>', 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 23cb19249a92835..29020475a3cb538 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('<<ListboxSelect>>', browse)
+        listbox.bind('<Double-ButtonRelease-1>', activate)
+        listbox.bind('<Return>', 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('<Key>', 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('<Return>', 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('<Return>', 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('<ButtonRelease-1>', self.files_select_event)
-        self.files.bind('<Double-ButtonRelease-1>', 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('<ButtonRelease-1>', self.dirs_select_event)
-        self.dirs.bind('<Double-ButtonRelease-1>', 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('<Return>', 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('<<AltUnderlined>>', 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('<Alt-w>', self.cancel_command)
-        self.top.bind('<Alt-W>', self.cancel_command)
+        # Alt + an underlined character invokes the matching button (cf.
+        # ::tk::AltKeyInDialog), like SimpleDialog and Dialog.
+        self.top.bind('<Alt-Key>', self._alt_key)
+        # The default ring follows the keyboard focus among the buttons (cf.
+        # tk::MessageBox), like SimpleDialog and Dialog.
+        self.top.bind('<FocusIn>', lambda e: self._set_default(e.widget, 
ACTIVE))
+        self.top.bind('<FocusOut>', lambda e: self._set_default(e.widget, 
NORMAL))
+        self.top.bind('<Escape>', 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('<<AltUnderlined>>')
+
+    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('<<AltUnderlined>>', 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('<<ListboxSelect>>')
+
     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)
@@ -446,44 +600,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 000000000000000..890a29cb7adf95d
--- /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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to