"""Functions related to Tk List / Tree View setup and behavior."""
__all__ = [
"make_treelist",
"ListBindingTk",
]
import logging
from typing import List
import dataclasses as dc
import tkinter as tk
from tkinter import ttk
from .toolkit import ListBinding
from .tk_treeedit import TreeEdit
def L():
return logging.getLogger(__name__)
def _unique(parent, id):
try:
parent.nametowidget(id)
except KeyError:
return id
else:
# id exists
return ""
[docs]
def make_treelist(
parent,
id=None,
text="",
columns=None,
first_column_editable=False,
widget_classes=None,
sort_asc_icon=None,
sort_desc_icon=None,
):
"""treeview (also usable as plain list)
Implementation note: Uses a ttk.TreeView, and wraps it into a frame
together with a vertical scrollbar. For correct placement, the
.place, .grid, .pack methods of the returned tv are replaced by that of
the frame.
Columns can be marked editable by appending "_" to the name.
If any column is editable, a :any:`TreeEdit` is generated instead of the TreeView.
Returns the treeview widget (within the frame).
"""
if widget_classes is None:
widget_classes = {
"box": tk.Frame,
"scrollbar": tk.Scrollbar,
"treelist": ttk.Treeview,
"treelist_editable": TreeEdit,
}
Frame = widget_classes["box"]
Scrollbar = widget_classes["scrollbar"]
text = text.strip()
id = _unique(parent, id)
is_editable = first_column_editable or any(column.editable for column in columns)
has_first_column = bool(text)
# setup scrollable container
frame = Frame(parent)
if is_editable:
tv = widget_classes["treelist_editable"](frame, columns=[c.id for c in columns])
else:
tv = widget_classes["treelist"](frame, columns=[c.id for c in columns])
scb = Scrollbar(frame)
scb.pack(side="right", fill="y")
tv.pack(expand=1, fill="both")
scb.config(command=tv.yview)
tv.config(yscrollcommand=scb.set)
# patch layout methods
tv.pack = frame.pack
tv.place = frame.place
tv.grid = frame.grid
keys = [column.id for column in columns]
if has_first_column:
keys.insert(0, "")
tv.variable = ListBindingTk(tv, keys)
tv.variable.sort_asc_icon = sort_asc_icon
tv.variable.sort_desc_icon = sort_desc_icon
# configure tree view
if has_first_column:
tv.heading("#0", text=text, command=lambda: tv.variable.on_heading_click(""))
if first_column_editable:
tv.editable("#0", True)
else:
# hide first column
tv["show"] = "headings"
for column in columns:
if not column.id:
continue
tv.heading(
column.id,
text=column.text,
command=lambda key=column.id: tv.variable.on_heading_click(key),
)
if column.editable:
tv.editable(column.id, True)
if is_editable:
tv.on_cell_modified += tv.variable.on_cell_modified
tv.on_add += tv.variable.on_add_cmd
tv.on_remove += tv.variable.on_remove_cmd
tv.on_add_child += tv.variable.on_add_child_cmd
tv.bind("<<TreeviewOpen>>", tv.variable.on_gui_expand)
# set up variable
return tv
[docs]
class ListBindingTk(ListBinding):
"""
Use ``get`` to return the ObsList;
``set`` to replace the value using a new list.
All the ``on_*`` methods are internal event handlers.
"""
def __init__(self, treeview, keys, **kwargs):
super().__init__(keys=keys, **kwargs)
self.list.toolkit_parent_id = ""
self._tv = treeview
self.sort_asc_icon = None
"""header icon for ascending column"""
self.sort_desc_icon = None
"""header icon for descending column"""
self._reorder_behavior = None
@property
def allow_reorder(self):
return self._reorder_behavior is not None
@allow_reorder.setter
def allow_reorder(self, val):
if val == self.allow_reorder:
return
if not val:
if self._reorder_behavior:
self._reorder_behavior.unbind()
self._reorder_behavior = None
else:
self._reorder_behavior = ReorderBehavior(self._tv)
[docs]
def get(self):
"""get underlying list"""
return self.list
[docs]
def set(self, val):
"""Set underlying list"""
self.list = val
def _set_list(self, val):
if self._list is not None:
self._tv.delete(*self._tv.get_children())
super()._set_list(val)
# val could have been cast into ObsList, use internal value.
for idx, item in enumerate(self._list):
iid = self.on_insert(idx, item, "")
self._list.toolkit_ids[idx] = iid
# === ObsList event-handler implementations ===
[docs]
def on_insert(self, idx, item, toolkit_parent_id):
"""create visible tree entry"""
iid = self._tv.insert(toolkit_parent_id, idx, text=self.retrieve(item, ""))
# insert placeholder so that "+" icon appears
if self._list.has_children(item):
self._tv.insert(iid, 0, text="")
self.on_replace(iid, item, update_text=False)
return iid
[docs]
def on_load_children(self, children):
"""replace subnodes"""
self._tv.delete(*self._tv.get_children(children.toolkit_parent_id))
for idx, item in enumerate(children):
iid = self.on_insert(idx, item, children.toolkit_parent_id)
children.toolkit_ids[idx] = iid
[docs]
def on_replace(self, iid, item, update_text=True):
"""replace visible tree entry"""
tv = self._tv
if update_text:
tv.item(iid, text=self.retrieve(item, ""))
for column in self.keys:
if column == "":
continue
txt = str(self.retrieve(item, column))
tv.set(iid, column, txt)
self._update_sortarrows()
[docs]
def on_remove(self, iid):
"""called when item was removed from list"""
self._tv.delete(iid)
[docs]
def on_sort(self, sublist, info):
"""Called when list was sorted, via GUI or list.sort()"""
super().on_sort(sublist, info)
tv = self._tv
_parent_iid = sublist.toolkit_parent_id
for idx, iid in enumerate(sublist.toolkit_ids):
tv.move(iid, _parent_iid, idx)
self._update_sortarrows()
def _update_sortarrows(self):
tv = self._tv
for key in self.keys:
tv.heading(key or "#0", image="")
if self.sort_key is not None:
image = self.sort_asc_icon if self.sort_ascending else self.sort_desc_icon
tv.heading(self.sort_key or "#0", image=image)
[docs]
def on_get_selection(self):
"""called to get the GUI selection as list items"""
iids = self._tv.selection()
nodes = []
def add_nodes(nodelist):
for node, tkid, childlist in zip(
nodelist, nodelist.toolkit_ids, nodelist._childlists
):
if tkid in iids:
nodes.append(node)
if childlist:
add_nodes(childlist)
add_nodes(self._list)
return nodes
# === GUI event handlers ===
def _item(self, iid):
sublist, idx = self._list.find_by_toolkit_id(iid)
return sublist[idx], sublist
[docs]
def on_tv_focus(self, function):
"""call function with focused item"""
iid = self._tv.focus()
if not iid:
return
item, _ = self._item(iid)
function(item)
[docs]
def on_gui_expand(self, stuff):
"""called when GUI item is expanded"""
iid = self._tv.focus()
# retrieve the idx
sublist, idx = self._list.find_by_toolkit_id(iid)
# on_load_children callback does the rest
sublist.load_children(idx)
[docs]
def on_heading_click(self, key: str):
"""called when heading is clicked"""
if self.sort_key is not None and key == self.sort_key:
ascending = not self.sort_ascending
else:
ascending = True
self.sort(key, ascending)
[docs]
def on_cell_modified(self, iid, columnname, val):
item, sublist = self._item(iid)
if columnname == "#0":
columnname = ""
self.store(item, val, columnname)
sublist.item_mutated(item)
return False
# == TreeEdit structural changes ==
[docs]
def on_add_cmd(self, after_iid):
if not after_iid:
sublist, idx = self._list, -1
else:
sublist, idx = self._list.find_by_toolkit_id(after_iid)
item = self.factory()
sublist.insert(idx + 1, item)
iid = sublist.toolkit_ids[idx + 1]
self._tv.focus(iid)
if self._tv.autoedit_added:
self._tv.begin_edit_row(None)
return False
[docs]
def on_add_child_cmd(self, parent_iid):
if not parent_iid:
sublist, idx = self._list, -1
else:
sublist, idx = self._list.find_by_toolkit_id(parent_iid)
sublist = sublist.get_children(idx)
item = self.factory()
sublist.append(item)
iid = sublist.toolkit_ids[-1]
self._tv.focus(iid)
if self._tv.autoedit_added:
self._tv.begin_edit_row(None)
return False
[docs]
def on_remove_cmd(self, iid):
if not iid:
return
item, sublist = self._item(iid)
sublist.remove(item)
return False
@dc.dataclass
class GrabbedItem:
"""The currently grabbed item"""
tk_id: str = ""
"""Tkinter treeview item id"""
index: int = 0
"""current position in the list"""
grabbed_at_y: int = 0
"""Where grab started, used to apply start-threshold distance"""
move_active = False
"""Item is being moved - Set to true after mouse was moved by threshold distance"""
SORT_THRESHOLD = 10
SCROLL_EDGES = (60, 30)
"""height of upper / lower scroll hit target.
Upper target is larger to accomodate heading space."""
class ReorderBehavior:
"""Allows interactive reordering of a ascii designer listview.
In ``f_on_build()``, just use ``ReorderBehavior(tk_treeview)``. Will bind
all necessary event handlers. It is recommended to store the reference away
as variable.
The list is updated immediately while dragging is in progress.
We generate ``<<ReorderStarted>>`` and ``<<ReorderFinished>>`` virtual events on
the Treeview when dragging starts / ends.
WILL NOT work with a regular listview. Reordering happens by changing the bound list.
Currently, multilevel lists (a.k.a trees) cannot be reordered.
Binds:
* ButtonPress, "grabs" item under mouse.
* Motion event, updates item's position in the list.
* ButtonRelease, ungrabs item.
* Leave event, ungrabs item.
"""
def __init__(self, tv):
self.tv = tv
"""Treeview object. Must have ``.variable`` property (ListBindingTk instance)"""
self.grabbed: GrabbedItem = None
"""Currently grabbed item"""
self._tk_bind_handles = []
self.bind()
@property
def list(self) -> List:
"""return the list model"""
return self.tv.variable.list
def bind(self):
"""Bind event handlers, i.e. activate sorting behavior.
Happens automatically on initialization."""
tv = self.tv
self.unbind()
self._tk_bind_handles = [
tv.bind("<ButtonPress-1>", self._grab, add="+"),
tv.bind("<Motion>", self._motion, add="+"),
tv.bind("<ButtonRelease-1>", self._ungrab, add="+"),
tv.bind("<Leave>", self._ungrab, add="+"),
]
def unbind(self):
"""Unbind event handlers i.e. deactivate sorting behavior."""
tv = self.tv
events = ["<ButtonPress-1>", "<Motion>", "<ButtonRelease-1>", "<Leave>"]
for event, handle in zip(events, self._tk_bind_handles):
tv.unbind(event, handle)
self._tk_bind_handles = []
def _grab(self, ev):
if self.tv.identify_region(ev.x, ev.y) != "tree":
return
iid = self.tv.identify_row(ev.y)
if not iid:
return
list, index = self.list.find_by_toolkit_id(iid)
assert list is self.list, "Cannot sort subtrees yet"
self.grabbed = GrabbedItem(iid, index, grabbed_at_y=ev.y)
# L().debug("Grab %s", self.grabbed)
def _motion(self, ev):
if not self.grabbed:
return
if (
not self.grabbed.move_active
and abs(self.grabbed.grabbed_at_y - ev.y) > SORT_THRESHOLD
):
self.tv.event_generate("<<ReorderStarted>>")
self.grabbed.move_active = True
self.tv.after(100, self._scroll_timer)
self.tv["cursor"] = "sb_v_double_arrow"
if not self.grabbed.move_active:
return
iid = self.tv.identify_row(ev.y)
if not iid:
return
index = self.tv.index(iid)
if index != self.grabbed.index:
L().debug("Move %d -> %d", self.grabbed.index, index)
self._move(self.grabbed.index, index)
self.grabbed.index = index
def _move(self, from_index, to_index):
l = self.list
if from_index != to_index:
l.insert(to_index, l.pop(from_index))
self.tv.update_idletasks()
def _scroll_timer(self):
if not self.grabbed:
return
self.tv.after(200, self._scroll_timer)
y = self.tv.winfo_pointery() - self.tv.winfo_rooty()
h = self.tv.winfo_height()
if y < SCROLL_EDGES[0]:
# scroll up
self.tv.yview_scroll(-1, "units")
elif h - y < SCROLL_EDGES[1]:
# scroll down
self.tv.yview_scroll(1, "units")
else:
return
self.tv.update_idletasks()
y_ = y
class dummy_ev_args:
y = y_
self._motion(dummy_ev_args)
def _ungrab(self, ev):
if self.grabbed and self.grabbed.move_active:
self.tv["cursor"] = ""
self.tv.event_generate("<<ReorderFinished>>")
self.grabbed = None