"""
GUI tools using tkinter. The multicolumn list is inspired by a post on
Stackoverflow:
http://stackoverflow.com/questions/5286093/\
display-listbox-with-columns-using-tkinter
"""
import os
import sys
from functools import wraps
try:
import tkinter as tk
from tkinter import filedialog
import tkinter.font as tkFont
from tkinter import ttk
except ImportError:
HAVE_TKINTER = False
else:
HAVE_TKINTER = True
LASTOPENDIR = None
LASTSAVEDIR = None
__all__ = [
"get_file_name",
"askopenfilename",
"asksaveasfilename",
"read_text_file",
"write_text_file",
"MultiColumnListbox",
]
[docs]
def get_file_name(f, read):
"""
Utility function to get the file name using guitools if needed.
Parameters
----------
f : :class:`pathlib.Path` object or string or None
File name or directory name (something that :func:`os.fspath`
accepts) or None. If `f` is not None or is not a directory
name, this routine just returns it as is. If it is a directory
name, it is used as the `initialdir` option to
:func:`askopenfilename` or :func:`asksaveasfilename`.
read : bool
If True, calls :func:`askopenfilename` to get file name.
Otherwise, :func:`asksaveasfilename` is called.
Returns
-------
filename : string
The selected filename.
"""
try:
fs = os.fspath(f)
except TypeError:
fs = f
initialdir = None
else:
if os.path.isdir(fs): # pragma: no cover
initialdir = fs
fs = None
else:
initialdir = None
if fs is None: # pragma: no cover
if read:
return askopenfilename(initialdir=initialdir)
else:
return asksaveasfilename(initialdir=initialdir)
return fs
[docs]
def askopenfilename(title=None, filetypes=None, initialdir=None): # pragma: no cover
"""
Use GUI to select file for reading
Parameters
----------
title : string or None
Title of window. Use None to accept tkinter default.
filetypes : list or None
Option to limit file search to certain patterns. Typically,
this is a list of tuples, each tuple containing a description
followed by a pattern (see example below).
initialdir : string or None
Initial directory to begin search. If None, use previous
location if there is one.
Returns
-------
filename : string
The selected filename.
Notes
-----
Here is a simple example::
from pyyeti import guitools
filename = guitools.askopenfilename()
To filter the files to selected types::
from pyyeti import guitools
filetypes = [('Output4 files', '*.op4'),
('All files', '*')]
filename = guitools.askopenfilename(filetypes=filetypes)
"""
if not HAVE_TKINTER:
msg = "tkinter not available, cannot create GUI dialog"
raise ImportError(msg)
global LASTOPENDIR
root = tk.Tk()
root.withdraw()
if filetypes is None:
filetypes = []
if initialdir is None:
initialdir = LASTOPENDIR
filename = filedialog.askopenfilename(
parent=root, filetypes=filetypes, initialdir=initialdir, title=title
)
root.destroy()
if filename:
LASTOPENDIR = os.path.dirname(filename)
return filename
[docs]
def asksaveasfilename(title=None, filetypes=None, initialdir=None): # pragma: no cover
"""
Use GUI to select file for writing
Parameters
----------
title : string or None
Title of window. Use None to accept tkinter default.
filetypes : list or None
Option to limit file search to certain patterns. Typically,
this is a list of tuples, each tuple containing a description
followed by a pattern (see example below).
initialdir : string or None
Initial directory to begin search. If None, use previous
location if there is one.
Returns
-------
filename : string
The selected filename.
Notes
-----
Here is a simple example::
from pyyeti import guitools
filename = guitools.asksaveasfilename()
To filter the files to selected types::
from pyyeti import guitools
filetypes = [('Output4 files', '*.op4'),
('All files', '*')]
filename = guitools.asksaveasfilename(filetypes=filetypes)
"""
if not HAVE_TKINTER:
msg = "tkinter not available, cannot create GUI dialog"
raise ImportError(msg)
global LASTSAVEDIR
root = tk.Tk()
root.withdraw()
if filetypes is None:
filetypes = []
if initialdir is None:
initialdir = LASTSAVEDIR
filename = filedialog.asksaveasfilename(
parent=root, filetypes=filetypes, initialdir=initialdir, title=title
)
# self.root.after_idle(self._quit)
root.destroy()
if filename:
LASTSAVEDIR = os.path.dirname(filename)
return filename
[docs]
def read_text_file(rdfunc):
r"""
Decorator that processes the file argument for text reading
Defaults to UTF-8 encoding, but if `rdfunc` has keyword argument
for "encoding", that will be used instead.
Parameters
----------
rdfunc : function
Function that reads text from a file. The first argument to
that function is a file argument. The file argument can be the
name of a file, or a file_like object as returned by
:func:`open` or :func:`io.StringIO`. It can also be the name
of a directory or None; in these cases, a GUI is opened for
file selection. For example, see
:func:`pyyeti.nastran.bulk.rdgrids`.
Returns
-------
function
Function that processes the file argument before calling
`rdfunc`.
See also
--------
:func:`write_text_file`
Examples
--------
>>> from pyyeti.guitools import read_text_file, write_text_file
>>> from io import StringIO
>>> @read_text_file
... def doread(f):
... return f.readline()
>>> @write_text_file
... def dowrite(f, string, number):
... f.write(f'{string} = {number:.3f}\n')
>>> with StringIO() as f:
... dowrite(f, 'param', number=45.3)
... _ = f.seek(0, 0)
... s = doread(f)
>>> s
'param = 45.300\n'
"""
@wraps(rdfunc)
def mod_func(f, *args, **kwargs):
f = get_file_name(f, read=True)
if isinstance(f, str):
encoding = kwargs.get("encoding", "utf_8")
with open(f, "r", encoding=encoding) as fin:
return rdfunc(fin, *args, **kwargs)
else:
return rdfunc(f, *args, **kwargs)
return mod_func
[docs]
def write_text_file(wtfunc):
r"""
Decorator that processes the file argument for writing
Parameters
----------
wtfunc : function
Function that writes text to a file. The first argument to
that function is a file argument. The file argument can be the
name of a file, or a file_like object as returned by
:func:`open` or :func:`io.StringIO`. It can also be input as
the integer 1 to write to stdout (or use
``sys.stdout``). Finally, it can also be the name of a
directory or None; in these cases, a GUI is opened for file
selection. To write to a string, ``import io`` and set ``f =
io.StringIO()``; afterwards, retrieve string by
``f.getvalue()``. For example, see
:func:`pyyeti.nastran.bulk.wtgrids`.
Returns
-------
function
Function that processes the file argument before calling
`wtfunc`.
See also
--------
:func:`read_text_file`
Examples
--------
>>> from pyyeti.guitools import write_text_file
>>> @write_text_file
... def dowrite(f, string, number):
... f.write(f'{string} = {number:.3f}\n')
>>> dowrite(1, 'param', number=45.3)
param = 45.300
"""
@wraps(wtfunc)
def mod_func(f, *args, **kwargs):
f = get_file_name(f, read=False)
if isinstance(f, str):
with open(f, "w") as fout:
return wtfunc(fout, *args, **kwargs)
else:
if f == 1:
f = sys.stdout
return wtfunc(f, *args, **kwargs)
return mod_func
[docs]
class MultiColumnListbox(object): # pragma: no cover
"""
Use a ttk.TreeView to build a linked, multicolumn listbox.
Once a window is created, you may select a single item by either
selecting a row and pressing "Done" or by double-clicking a row.
Or, you may select multiple items and pressing "Done".
You can filter the values shown by entering strings in the filter
boxes above the multicolumn listbox and hitting the Return
key. All values in a row must match their respective filter for
the row to remain visible. A value matches if it contains the
filter anywhere in it.
After selection, retrieve your selection by accessing attributes
`sel_index` or `sel_dict`. `sel_index` is a list of indexes into
the provided lists. `sel_dict` is a dictionary with the keys being
the indexes and the values being another dictionary of `header` :
list-item pairs.
Example code::
from pyyeti import guitools
headers = ['First', 'Middle', 'Last']
lst1 = ['Tony', 'Jennifer', 'Albert', 'Marion']
lst2 = ['J.', 'M.', 'E.', 'K.']
lst3 = ['Anderson', 'Smith', 'Kingsley', 'Cotter']
ind = guitools.MultiColumnListbox(
'Select person', headers, [lst1, lst2, lst3]
).sel_index[0]
print(f'First Person is {lst1[ind]} {lst2[ind]} {lst3[ind]}')
Or, using the `sel_dict` attribute::
dct = guitools.MultiColumnListbox(
'Select person', headers, [lst1, lst2, lst3]
).sel_dict
key = sorted(dct)[0]
vals = dct[key]
print(f"First Person is {vals['First']} {vals['Middle']} "
f"{vals['Last']}")
"""
[docs]
def __init__(
self,
title,
headers,
lists,
topstring=(
"Click on header to sort by that column;\n"
"Drag boundary to change width of column"
),
):
"""
Initialize a :class:`MultiColumnListbox` instance.
Parameters
----------
title : string
Title for the window
headers : list of strings
List of column headers
lists : list of lists
Corresponds to `headers`. Each list must be the same
length and contain the contents of the columns. The
contents are expected to be strings.
topstring : string; optional
String to print above table
"""
if not HAVE_TKINTER:
msg = "tkinter not available, cannot create GUI dialog"
raise ImportError(msg)
self.root = tk.Tk()
self.root.title(title)
self.tree = None
self.headers = headers
self.lists = lists
self.topstring = topstring
self.sel_dict = {}
self.sel_index = []
self.detached_items = [0] * len(lists[0])
self._setup_widgets()
self._build_tree()
self.root.mainloop()
def _setup_widgets(self):
msg = tk.Text(wrap="word", height=2, font="TkDefaultFont")
msg.insert("1.0", self.topstring) # line 1, column 0
msg.configure(bg=self.root.cget("bg"), relief="flat", state="disabled")
msg.pack(fill="x")
self._add_filter_boxes()
container = ttk.Frame()
container.pack(fill="both", expand=True)
# create a treeview with dual scrollbars
self.tree = ttk.Treeview(
height=min(25, len(self.lists[0])), columns=self.headers, show="headings"
)
vsb = ttk.Scrollbar(orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(column=0, row=0, sticky="nsew", in_=container)
vsb.grid(column=1, row=0, sticky="ns", in_=container)
hsb.grid(column=0, row=1, sticky="ew", in_=container)
container.grid_columnconfigure(0, weight=1)
container.grid_rowconfigure(0, weight=1)
# add a done button:
button = ttk.Button(text="Done", command=self._get_selection)
button.pack()
def _add_filter_boxes(self):
container = ttk.Frame()
container.pack(fill="x")
msg = ttk.Label(
container,
text="Filters:",
# background='gray',
borderwidth=5,
)
# msg.grid(row=0, rowspan=2, column=0, padx=10)
msg.grid(row=0, column=0, padx=10)
self.casesen = tk.IntVar()
self.casesen.set(0)
cbutton = ttk.Checkbutton(
container, text="Case Sensitive", variable=self.casesen
) # command=self._checkbutton_action)
cbutton.grid(row=1, column=0)
self.filter_var = []
# add an Entry widget for all columns except "Index":
for i, header in enumerate(self.headers):
filter_var = tk.StringVar()
filter_entry = ttk.Entry(container, textvariable=filter_var)
filter_var.set("")
filter_entry.bind("<Key-Return>", self._apply_filters)
# filter_entry.pack(side='left', padx=10, expand=True)
ttk.Label(container, text=header).grid(row=0, column=i + 1, sticky="w")
filter_entry.grid(row=1, column=i + 1, sticky="w")
# container.grid_columnconfigure(i+1, weight=1)
self.filter_var.append(filter_var)
def _apply_filters(self, event=None):
# detach all items remaining:
for item in self.tree.get_children(""):
i = int(item[1:], 16) - 1
self.detached_items[i] = item
self.tree.detach(item)
# reattach those where all filters pass:
if self.casesen.get():
filtervars = [v.get() for v in self.filter_var]
def _do_find(value, sub):
return value.find(sub) > -1
else:
filtervars = [v.get().lower() for v in self.filter_var]
def _do_find(value, sub):
return value.lower().find(sub) > -1
for i in range(len(self.lists[0])):
for string, curlist in zip(filtervars, self.lists):
if string and not _do_find(curlist[i], string):
break
else:
# only here if the 'break' was not executed ...
# when all filters pass:
self.tree.move(self.detached_items[i], "", i)
self.detached_items[i] = 0
def _quit(self):
# self.root.quit()
self.root.destroy()
def _store_selection(self, items):
dct = {}
index = []
for item in items:
i = int(item[1:], 16) - 1
dct[i] = self.tree.set(item)
index.append(i)
self.sel_dict = dct
self.sel_index = index
self.root.after_idle(self._quit)
def _get_selection(self):
self._store_selection(self.tree.selection())
def _double_click(self, event):
item = self.tree.identify("item", event.x, event.y)
self._store_selection((item,))
def _build_tree(self):
for col in self.headers:
self.tree.heading(
col, text=col.title(), command=lambda c=col: _sortby(self.tree, c, 0)
)
# adjust the column's width to the header string
# - add 15 pixels for a little buffer
self.tree.column(col, width=tkFont.Font().measure(col.title()) + 15)
for item in zip(*self.lists):
self.tree.insert("", "end", values=item)
# adjust each column's width by maximum length string:
for i, col in enumerate(self.headers):
try:
s = max(self.lists[i], key=len)
except TypeError:
s = str(max(self.lists[i]))
col_w = tkFont.Font().measure(s)
width = self.tree.column(col, width=None)
if width < col_w:
self.tree.column(col, width=col_w)
self.tree.bind("<Double-1>", self._double_click)
def _sortby(tree, col, descending): # pragma: no cover
"""sort tree contents when a column header is clicked on"""
# grab values to sort
data = [(tree.set(child, col), child) for child in tree.get_children("")]
# print(data)
data.sort(reverse=descending)
for ix, item in enumerate(data):
tree.move(item[1], "", ix)
# to make first of any current selection visible:
tree.see(tree.selection()[:1])
# switch the heading so it will sort in the opposite direction
tree.heading(col, command=lambda col=col: _sortby(tree, col, int(not descending)))