Skip to content

Commit 9fce9ed

Browse files
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 <noreply@anthropic.com>
1 parent 5e0747d commit 9fce9ed

5 files changed

Lines changed: 392 additions & 107 deletions

File tree

Doc/library/dialog.rst

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,19 @@ These do not emulate the native look-and-feel of the platform.
216216
.. note:: The *FileDialog* class should be subclassed for custom event
217217
handling and behaviour.
218218

219-
.. class:: FileDialog(master, title=None)
219+
.. class:: FileDialog(master, title=None, *, use_ttk=True)
220220

221221
Create a basic file selection dialog.
222+
Its layout -- a filter entry, side-by-side directory and file lists, and a
223+
selection entry -- follows the classic Motif file selection dialog.
224+
When *use_ttk* is true (the default), the dialog is built from the themed
225+
:mod:`tkinter.ttk` widgets; when false, from the classic :mod:`tkinter`
226+
widgets.
227+
228+
.. versionchanged:: next
229+
The dialog is now built from the themed :mod:`tkinter.ttk` widgets by
230+
default, instead of the classic :mod:`tkinter` widgets.
231+
Added the *use_ttk* parameter.
222232

223233
.. method:: cancel_command(event=None)
224234

@@ -280,21 +290,27 @@ These do not emulate the native look-and-feel of the platform.
280290
Update the current file selection to *file*.
281291

282292

283-
.. class:: LoadFileDialog(master, title=None)
293+
.. class:: LoadFileDialog(master, title=None, *, use_ttk=True)
284294

285295
A subclass of FileDialog that creates a dialog window for selecting an
286296
existing file.
287297

298+
.. versionchanged:: next
299+
Added the *use_ttk* parameter.
300+
288301
.. method:: ok_command()
289302

290303
Test that a file is provided and that the selection indicates an
291304
already existing file.
292305

293-
.. class:: SaveFileDialog(master, title=None)
306+
.. class:: SaveFileDialog(master, title=None, *, use_ttk=True)
294307

295308
A subclass of FileDialog that creates a dialog window for selecting a
296309
destination file.
297310

311+
.. versionchanged:: next
312+
Added the *use_ttk* parameter.
313+
298314
.. method:: ok_command()
299315

300316
Test whether or not the selection points to a valid file that is not a

Doc/whatsnew/3.16.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,14 @@ tkinter
236236
ttk version, and accepts mappings of button options as *buttons* entries.
237237
(Contributed by Serhiy Storchaka in :gh:`59396`.)
238238

239+
* The :class:`!tkinter.filedialog.FileDialog` dialog and its
240+
:class:`!tkinter.filedialog.LoadFileDialog` and
241+
:class:`!tkinter.filedialog.SaveFileDialog` subclasses are now built from the
242+
themed :mod:`tkinter.ttk` widgets by default instead of the classic
243+
:mod:`tkinter` widgets, and gained a *use_ttk* parameter to select between
244+
them.
245+
(Contributed by Serhiy Storchaka in :gh:`59396`.)
246+
239247
xml
240248
---
241249

Lib/test/test_tkinter/test_filedialog.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
22
import unittest
3+
import tkinter
34
from tkinter import filedialog
5+
from tkinter import ttk
46
from tkinter.commondialog import Dialog
57
from test.support import requires, swap_attr
68
from test.test_tkinter.support import setUpModule # noqa: F401
@@ -68,6 +70,104 @@ def test_subclasses(self):
6870
self.assertIsInstance(d, filedialog.FileDialog)
6971
self.assertEqual(d.top.title(), cls.title)
7072

73+
# --- Themed widgets and keyboard (modernization) ---
74+
75+
def open(self, **kw):
76+
d = filedialog.FileDialog(self.root, **kw)
77+
self.addCleanup(lambda: d.top.winfo_exists() and d.top.destroy())
78+
d.top.deiconify() # __init__ leaves the dialog withdrawn until go()
79+
d.top.update()
80+
return d
81+
82+
def test_use_ttk(self):
83+
# The dialog uses the themed (ttk) widgets by default.
84+
d = self.open()
85+
self.assertEqual(d.ok_button.winfo_class(), 'TButton')
86+
self.assertEqual(d.selection.winfo_class(), 'TEntry')
87+
88+
def test_use_classic(self):
89+
# use_ttk=False uses the classic Tk widgets.
90+
d = self.open(use_ttk=False)
91+
self.assertEqual(d.ok_button.winfo_class(), 'Button')
92+
self.assertEqual(d.selection.winfo_class(), 'Entry')
93+
if d.top._windowingsystem == 'x11':
94+
self.assertEqual(str(d.botframe.cget('relief')), 'raised')
95+
96+
def test_background(self):
97+
# The ttk dialog adopts the ttk background, even a customized one, while
98+
# the classic dialog keeps the default Toplevel background.
99+
style = ttk.Style(self.root)
100+
old = style.lookup('.', 'background')
101+
style.configure('.', background='#123456')
102+
self.addCleanup(style.configure, '.', background=old)
103+
d = self.open()
104+
self.assertEqual(str(d.top.cget('background')), '#123456')
105+
d = self.open(use_ttk=False)
106+
ref = tkinter.Toplevel(self.root)
107+
self.addCleanup(ref.destroy)
108+
self.assertEqual(str(d.top.cget('background')),
109+
str(ref.cget('background')))
110+
111+
def test_button_accelerator(self):
112+
# The buttons' "&" accelerators are parsed.
113+
d = self.open()
114+
self.assertEqual(str(d.ok_button.cget('text')), 'OK')
115+
self.assertEqual(int(d.ok_button.cget('underline')), 0)
116+
117+
def test_default_ring(self):
118+
# The default ring follows the keyboard focus among the buttons.
119+
d = self.open()
120+
self.assertEqual(str(d.cancel_button.cget('default')), 'normal')
121+
d.cancel_button.focus_force()
122+
d.top.update()
123+
self.assertEqual(str(d.cancel_button.cget('default')), 'active')
124+
d.ok_button.focus_force()
125+
d.top.update()
126+
self.assertEqual(str(d.cancel_button.cget('default')), 'normal')
127+
128+
def test_alt_key(self):
129+
# Alt + the underlined letter invokes the matching button.
130+
d = self.open()
131+
invoked = []
132+
d.cancel_button.configure(command=lambda: invoked.append(True))
133+
d.top.focus_force()
134+
d.top.update()
135+
d.top.event_generate('<Alt-c>') # "&Cancel"
136+
d.top.update()
137+
self.assertTrue(invoked)
138+
139+
def test_escape_cancels(self):
140+
# The Escape key cancels the dialog.
141+
d = self.open()
142+
d.how = 'spam'
143+
d.top.focus_force()
144+
d.top.update()
145+
d.top.event_generate('<Escape>')
146+
d.top.update()
147+
self.assertIsNone(d.how)
148+
149+
def test_horizontal_scrollbars(self):
150+
# Each list has a horizontal scrollbar besides the vertical one.
151+
d = self.open()
152+
self.assertEqual(str(d.dirshbar.cget('orient')), 'horizontal')
153+
self.assertEqual(str(d.fileshbar.cget('orient')), 'horizontal')
154+
self.assertTrue(d.dirs.cget('xscrollcommand'))
155+
self.assertTrue(d.files.cget('xscrollcommand'))
156+
157+
def test_type_ahead(self):
158+
# Typing characters over a list jumps to a matching entry.
159+
d = self.open()
160+
d.directory = os.getcwd() # browsing the match fills the selection entry
161+
d.files.delete(0, 'end')
162+
for name in ('alpha', 'bravo', 'charlie'):
163+
d.files.insert('end', name)
164+
d.files.focus_force()
165+
d.top.update()
166+
d.files.event_generate('<Key>', keysym='c')
167+
d.top.update()
168+
sel = d.files.curselection()
169+
self.assertEqual([d.files.get(i) for i in sel], ['charlie'])
170+
71171

72172
if __name__ == "__main__":
73173
unittest.main()

0 commit comments

Comments
 (0)