|
| 1 | +import tkinter as tk |
| 2 | +from tkinter import ttk |
| 3 | +from typing import TYPE_CHECKING, Any |
| 4 | + |
| 5 | +from bigtree.node import node |
| 6 | + |
| 7 | +if TYPE_CHECKING: |
| 8 | + TkEvent = tk.Event[tk.Widget] |
| 9 | +else: |
| 10 | + TkEvent = Any |
| 11 | + |
| 12 | +__all__ = ["render_tree"] |
| 13 | + |
| 14 | + |
| 15 | +class TkinterTree: |
| 16 | + def __init__( |
| 17 | + self, |
| 18 | + root: tk.Tk, |
| 19 | + title: str = "Tree Render", |
| 20 | + root_name: str = "Root", |
| 21 | + ): |
| 22 | + """Tree render using Tkinter. |
| 23 | +
|
| 24 | + Args: |
| 25 | + root: existing Tkinter object |
| 26 | + title: title of render for window pop-up |
| 27 | + root_name: initial root name of tree |
| 28 | + """ |
| 29 | + self.counter = 0 |
| 30 | + |
| 31 | + root.title(title) |
| 32 | + |
| 33 | + tree = ttk.Treeview(root) |
| 34 | + tree.pack(fill=tk.BOTH, expand=True) |
| 35 | + |
| 36 | + # Hidden entry for inline editing |
| 37 | + entry = tk.Entry(root) |
| 38 | + entry.bind("<FocusOut>", lambda e: entry.place_forget()) |
| 39 | + entry.bind("<Return>", self.on_return) |
| 40 | + entry.bind("<plus>", self.on_plus) |
| 41 | + tree.bind("<plus>", self.on_plus) |
| 42 | + tree.bind("<Delete>", self.on_delete) |
| 43 | + tree.bind("<Double-1>", self.on_double_click) |
| 44 | + |
| 45 | + # Insert nodes |
| 46 | + tree_root = tree.insert("", "end", iid=self.get_iid(), text=root_name) |
| 47 | + tree.insert(tree_root, "end", iid=self.get_iid(), text="Child 1") |
| 48 | + |
| 49 | + # Add button |
| 50 | + tk.Button(root, text="Add Child", command=self.on_plus).pack() |
| 51 | + tk.Button(root, text="Print Tree", command=self.print_tree).pack() |
| 52 | + tk.Button(root, text="Export Tree", command=self.export_tree).pack() |
| 53 | + |
| 54 | + self.tree = tree |
| 55 | + self.tree_root = tree_root |
| 56 | + self.entry = entry |
| 57 | + |
| 58 | + def get_iid(self) -> str: |
| 59 | + """Get iid of item |
| 60 | +
|
| 61 | + Returns: |
| 62 | + iid |
| 63 | + """ |
| 64 | + self.counter += 1 |
| 65 | + return str(self.counter) |
| 66 | + |
| 67 | + def validate_name(self, parent: str, name: str) -> None: |
| 68 | + """Validate name to ensure it is not empty and there is no duplicated name. |
| 69 | +
|
| 70 | + Args: |
| 71 | + parent: parent iid |
| 72 | + name: name of child to be validate |
| 73 | + """ |
| 74 | + if not name: |
| 75 | + raise ValueError("No text detected. Please key in a valid entry.") |
| 76 | + for child in self.tree.get_children(parent): |
| 77 | + if self.tree.item(child, "text") == name: |
| 78 | + raise ValueError("No duplicate names allowed") |
| 79 | + |
| 80 | + def entry_place_forget(self) -> None: |
| 81 | + """Reset self.entry""" |
| 82 | + self.entry.place_forget() |
| 83 | + self.entry._target_parent = None # type: ignore |
| 84 | + self.entry._current_item = None # type: ignore |
| 85 | + |
| 86 | + def get_tree(self) -> node.Node: |
| 87 | + """Get bigtree node.Node from tkinter tree""" |
| 88 | + |
| 89 | + def _add_child(_node: node.Node, node_iid: str) -> None: |
| 90 | + for child_iid in self.tree.get_children(node_iid): |
| 91 | + child_name = self.tree.item(child_iid)["text"] |
| 92 | + child_node = node.Node(child_name, parent=_node) |
| 93 | + _add_child(child_node, child_iid) |
| 94 | + |
| 95 | + root = node.Node(self.tree.item(self.tree_root)["text"]) |
| 96 | + _add_child(root, self.tree_root) |
| 97 | + return root |
| 98 | + |
| 99 | + def print_tree(self) -> None: |
| 100 | + """Export tree, print tree to console. Tree can be constructed into a bigtree object using |
| 101 | + bigtree.tree.construct.str_to_tree.""" |
| 102 | + tree = self.get_tree() |
| 103 | + tree.show() |
| 104 | + |
| 105 | + def export_tree(self) -> None: |
| 106 | + """Export tree, print tree dictionary to console. Tree can be constructed into a bigtree object using |
| 107 | + bigtree.tree.construct.dict_to_tree""" |
| 108 | + from pprint import pprint |
| 109 | + |
| 110 | + from bigtree.tree import export |
| 111 | + |
| 112 | + tree = self.get_tree() |
| 113 | + pprint(export.tree_to_dict(tree)) |
| 114 | + |
| 115 | + def on_return(self, event: TkEvent) -> None: |
| 116 | + """Add or rename node""" |
| 117 | + item_id = getattr(self.entry, "_current_item", None) |
| 118 | + parent = getattr(self.entry, "_target_parent", None) |
| 119 | + name = self.entry.get().strip() |
| 120 | + if item_id: |
| 121 | + # Rename node |
| 122 | + self.validate_name(self.tree.parent(item_id), name) |
| 123 | + self.tree.item(item_id, text=name) |
| 124 | + elif parent: |
| 125 | + # Add node |
| 126 | + self.validate_name(parent, name) |
| 127 | + self.tree.insert(parent, "end", text=name, iid=self.get_iid()) |
| 128 | + self.entry_place_forget() |
| 129 | + |
| 130 | + def on_plus(self, event: TkEvent = None) -> None: |
| 131 | + """Add node, assigns _target_parent to entry""" |
| 132 | + parent = self.tree.selection()[0] if self.tree.selection() else self.tree_root |
| 133 | + self.tree.item(parent, open=True) |
| 134 | + |
| 135 | + # Focus entry below parent |
| 136 | + bbox = self.tree.bbox(parent) |
| 137 | + if bbox: |
| 138 | + cum_height = sum( |
| 139 | + [self.tree.bbox(child)[3] for child in self.tree.get_children(parent)] # type: ignore |
| 140 | + ) |
| 141 | + x, y, width, height = bbox |
| 142 | + self.entry.place( |
| 143 | + x=x + 20, y=y + cum_height + height, width=120, height=height |
| 144 | + ) |
| 145 | + self.entry.delete(0, tk.END) |
| 146 | + self.entry.focus() |
| 147 | + self.entry._target_parent = parent # type: ignore |
| 148 | + |
| 149 | + def on_delete(self, event: TkEvent) -> None: |
| 150 | + """Delete selected node(s)""" |
| 151 | + for item in self.tree.selection(): |
| 152 | + if item is not self.tree_root: |
| 153 | + self.tree.delete(item) |
| 154 | + |
| 155 | + def on_double_click(self, event: TkEvent) -> None: |
| 156 | + """Rename node, assigns _current_item to entry""" |
| 157 | + # Identify item |
| 158 | + item_id = self.tree.identify_row(event.y) |
| 159 | + if not item_id: |
| 160 | + return |
| 161 | + |
| 162 | + # Focus entry on item |
| 163 | + bbox = self.tree.bbox(item_id) |
| 164 | + if bbox: |
| 165 | + x, y, width, height = bbox |
| 166 | + self.entry.place(x=x, y=y, width=width, height=height) |
| 167 | + self.entry.delete(0, tk.END) |
| 168 | + self.entry.insert(0, self.tree.item(item_id, "text")) |
| 169 | + self.entry.focus() |
| 170 | + self.entry._current_item = item_id # type: ignore |
| 171 | + |
| 172 | + |
| 173 | +def render_tree( |
| 174 | + title: str = "Tree Render", |
| 175 | + root_name: str = "Root", |
| 176 | +) -> None: |
| 177 | + """Renders tree with tkinter, exports tree to JSON file. |
| 178 | +
|
| 179 | + Viewing Interaction: |
| 180 | +
|
| 181 | + - Expand/Hide Children: Press "Enter" (might have to re-click on item to expand/hide) |
| 182 | +
|
| 183 | + Editing Interaction: |
| 184 | +
|
| 185 | + - Add node: Press "+" / Click "Add Child" button |
| 186 | + - Delete node: Press "Delete" |
| 187 | + - Rename node: Double click |
| 188 | +
|
| 189 | + Export Interaction: |
| 190 | +
|
| 191 | + - Print tree to console: Click "Print Tree" button |
| 192 | + - Print tree dictionary to console: Click "Export Tree" button |
| 193 | +
|
| 194 | + Args: |
| 195 | + title: title of render for window pop-up |
| 196 | + root_name: initial root name of tree |
| 197 | +
|
| 198 | + Returns: |
| 199 | + Tree render in window pop-up |
| 200 | + """ |
| 201 | + root = tk.Tk() |
| 202 | + TkinterTree(root, title, root_name) |
| 203 | + root.mainloop() |
| 204 | + |
| 205 | + |
| 206 | +if __name__ == "__main__": |
| 207 | + render_tree() |
0 commit comments