Source code for ascii_designer.tk_treeedit

"""``ttk.TreeView`` control augmented by editing capabilities.

For basic information, see official Tkinter (``ttk``) docs.

The following additional functionality is provided:

 * Mark column as editable using :any:`TreeEdit.editable`.
 * allow= parameter to specify legal structural operations.

``allow`` is a list of strings or a comma-separated string. It can contain any of:

 * ``add`` to allow adding new items (anywhere)
 * ``addchild`` to allow insertion of child items
 * ``remove`` to allow deletion of items.

For each allowance, the corresponding control is shown, and the keybinding is activated.

The following bindings / behaviors are built-in. Generally, value is
submitted on close, except if Escape key is used.

**Treeview**:

 * Dblclick:      open edit on col
 * Scroll:        Take focus (close edit)
 * Resize:        close edit box
 * F2:            open first edit of row
 * Ctrl+plus,
   Insert:        add item (if enabled, see below)
 * Ctrl+asterisk: add child (if enabled)
 * Ctrl+minus,
 * Delete:        remove item (if enabled)

**Edit box**:

 * Lose Focus:    close
 * Return:        close
 * Escape:        close without submitting
 * Shift+enter,
 * Down arrow:    Close + edit same column in next row
 * Tab,
 * Shift+Right arrow:   close + edit next column (or 1st col in next row)
 * Shift+Tab,
 * Shift+Left arrow:    like Tab but backwards
 * Up arrow:      Close + edit same col in prev row

**Events**:

These are properties of the TreeeEdit control.
Use ``treeedit.<property> += handler``to bind a handler, ``-=`` to unbind it.

 * ``on_cell_edit(iid, columnname, cur_value)`` when editor is opened
 * ``on_cell_modified(iid, columname, new_value)`` when editor is closed
 * ``on_add(iid)``: before item is inserted after (iid).
 * ``on_add_child(iid)``: before child is inserted under (iid).
 * ``on_remove(iid)``: before child is deleted

.. note::
    ``on_cell_modified``, ``on_add``, ``on_add_child``, ``on_remove`` are fired
    immediately before the respective action takes place in the widget.

    Your handler can return ``False`` to indicate that the widget content shall
    not be modified; e.g. if the action is forbidden or you took care of
    updating the tree yourself. Note that ``None`` is counted as ``True`` result
    in this case.

# TODO:
# reorder
# custom editor types (button, checkbox, combo, ... pass own widget(s))
# copy/paste
# have a handler for after-item-insertion with the actual iid as param (we don't need it currently)
"""

__all__ = [
    "TreeEdit",
]

import sys

import tkinter as tk
import tkinter.ttk as ttk
from typing import Any, Literal

from .event import CancelEvent, event


[docs] class TreeEdit(ttk.Treeview): """see module docs""" list_bindings = [ ("<Double-Button-1>", "_dblclick"), ("<F2>", "begin_edit_row"), # Scroll wheel ("<4>", "_close_edit_refocus"), ("<5>", "_close_edit_refocus"), ("<Control-plus>", "ins_item"), ("<Insert>", "ins_item"), ("<Control-asterisk>", "ins_child_item"), ("<Control-minus>", "del_item"), ("<Delete>", "del_item"), ] editbox_bindings = [ ("<FocusOut>", "close_edit"), ("<Return>", "_close_edit_refocus"), ("<KP_Enter>", "_close_edit_refocus"), ("<Escape>", "_cancel_edit_refocus"), ("<Shift-Right>", "advance_right"), ("<Tab>", "advance_right"), ("<Shift-Left>", "advance_left"), ("<Shift-Tab>", "advance_left"), ("<Shift-Return>", "advance_down"), ("<Shift-KP_Enter>", "advance_down"), ("<Down>", "advance_down"), ("<Up>", "advance_up"), ("<Control-plus>", "ins_item"), ("<Control-asterisk>", "ins_child_item"), ("<Control-minus>", "del_item"), ] + ( [] if not sys.platform.startswith("linux") else [ ("<Shift-ISO_Left_Tab>", "advance_left"), ] ) def __init__(self, master, allow=None, *args, **kwargs): super().__init__(master, *args, **kwargs) self._editvar = tk.StringVar(self, "") self._editbox = ttk.Entry(self, textvariable=self._editvar) self._edit_cell = None self._editable = {} self._all_columns = ["#0"] + list(kwargs.get("columns", [])) for name in self._all_columns: self._editable[name] = False self.bind("<Configure>", self._on_configure) for trigger, handler in self.list_bindings: if isinstance(handler, str): handler = getattr(self, handler) self.bind(trigger, handler) for trigger, handler in self.editbox_bindings: if isinstance(handler, str): handler = getattr(self, handler) self._editbox.bind(trigger, handler) self.allow = allow self.autoedit_added = True @property def allow(self): """Allowed structural edits (add, delete, addchild). Pass the allowed actions as list of strings or comma-separated string. Can be updated during operation. """ return self._allow[:] @allow.setter def allow(self, allow): allow = allow or [] if isinstance(allow, str): allow = allow.split(",") allow = [item.strip() for item in allow] bad_items = [ item for item in allow if item not in ["add", "addchild", "remove"] ] if bad_items: raise ValueError("Unknown allow entries: %s" % (bad_items,)) self._allow = allow self._update_controls() @property def autoedit_added(self) -> bool: """Automatically begin editing added items yes/no""" return self._autoedit_added @autoedit_added.setter def autoedit_added(self, val: bool): self._autoedit_added = bool(val)
[docs] @event def on_add(after_iid: str, /) -> bool | None: # type:ignore """Event: Item is about to be added. ``after_iid`` gives the currently-selected item. A handler may return ``False`` to indicate that it already took care about inserting item in the view. """
[docs] @event def on_add_child(below_iid: str, /) -> bool | None: # type:ignore """Event: Child item is about to be added. ``below_iid`` gives the currently-selected item. A handler may return ``False`` to indicate that it already took care about inserting item in the view. """
[docs] @event def on_remove(iid: str, /) -> bool | None: # type:ignore """Event: Item is about to be removed. A handler may return ``False`` to indicate that it already took care about removing item from the view. """
[docs] @event def on_cell_edit(iid: str, columnname: str, cur_value: str, /): # type:ignore """Event: cell is about to be edited."""
[docs] @event def on_cell_modified(iid: str, columname: str, new_value: str, /): # type:ignore """Event: editing is finished"""
[docs] def editable(self, column, editable=None): """Query or specify whether the column is editable. Only accepts Column Name or ``'#0'``. """ if column not in self._editable: raise KeyError(column) if editable is not None: self._editable[column] = bool(editable) return self._editable[column]
@property def _ed_list(self): return [name for name in self._all_columns if self._editable[name]]
[docs] def begin_edit(self, iid: str, column: str): """Show edit widget for the specified cell.""" self.close_edit() self.see(iid) self.focus(iid) self.update_idletasks() try: x, y, w, h = self.bbox(iid, column=column) except ValueError: # not visible return if column == "#0": val = self.item(iid, option="text") else: # self.set GETS the value! val = self.set(iid, column) self._editvar.set(val) self._editbox.place(x=x, y=y, width=w, height=h) self._edit_cell = (iid, column) self._editbox.selection_range(0, "end") self._editbox.focus_set() self.on_cell_edit(iid, column, self._editvar.get())
def _close_edit_refocus(self, ev=None, cancel=False): self.close_edit(ev, cancel) self.focus_set() return "break" def _cancel_edit_refocus(self, ev=None): self.close_edit(ev, cancel=True) self.focus_set() return "break"
[docs] def close_edit(self, ev: Any = None, cancel: bool = False): """Close the currently open editor, if any.""" if not cancel and self._edit_cell is not None: iid, column = self._edit_cell result = self.on_cell_modified(iid, column, self._editvar.get()) if result is None or result: # Modify content if column == "#0": self.item(iid, text=self._editvar.get()) else: self.set(iid, column, self._editvar.get()) self._edit_cell = None self._editbox.place_forget()
def _dblclick(self, ev): iid = self.identify_row(ev.y) column = self.identify_column(ev.x) idx = int(column[1:]) if idx > 0: dc = self["displaycolumns"] if dc == ("#all",): dc = self["columns"] colname = dc[idx - 1] else: colname = "#0" if not iid or not self._editable[colname]: return self.begin_edit(iid, colname) return "break"
[docs] def begin_edit_row(self, ev: Any = None): """Start editing the first editable column of the focused row.""" iid = self.focus() columns = self._ed_list if not columns: return self.begin_edit(iid, columns[0])
[docs] def advance(self, direction: Literal["left", "right", "up", "down"] = "right"): """switch to next cell. ``direction`` can be left, right, up, down. If going left/right beyond the first or last cell, edit box moves to the previous/next row. """ if self._edit_cell is None: return iid, column = self._edit_cell if direction == "down": iid = self.next(iid) elif direction == "up": iid = self.prev(iid) elif direction == "left": columns = self._ed_list idx = columns.index(column) - 1 if idx < 0: idx = len(columns) - 1 iid = self.prev(iid) column = columns[idx] elif direction == "right": columns = self._ed_list idx = columns.index(column) + 1 if idx >= len(columns): idx = 0 iid = self.next(iid) column = columns[idx] else: raise ValueError("invalid direction %s" % direction) if iid: self.begin_edit(iid, column) else: self._close_edit_refocus() # to disable default behaviour return "break"
[docs] def advance_left(self, ev: Any = None): return self.advance("left")
[docs] def advance_right(self, ev: Any = None): return self.advance("right")
[docs] def advance_up(self, ev: Any = None): return self.advance("up")
[docs] def advance_down(self, ev: Any = None): return self.advance("down")
def _on_configure(self, ev): self.close_edit() if self._controls: self._controls.place(relx=1, rely=1, anchor="se") def _update_controls(self): allow = self._allow if allow: ctls = self._controls = ttk.Frame(self) if "add" in allow: addbtn = ttk.Button(ctls, text=" + ", command=lambda: self.ins_item()) addbtn.pack(side="left") if "addchild" in allow: addcbtn = ttk.Button( ctls, text="+>", command=lambda: self.ins_item(child=True) ) addcbtn.pack(side="left") if "remove" in allow: delbtn = ttk.Button(ctls, text=" X ", command=self.del_item) delbtn.pack(side="left") else: self._controls = None
[docs] def ins_item(self, ev: Any = None, child: bool = False): """Trigger insertion of a new item.""" if ("addchild" if child else "add") not in self._allow: return f = self.focus() self.close_edit() if child: result = self.on_add_child(f) else: result = self.on_add(f) if result is None or result: new_iid = self.insert(f if child else self.parent(f), self.index(f) + 1) self.focus(new_iid) if self.autoedit_added: self.begin_edit_row(None)
[docs] def ins_child_item(self, ev: Any = None): """Trigger insertion of new child item""" return self.ins_item(ev, child=True)
[docs] def del_item(self, ev: Any = None): """Trigger deletion of focused item.""" if "remove" not in self._allow: return self.close_edit(cancel=True) iid = self.focus() if iid: result = self.on_remove(iid) if result is None or result: self.delete(iid)
def main(): import tkinter as tk from tkinter import ttk from tkinter import font tl = tk.Tk() tl.grid_rowconfigure(0, weight=1) tl.grid_columnconfigure(0, weight=1) style = ttk.Style() style.configure(".", font=font.Font(family="Helvetica")) style.configure("Treeview.Heading", font=("Helvetica", 10, "bold")) style.configure("Treeview", rowheight=30) te = TreeEdit(tl, columns=["col1", "col2", "col3"]) # te.pack(fill='both', expand=True) te.grid(row=0, column=0, sticky="nsew") te.allow = "add,remove,addchild" te.on_add += lambda focus: print("inserting an item after", focus) def delete_myself(iid: str): print("delete", iid) te.delete(iid) return False te.on_remove += delete_myself def print_begin(iid, column, current_val): print("Edit: ", iid, column, current_val) def print_change(iid, column, new_val): print("Modified: ", iid, column, new_val) te.on_cell_edit += print_begin te.on_cell_modified += print_change te.editable("#0", True) te.editable("col1", True) te.editable("col3", True) fakedata = [ ["this", "is", "a", "row"], ["another", "row", "is", "here"], ["lorem", "ipsum", "dolor", "sit"], ["romanes", "eunt", "domus", ""], ["romani", "ite", "domum", ""], ] * 5 parent = "" for i in range(2): iid = "" for data in fakedata: txt, t1, t2, t3 = data iid = te.insert(parent, "end", text=txt) te.set(iid, "col1", t1) te.set(iid, "col2", t2) te.set(iid, "col3", t3) parent = iid tl.mainloop() if __name__ == "__main__": main()