Skip to content

Commit 3d9d6c8

Browse files
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 <noreply@anthropic.com>
1 parent 856049a commit 3d9d6c8

6 files changed

Lines changed: 400 additions & 109 deletions

File tree

Doc/library/dialog.rst

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

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

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

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

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

283293

284-
.. class:: LoadFileDialog(master, title=None)
294+
.. class:: LoadFileDialog(master, title=None, *, use_ttk=True)
285295

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

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

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

294-
.. class:: SaveFileDialog(master, title=None)
307+
.. class:: SaveFileDialog(master, title=None, *, use_ttk=True)
295308

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

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

301317
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
@@ -383,6 +383,14 @@ tkinter
383383
ttk version, and accepts mappings of button options as *buttons* entries.
384384
(Contributed by Serhiy Storchaka in :gh:`59396`.)
385385

386+
* The :class:`!tkinter.filedialog.FileDialog` dialog and its
387+
:class:`!tkinter.filedialog.LoadFileDialog` and
388+
:class:`!tkinter.filedialog.SaveFileDialog` subclasses are now built from the
389+
themed :mod:`tkinter.ttk` widgets by default instead of the classic
390+
:mod:`tkinter` widgets, and gained a *use_ttk* parameter to select between
391+
them.
392+
(Contributed by Serhiy Storchaka in :gh:`59396`.)
393+
386394
xml
387395
---
388396

Lib/idlelib/run.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,17 @@
3131
import tkinter # Use tcl and, if startup fails, messagebox.
3232
if not hasattr(sys.modules['idlelib.run'], 'firstrun'):
3333
# Undo modifications of tkinter by idlelib imports; see bpo-25507.
34+
# Which of these submodules got imported (and thus added as a tkinter
35+
# attribute) depends on what idlelib pulled in, so tolerate missing
36+
# ones rather than assuming a fixed set; see gh-59396.
3437
for mod in ('simpledialog', 'messagebox', 'font',
3538
'dialog', 'filedialog', 'commondialog',
3639
'ttk'):
37-
delattr(tkinter, mod)
38-
del sys.modules['tkinter.' + mod]
40+
try:
41+
delattr(tkinter, mod)
42+
del sys.modules['tkinter.' + mod]
43+
except (AttributeError, KeyError):
44+
pass
3945
# Avoid AttributeError if run again; see bpo-37038.
4046
sys.modules['idlelib.run'].firstrun = False
4147

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
@@ -101,6 +103,104 @@ def test_subclasses(self):
101103
self.assertIsInstance(d, filedialog.FileDialog)
102104
self.assertEqual(d.top.title(), cls.title)
103105

106+
# --- Themed widgets and keyboard (modernization) ---
107+
108+
def open(self, **kw):
109+
d = filedialog.FileDialog(self.root, **kw)
110+
self.addCleanup(lambda: d.top.winfo_exists() and d.top.destroy())
111+
d.top.deiconify() # __init__ leaves the dialog withdrawn until go()
112+
d.top.update()
113+
return d
114+
115+
def test_use_ttk(self):
116+
# The dialog uses the themed (ttk) widgets by default.
117+
d = self.open()
118+
self.assertEqual(d.ok_button.winfo_class(), 'TButton')
119+
self.assertEqual(d.selection.winfo_class(), 'TEntry')
120+
121+
def test_use_classic(self):
122+
# use_ttk=False uses the classic Tk widgets.
123+
d = self.open(use_ttk=False)
124+
self.assertEqual(d.ok_button.winfo_class(), 'Button')
125+
self.assertEqual(d.selection.winfo_class(), 'Entry')
126+
if d.top._windowingsystem == 'x11':
127+
self.assertEqual(str(d.botframe.cget('relief')), 'raised')
128+
129+
def test_background(self):
130+
# The ttk dialog adopts the ttk background, even a customized one, while
131+
# the classic dialog keeps the default Toplevel background.
132+
style = ttk.Style(self.root)
133+
old = style.lookup('.', 'background')
134+
style.configure('.', background='#123456')
135+
self.addCleanup(style.configure, '.', background=old)
136+
d = self.open()
137+
self.assertEqual(str(d.top.cget('background')), '#123456')
138+
d = self.open(use_ttk=False)
139+
ref = tkinter.Toplevel(self.root)
140+
self.addCleanup(ref.destroy)
141+
self.assertEqual(str(d.top.cget('background')),
142+
str(ref.cget('background')))
143+
144+
def test_button_accelerator(self):
145+
# The buttons' "&" accelerators are parsed.
146+
d = self.open()
147+
self.assertEqual(str(d.ok_button.cget('text')), 'OK')
148+
self.assertEqual(int(d.ok_button.cget('underline')), 0)
149+
150+
def test_default_ring(self):
151+
# The default ring follows the keyboard focus among the buttons.
152+
d = self.open()
153+
self.assertEqual(str(d.cancel_button.cget('default')), 'normal')
154+
d.cancel_button.focus_force()
155+
d.top.update()
156+
self.assertEqual(str(d.cancel_button.cget('default')), 'active')
157+
d.ok_button.focus_force()
158+
d.top.update()
159+
self.assertEqual(str(d.cancel_button.cget('default')), 'normal')
160+
161+
def test_alt_key(self):
162+
# Alt + the underlined letter invokes the matching button.
163+
d = self.open()
164+
invoked = []
165+
d.cancel_button.configure(command=lambda: invoked.append(True))
166+
d.top.focus_force()
167+
d.top.update()
168+
d.top.event_generate('<Alt-c>') # "&Cancel"
169+
d.top.update()
170+
self.assertTrue(invoked)
171+
172+
def test_escape_cancels(self):
173+
# The Escape key cancels the dialog.
174+
d = self.open()
175+
d.how = 'spam'
176+
d.top.focus_force()
177+
d.top.update()
178+
d.top.event_generate('<Escape>')
179+
d.top.update()
180+
self.assertIsNone(d.how)
181+
182+
def test_horizontal_scrollbars(self):
183+
# Each list has a horizontal scrollbar besides the vertical one.
184+
d = self.open()
185+
self.assertEqual(str(d.dirshbar.cget('orient')), 'horizontal')
186+
self.assertEqual(str(d.fileshbar.cget('orient')), 'horizontal')
187+
self.assertTrue(d.dirs.cget('xscrollcommand'))
188+
self.assertTrue(d.files.cget('xscrollcommand'))
189+
190+
def test_type_ahead(self):
191+
# Typing characters over a list jumps to a matching entry.
192+
d = self.open()
193+
d.directory = os.getcwd() # browsing the match fills the selection entry
194+
d.files.delete(0, 'end')
195+
for name in ('alpha', 'bravo', 'charlie'):
196+
d.files.insert('end', name)
197+
d.files.focus_force()
198+
d.top.update()
199+
d.files.event_generate('<Key>', keysym='c')
200+
d.top.update()
201+
sel = d.files.curselection()
202+
self.assertEqual([d.files.get(i) for i in sel], ['charlie'])
203+
104204

105205
if __name__ == "__main__":
106206
unittest.main()

0 commit comments

Comments
 (0)