Skip to content

Instantly share code, notes, and snippets.

@sjlongland
Last active March 8, 2024 07:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sjlongland/f9bdd934d6c14e78027db2bb370b0f75 to your computer and use it in GitHub Desktop.
Save sjlongland/f9bdd934d6c14e78027db2bb370b0f75 to your computer and use it in GitHub Desktop.
Simplified widgets using tkinter.ttk.Treeview
#!/usr/bin/env python3
# Button box component, lays out buttons in a horizontal or vertical layout
# evenly.
# GUI stuff
import tkinter
from tkinter import ttk
# The component
class ButtonBox(ttk.Frame):
def __init__(
self, parent, orientation=tkinter.HORIZONTAL, weight=1, **kwargs
):
super(ButtonBox, self).__init__(parent, **kwargs)
if orientation == tkinter.HORIZONTAL:
self.columnconfigure(0, weight=weight)
elif orientation == tkinter.VERTICAL:
self.rowconfigure(0, weight=weight)
else:
raise ValueError(
"Expected orientation to be tkinter.HORIZONTAL or "
"tkinter.VERTICAL, got %r" % orientation
)
self._orientation = orientation
self._buttons = []
self._next_pos = 0
def add_button(
self,
text,
command,
span=1,
weight=1,
sticky=(tkinter.N, tkinter.S, tkinter.E, tkinter.W),
**kwargs
):
btn = ttk.Button(self, text=text, command=command)
self._buttons.append(btn)
pos = self._next_pos
self._next_pos += span
if self._orientation == tkinter.HORIZONTAL:
btn.grid(
row=0, column=pos, rowspan=1, columnspan=span, sticky=sticky
)
self.columnconfigure(pos, weight=weight)
else:
btn.grid(
row=pos, column=0, rowspan=span, columnspan=1, sticky=sticky
)
self.rowconfigure(pos, weight=weight)
return self
# Example usage
if __name__ == "__main__":
print("Running button box example")
tk = tkinter.Tk()
def _pressed(x, *a):
print("%r pressed (a=%r)" % (x, a))
widget = ButtonBox(parent=tk)
widget.add_button("Button 1", command=lambda *a: _pressed("B1", *a))
widget.add_button("Button 2", command=lambda *a: _pressed("B2", *a))
widget.add_button("Button 3", command=lambda *a: _pressed("B3", *a))
widget.pack(fill="both", expand=True)
tk.mainloop()
#!/usr/bin/env python3
"""
A drop-down list widget that takes an Enum for its input.
"""
# © 2024 Stuart Longland VK4MSL
# SPDX-License-Identifier: Python-2.0
# GUI stuff
from .objectlist import ObjectList
# The component
class EnumList(ObjectList):
"""
Drop-down list box using Enums. Sorting and label generation can be
customised through subclassing.
"""
def __init__(
self,
parent,
enum,
value=None,
on_select=None,
add_none=False,
reverse=False,
**kwargs
):
"""
Create a new EnumList object.
:param parent: Tkinter parent object
:type parent: class:`tkinter.Widget`
:param enum: The enumeration that represents the possible
values.
:type enum: class:`Enum`
:param on_select: Call-back function when an item is selected
:type on_select: function
:param add_none: Add an option for ``None``
:type add_none: boolean
:param reverse: Reverse the sort order
:type reverse: boolean
"""
values = list(enum)
if add_none:
values.insert(0, None)
self._enum = enum
super(EnumList, self).__init__(
parent,
values=values,
value=value,
on_select=on_select,
reverse=reverse,
**kwargs
)
# Internals
def _get_identity(self, value):
"""
Cast the value to the Enum value.
"""
if value is None:
return None
return self._enum(value)
def _get_text(self, value):
"""
Conversion of a value to a string. This simple version returns the
"name" of the enum value, or an empty string for ``None``.
This may be customised in a sub-class for special handling.
"""
if value is None:
return ""
else:
return value.name
# Example usage
if __name__ == "__main__":
from enum import Enum
import tkinter
print("Running EnumList example")
tk = tkinter.Tk()
class TestEnum(Enum):
EnumA = 1
EnumB = 2
EnumC = 3
def _on_select(lst, selection):
print("Selected: %r" % selection)
widget = EnumList(
parent=tk, enum=TestEnum, on_select=_on_select, add_none=True
)
assert widget.selection is None
assert widget.text == ""
widget.selection = 2 # EnumB
print(widget.selection)
widget.pack(fill="both", expand=True)
tk.mainloop()
#!/usr/bin/env python3
"""
A drop-down list widget that takes arbtrary objects for input.
"""
# © 2024 Stuart Longland VK4MSL
# SPDX-License-Identifier: Python-2.0
# GUI stuff
import tkinter
from tkinter import ttk
# The component
class ObjectList(ttk.Frame):
"""
Drop-down list box using arbitrary object instances. Sorting and label
generation can be customised through subclassing.
"""
def __init__(
self,
parent,
values,
value=None,
on_select=None,
add_none=False,
reverse=False,
**kwargs
):
"""
Create a new ObjectList object.
:param parent: Tkinter parent object
:type parent: class:`tkinter.Widget`
:param values: The possible selection values permitted.
:type values: class:`Sequence`
:param on_select: Call-back function when an item is selected
:type on_select: function
:param reverse: Reverse the sort order
:type reverse: boolean
"""
super(ObjectList, self).__init__(parent, **kwargs)
self._on_select = on_select
self._reverse = reverse
(self._values, self._labels) = self._enumerate_values(values)
self._values_rmap = dict(
(self._get_identity(value), posn)
for (posn, value) in enumerate(self._values)
)
self._label_var = tkinter.StringVar(value=self._get_text(value))
self._listbox = ttk.Combobox(self, textvariable=self._label_var)
self._listbox["values"] = self._labels
self._listbox.state(["readonly"])
self._listbox.grid(
row=0,
column=0,
sticky=(tkinter.N, tkinter.S, tkinter.E, tkinter.W),
)
self._listbox.bind("<<ComboboxSelected>>", self._on_lbselect)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
# Selection API
@property
def text(self):
"""
Return the human-readable selection text value.
"""
return self._label_var.get()
@property
def selection(self):
"""
Retrieve the currently selected enum value.
"""
# current() returns the index of the current value or -1 if the current value is
# not in the values list.
selection = self._listbox.current()
if selection < 0:
return None
else:
return self._values[selection]
@selection.setter
def selection(self, value):
"""
Select one of the possible enum values, or none of them if ``None``.
"""
if value is None:
self._listbox.current(-1)
else:
self._listbox.current(
self._values_rmap[self._get_identity(value)]
)
@property
def on_select(self):
"""
Return the callback called when a selection is changed.
"""
return self._on_select
@on_select.setter
def on_select(self, callback):
"""
Change the callback called when a selection is changed.
"""
self._on_select = callback
# Internals
def _get_identity(self, value):
"""
Return an identifier for the value provided. This is used to select a
specific value key. The default implementation returns the object's
``id()`` (in CPython; this function returns the C pointer.)
"""
return id(value)
def _enumerate_values(self, values):
"""
Enumerate all possible values and their labels.
Returns (tuple_of_values, tuple_of_labels)
"""
# Generates a list of [(enumvalue, enumlabel)]
labelled_values = sorted(
((value, self._get_text(value)) for value in values),
key=lambda i: self._get_sort_key(*i),
reverse=self._reverse,
)
# Split these into two separate tuples, return as a 2-element tuple of
# tuples.
return tuple(zip(*labelled_values))
def _get_sort_key(self, value, label):
"""
Return the sort order key for the enumeration. The default
implementation sorts by text label ascending, this can be overridden
in a sub-class.
"""
return label
def _get_text(self, value):
"""
Conversion of a value to a string. This simple version returns the
result of ``str(value)``, or an empty string for ``None``.
This may be customised in a sub-class for special handling.
"""
if value is None:
return ""
else:
return str(value)
def _on_lbselect(self, *args):
if self.on_select is not None:
self.on_select(self, self.selection)
# Example usage
if __name__ == "__main__":
from enum import Enum
print("Running ObjectList example")
tk = tkinter.Tk()
class MyObjectClass(object):
def __init__(self, label):
self._label = label
def __str__(self):
return self._label
def __repr__(self):
return "%s(%r)" % (self.__class__.__name__, self._label)
values = [
None,
MyObjectClass("Option 1"),
MyObjectClass("Option 2"),
MyObjectClass("Option 3"),
]
def _on_select(lst, selection):
print("Selected: %r" % selection)
widget = ObjectList(parent=tk, values=values, on_select=_on_select)
assert widget.selection is None
assert widget.text == ""
widget.selection = values[2] # Option 2
print(widget.selection)
widget.pack(fill="both", expand=True)
tk.mainloop()
#!/usr/bin/env python3
"""
Simplified table widget based on ttk.Treeview. This simplifies the Treeview
widget, distilling the essential functions needed to manage a simple table
view.
The Treeview widget is actually very capable, but it has a rather obscure API
owing to its complexity _and_ its background as being a Tk widget that has
been "mapped" to Python's API.
The following wrapper provides a simplified and opinionated interface that
allows for display and manipulation of tabular data only. A distinctly "KISS"
approach is taken here.
- multi-selections are supported, but not the default
- true 'tree' structures are not supported
- `None` is rendered as an empty string
- Other data types are coerced to strings on passing to the Treeview
- There is some support there for custom row types through subclassing
"""
# © 2024 Stuart Longland VK4MSL
# SPDX-License-Identifier: Python-2.0
from collections.abc import MutableSequence, MutableMapping, Sequence, Mapping
# GUI stuff
import tkinter
from tkinter import ttk
class TableItem(MutableMapping):
"""
Item base class for representing rows in the table. This stores
references to the textual label for the row (left-most column), any values
for the columns themselves, the item ID (as assigned by Tkinter) and a
reference to an application-specific object.
:param table: The Table object that this row is a part of.
:type table: class:`Table`
:param value: The value representing the label of the row. If not a
string, it will be stringified using the ``str()``
operator.
:param columns: The values of the optional data columns.
:type columns: tuple
:param iid: The iid returned by ``tkinter`` when inserted
:type iid: str
:param objectref: Optional object reference for the application
"""
def __init__(self, table, value, columns=None, iid=None, objectref=None):
self._table = table
self._value = value
self._columns = list(columns)
self._iid = iid
self._objectref = objectref
# Debugging
def __repr__(self):
return "%s(table=%r, value=%r, columns=%r, iid=%r, objectref=%r)" % (
self.__class__.__name__,
self._table,
self._value,
self._columns,
self._iid,
self._objectref,
)
# Interaction with the item label itself
@property
def value(self):
"""
Return the value associated for this row.
"""
return self._value
@value.setter
def value(self, value):
"""
Update the value associated with this row.
"""
self._value = value
if self._iid is not None:
# Stringify "value" in case it's an object
self._table._treeview.item(self._iid, text=self._get_text(value))
@property
def objectref(self):
"""
Return the application object linked to this row.
"""
return self._objectref
@objectref.setter
def objectref(self, value):
"""
Update the application object linked to this row.
"""
self._objectref = value
# MutableMapping interface for interacting with columns
def __getitem__(self, key):
"""
Return the value of the column specified by ``key``.
:param key: Either the name or index of the column requested.
:type key: str or int
"""
(pos,) = self._get_pos(key)
return self._columns[pos]
def __setitem__(self, key, value):
"""
Set the value of the column specified by ``key``.
:param key: Either the name or index of the column requested.
:type key: str or int
:param value: The value being set, if it is not a ``str``, it will
be stringified before being passed to the Treeview.
"""
(pos, name) = self._get_pos(key)
self._set_column(name, pos, value)
def __delitem__(self, key):
"""
Clear the value of the column specified by ``key``. This is
equivalent to:
item[key] = None
:param key: Either the name or index of the column requested.
:type key: str or int
"""
(pos, name) = self._get_pos(key)
self._set_column(name, pos, None)
def __iter__(self):
"""
Iterate over the names of the columns.
"""
return iter(self._table._columns)
def __len__(self):
"""
Returnt the number of columns.
"""
return len(self._table._columns)
# Internals
def _get_text(self, value):
"""
Translate the given value to a text string. The default
implementation passes this to the table class for conversion.
"""
return self._table._get_text(value)
def _get_pos(self, name):
"""
Given a name or index, find the piece of information that's missing
and return both.
:param name: Name of a column (``str``) or index (``int``).
:type name: str or int
"""
if isinstance(name, int):
return (name, self._table._columns[name])
else:
return (self._table._columns_by_name[name], name)
def _set_column(self, name, pos, value):
"""
Set the value of a column, updating the underlying Treeview at the
same time.
"""
self._columns[pos] = value
if self._iid is not None:
# Stringify "text" in case it's an object
self._table._treeview.set(self._iid, name, self._get_text(value))
# The component
class Table(ttk.Frame, MutableSequence):
_ITEM_CLASS = TableItem
"""
Table component for Tkinter applications. This can be sub-classed by
applications to customise the behaviour.
"""
def __init__(
self,
parent,
columns=None,
multiselect=False,
on_select=None,
**kwargs
):
"""
Create a new Table object.
:param parent: Tkinter parent object
:type parent: class:`tkinter.Widget`
:param columns: The columns and their labels, a sequence of
two-element tuples of the form ``(name, title)``.
:type columns: class:`Sequence`
:param multiselect: Whether we allow selecting multiple rows or not?
:type multiselect: boolean
:param on_select: Call-back function when an item is selected
:type on_select: function
"""
if columns is None:
columns = ()
super(Table, self).__init__(parent, **kwargs)
self._columns = tuple(c[0] for c in columns)
self._columns_by_name = dict(
(name, pos) for pos, name in enumerate(self._columns)
)
self._items = []
self._multiselect = multiselect
self._on_select = on_select
self._treeview = ttk.Treeview(
self,
columns=self._columns,
selectmode="extended" if multiselect else "browse",
)
self._treeview.grid(
row=0,
column=0,
sticky=(tkinter.N, tkinter.S, tkinter.E, tkinter.W),
)
self._treeview.bind("<<TreeviewSelect>>", self._on_tvselect)
# Set up headings
for name, label in columns:
self._treeview.heading(name, text=self._get_text(label))
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
# Selection API
@property
def selection(self):
"""
Retrieve the selections made on the table.
If ``multiselect`` is ``True``:
a ``set`` of indices is returned. If nothing is selected,
an empty ``set`` is returned.
Else if ``multiselect`` is ``False``:
the index of the row selected is returned.
If nothing is selected, ``None`` is returned.
"""
selection = self._treeview.selection()
if self._multiselect:
return set(self._treeview.index(iid) for iid in selection)
else:
try:
return self._treeview.index(selection[0])
except IndexError:
return None
@selection.setter
def selection(self, indices):
"""
Replace the selections made on the table.
If ``indices`` is ``None``:
De-select all rows.
Else if ``multiselect`` is ``True``:
``indices`` is either a single index, or a sequence of indices.
These rows will be selected (and others de-selected).
Else if ``multiselect`` is ``False``:
``indices`` is an integer row index. It will be selected.
"""
if indices is None:
# De-select all
selection = ()
elif self._multiselect:
# Set multi-selection
if not isinstance(indices, Sequence):
indices = (indices,)
selection = tuple(self._items[idx]._iid for idx in indices)
else:
# Set single-selection
selection = (self._items[indices]._iid,)
self._treeview.selection_set(*selection)
def selection_add(self, *indices):
"""
Add the indicated rows (given by row index) to the current selection
without de-selecting other rows. Requires ``multiselect`` set to
``True``.
"""
self._selection_update("add", *indices)
def selection_remove(self, *indices):
"""
Remove the indicated rows (given by row index) to the current selection
without de-selecting other rows. Requires ``multiselect`` set to
``True``.
"""
self._selection_update("remove", *indices)
def selection_toggle(self, *indices):
"""
Toggle the indicated rows (given by row index) to the current selection
without de-selecting other rows. Requires ``multiselect`` set to
``True``.
"""
self._selection_update("toggle", *indices)
@property
def on_select(self):
"""
Return the callback called when a selection is changed.
"""
return self._on_select
@on_select.setter
def on_select(self, callback):
"""
Change the callback called when a selection is changed.
"""
self._on_select = callback
# MutableSequence interface
def __getitem__(self, idx):
"""
Return the item on row ``idx``; if there's an object reference, return
the object reference itself, otherwise just return the ``TableItem``.
"""
item = self._items[idx]
if item.objectref is not None:
return item.objectref
else:
return item
def __setitem__(self, idx, itemdata):
"""
Replace the data for row ``idx``. ``itemdata`` can take a couple of
forms:
- Bare value: ``itemdata`` will be stringified and used for the row
label value.
- Two-element tuple ``(value, columndict)``: ``value`` will be
stringified and used as the row label value. ``columndict`` is a
mapping of column names to _their_ values for this row: again, all
will be stringified for display purposes.
- N-element tuple: N > 1; the first element of the tuple is taken as
the row value, the remainder is the values of each column.
"""
(value, columns, objectref) = self._getobject(itemdata)
item = self._items[idx]
item.value = value
item.objectref = objectref
for pos, col in enumerate(columns):
item[pos] = col
def __delitem__(self, idx):
"""
Remove the row at index ``idx``.
"""
item = self._items[idx]
self._treeview.delete(item._iid)
self._items.pop(idx)
def __len__(self):
"""
Return the number of rows in the table.
"""
return len(self._items)
def insert(self, idx, itemdata):
"""
Insert a new row into the table. See ``__setitem__`` above.
"""
(value, columns, objectref) = self._getobject(itemdata)
iid = self._treeview.insert(
"",
idx,
text=str(value),
values=tuple(self._get_text(c) for c in columns),
)
item = self._ITEM_CLASS(
table=self,
value=value,
columns=columns,
iid=iid,
objectref=objectref,
)
self._items.insert(idx, item)
# Internals
def _selection_update(self, method, *indices):
if not self._multiselect:
raise NotImplementedError("This is not a multi-select table")
getattr(self._treeview, "selection_%s" % method)(
*(self._items[idx]._iid for idx in indices)
)
def _getobject(self, itemdata):
"""
Extract the row information and object reference from the given
object. Sub-classes may override this, returning a tuple of the form:
``(rowvalue, columnvalues, objectref)``.
"""
if isinstance(itemdata, str):
# Assume plain row with no column data
value = itemdata
columns = (None,) * len(self._columns)
elif isinstance(itemdata, Sequence):
# Row with column data
value = itemdata[0]
if isinstance(itemdata[1], Mapping):
# Columns given as a dict, convert to tuple
columns = tuple(itemdata[1].get(c) for c in self._columns)
else:
columns = tuple(itemdata[1:])
if len(columns) < len(self._columns):
# Pad the columns
columns += (None,) * (len(self._columns) - len(columns))
else:
raise TypeError(
"Don't know how to handle type %s" % type(itemdata)
)
return (value, columns, None)
def _get_text(self, value):
"""
Conversion of a value to a string. This simple version returns an
empty string for ``None``, and ``str(value)`` for everything else.
This may be customised in a sub-class for special handling.
"""
if value is None:
return ""
else:
return str(value)
def _on_tvselect(self, *args):
if self.on_select is not None:
self.on_select(self, self.selection)
# Example usage
if __name__ == "__main__":
print("Running Table example")
tk = tkinter.Tk()
def _on_select(tbl, selection):
print("Selected: %r" % selection)
widget = Table(
parent=tk,
columns=[("a", "Column A"), ("b", "Column B"), ("c", "Column C")],
on_select=_on_select,
)
widget.append("A bare string")
widget.append(("String with column data (tuple)", 111, 222, 333))
widget.append("To be deleted")
widget.append(
("String with column data (dict)", {"a": 444, "b": 555, "c": 666})
)
widget.append("Bare string, will be added to later")
widget.selection = 1
print(widget.selection)
del widget[2]
widget[3]["a"] = 123
widget[3]["b"] = 456
widget[3]["c"] = 789
widget.pack(fill="both", expand=True)
tk.mainloop()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment