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 = {}
"""Standard file selection dialog -- no checks on selected file.
+ The layout and behavior follow the classic Motif file selection dialog.
+
Usage:
d = FileDialog(master)
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:
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:
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()
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)
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__':