Files
cpython/Lib/tkinter/filedialog.py
T
3d9d6c8ccb 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]>
2026-07-05 14:27:51 +03:00

638 lines
23 KiB
Python

"""File selection dialog classes.
Classes:
- FileDialog
- LoadFileDialog
- SaveFileDialog
This module also presents tk common file dialogues, it provides interfaces
to the native file dialogues available in Tk 4.2 and newer, and the
directory dialogue available in Tk 8.3 and newer.
These interfaces were written by Fredrik Lundh, May 1997.
"""
__all__ = ["FileDialog", "LoadFileDialog", "SaveFileDialog",
"Open", "SaveAs", "Directory",
"askopenfilename", "asksaveasfilename", "askopenfilenames",
"askopenfile", "askopenfiles", "asksaveasfile", "askdirectory"]
import fnmatch
import os
import tkinter
from tkinter import (
ACTIVE, CENTER, EW, NORMAL, NS, NSEW, RAISED, W, YES, BOTTOM, TOP, Tk, X,
Toplevel, END, Listbox, BOTH,
)
from tkinter import ttk
from tkinter import messagebox
from tkinter import commondialog
from tkinter.simpledialog import (_setup_dialog, _place_window, _temp_grab_focus,
_underline_ampersand, _find_alt_key_target)
dialogstates = {}
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)
fname = d.go(dir_or_file, pattern, default, key)
if fname is None: ...canceled...
else: ...open file...
All arguments to go() are optional.
The 'key' argument specifies a key in the global dictionary
'dialogstates', which keeps track of the values for the directory
and pattern arguments, overriding the values passed in (it does
not keep track of the default argument!). If no key is specified,
the dialog keeps no memory of previous state. Note that memory is
kept even when the dialog is canceled. (All this emulates the
behavior of the Macintosh file selection dialogs.)
"""
title = "File Selection Dialog"
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)
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.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)
# 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:
self.directory, pattern = dialogstates[key]
else:
dir_or_file = os.path.expanduser(dir_or_file)
if os.path.isdir(dir_or_file):
self.directory = dir_or_file
else:
self.directory, default = os.path.split(dir_or_file)
self.set_filter(self.directory, pattern)
self.set_selection(default)
self.filter_command()
# 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.how = None
# 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:
directory = os.path.dirname(self.how)
dialogstates[key] = directory, pattern
self.top.destroy()
return self.how
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()
def ok_command(self):
self.quit(self.get_selection())
def filter_command(self, event=None):
dir, pat = self.get_filter()
try:
names = os.listdir(dir)
except OSError:
self.master.bell()
return
self.directory = dir
self.set_filter(dir, pat)
names.sort()
subdirs = [os.pardir]
matchingfiles = []
for name in names:
fullname = os.path.join(dir, name)
if os.path.isdir(fullname):
subdirs.append(name)
elif fnmatch.fnmatch(name, pat):
matchingfiles.append(name)
self.dirs.delete(0, END)
for name in subdirs:
self.dirs.insert(END, name)
self.files.delete(0, END)
for name in matchingfiles:
self.files.insert(END, name)
head, tail = os.path.split(self.get_selection())
if tail == os.curdir: tail = ''
self.set_selection(tail)
def get_filter(self):
filter = self.filter.get()
filter = os.path.expanduser(filter)
if filter[-1:] == os.sep or os.path.isdir(filter):
filter = os.path.join(filter, "*")
return os.path.split(filter)
def get_selection(self):
file = self.selection.get()
file = os.path.expanduser(file)
return file
def cancel_command(self, event=None):
self.quit()
def set_filter(self, dir, pat):
if not os.path.isabs(dir):
try:
pwd = os.getcwd()
except OSError:
pwd = None
if pwd:
dir = os.path.join(pwd, dir)
dir = os.path.normpath(dir)
self.filter.delete(0, END)
self.filter.insert(END, os.path.join(dir or os.curdir, pat or "*"))
def set_selection(self, file):
self.selection.delete(0, END)
self.selection.insert(END, os.path.join(self.directory, file))
class LoadFileDialog(FileDialog):
"""File selection dialog which checks that the file exists."""
title = "Load File Selection Dialog"
def ok_command(self):
file = self.get_selection()
if not os.path.isfile(file):
self.master.bell()
else:
self.quit(file)
class SaveFileDialog(FileDialog):
"""File selection dialog which checks that the file may be created."""
title = "Save File Selection Dialog"
def ok_command(self):
file = self.get_selection()
if os.path.exists(file):
if os.path.isdir(file):
self.master.bell()
return
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)
if not os.path.isdir(head):
self.master.bell()
return
self.quit(file)
# For the following classes and modules:
#
# options (all have default values):
#
# - defaultextension: added to filename if not explicitly given
#
# - filetypes: sequence of (label, pattern) tuples. the same pattern
# may occur with several patterns. use "*" as pattern to indicate
# all files.
#
# - initialdir: initial directory. preserved by dialog instance.
#
# - initialfile: initial file (ignored by the open dialog). preserved
# by dialog instance.
#
# - parent: which window to place the dialog on top of
#
# - title: dialog title
#
# - multiple: if true user may select more than one file
#
# options for the directory chooser:
#
# - initialdir, parent, title: see above
#
# - mustexist: if true, user must pick an existing directory
#
class _Dialog(commondialog.Dialog):
def _fixoptions(self):
try:
# make sure "filetypes" is a tuple
self.options["filetypes"] = tuple(self.options["filetypes"])
except KeyError:
pass
def _fixresult(self, widget, result):
if not result:
result = '' # normalize the cancelled result (gh-103878)
else:
# keep directory and filename until next time
# convert Tcl path objects to strings
try:
result = result.string
except AttributeError:
# it already is a string
pass
path, file = os.path.split(result)
self.options["initialdir"] = path
self.options["initialfile"] = file
self.filename = result # compatibility
return result
#
# file dialogs
class Open(_Dialog):
"Ask for a filename to open"
command = "tk_getOpenFile"
def _fixresult(self, widget, result):
if self.options.get("multiple"):
# multiple results: a tuple of filenames
if not isinstance(result, tuple):
result = widget.tk.splitlist(result)
result = tuple([getattr(r, "string", r) for r in result])
if result:
path, file = os.path.split(result[0])
self.options["initialdir"] = path
# don't set initialfile or filename, as we have multiple of these
return result
return _Dialog._fixresult(self, widget, result)
class SaveAs(_Dialog):
"Ask for a filename to save as"
command = "tk_getSaveFile"
# the directory dialog has its own _fix routines.
class Directory(commondialog.Dialog):
"Ask for a directory"
command = "tk_chooseDirectory"
def _fixresult(self, widget, result):
if not result:
result = '' # normalize the cancelled result (gh-103878)
else:
# convert Tcl path objects to strings
try:
result = result.string
except AttributeError:
# it already is a string
pass
# keep directory until next time
self.options["initialdir"] = result
self.directory = result # compatibility
return result
#
# convenience stuff
def askopenfilename(**options):
"Ask for a filename to open"
return Open(**options).show()
def asksaveasfilename(**options):
"Ask for a filename to save as"
return SaveAs(**options).show()
def askopenfilenames(**options):
"""Ask for multiple filenames to open
Returns a list of filenames or empty list if
cancel button selected
"""
options["multiple"]=1
return Open(**options).show()
# FIXME: are the following perhaps a bit too convenient?
def askopenfile(mode = "r", **options):
"Ask for a filename to open, and returned the opened file"
filename = Open(**options).show()
if filename:
return open(filename, mode)
return None
def askopenfiles(mode = "r", **options):
"""Ask for multiple filenames and return the open file
objects
returns a list of open file objects or an empty list if
cancel selected
"""
files = askopenfilenames(**options)
return [open(filename, mode) for filename in files]
def asksaveasfile(mode = "w", **options):
"Ask for a filename to save as, and returned the opened file"
filename = SaveAs(**options).show()
if filename:
return open(filename, mode)
return None
def askdirectory (**options):
"Ask for a directory, and return the file name"
return Directory(**options).show()
# --------------------------------------------------------------------
# test stuff
def test():
"""Simple test program."""
root = Tk()
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__':
test()