Skip to content

Commit 8c77b65

Browse files
authored
Construct tree by rendering it interactively (#385)
* docs: reorder import * test: fix test coverage * feat: add render tree * feat: add render tree * feat: add render tree * fix: pyvis enhancements * test: fix test coverage + omit render from being tested
1 parent 9127dd2 commit 8c77b65

File tree

11 files changed

+303
-38
lines changed

11 files changed

+303
-38
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added:
99
- Tree Query: Support "LIKE", "IN", and "BETWEEN" operations, remove support for "contains" and "in" operations.
10+
- Tree Render: Construct trees dynamically using interactive UI, using `render_tree`, powered by Tkinter.
11+
## Changed:
12+
- Misc: Shift pyvis constants to utils.constants file.
1013
### Fixed:
1114
- Docs: Relax type hints, remove mutable defaults.
15+
- Build: Fixed hatch dependencies to use hatch shortcuts.
1216

1317
## [0.29.0] - 2025-05-09
1418
### Added:
15-
- Tree Query: Query tree using SQL-like syntax.
19+
- Tree Query: Query tree using SQL-like syntax, using `query_tree`, powered by Lark parser.
1620

1721
## [0.28.0] - 2025-04-19
1822
### Added:

bigtree/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
newick_to_tree,
2626
polars_to_tree,
2727
polars_to_tree_by_relation,
28+
render_tree,
2829
str_to_tree,
2930
)
3031
from bigtree.tree.export import (

bigtree/dag/export.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def dag_to_dict(
105105
data_child.update(
106106
child_node.describe(exclude_attributes=["name"], exclude_prefix="_")
107107
)
108-
else:
108+
elif attr_dict:
109109
for k, v in attr_dict.items():
110110
data_child[v] = child_node.get_attr(k)
111111
data_dict[child_node.node_name] = data_child
@@ -171,7 +171,7 @@ def dag_to_dataframe(
171171
data_child.update(
172172
child_node.describe(exclude_attributes=["name"], exclude_prefix="_")
173173
)
174-
else:
174+
elif attr_dict:
175175
for k, v in attr_dict.items():
176176
data_child[v] = child_node.get_attr(k)
177177
data_list.append(data_child)

bigtree/tree/construct/__init__.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,26 @@
1515
nested_dict_to_tree,
1616
)
1717
from .lists import list_to_tree, list_to_tree_by_relation
18+
from .render import render_tree
1819
from .strings import add_path_to_tree, newick_to_tree, str_to_tree
1920

2021
__all__ = [
21-
"add_path_to_tree",
22-
"add_dict_to_tree_by_path",
23-
"add_dict_to_tree_by_name",
24-
"add_dataframe_to_tree_by_path",
2522
"add_dataframe_to_tree_by_name",
26-
"add_polars_to_tree_by_path",
23+
"add_dataframe_to_tree_by_path",
2724
"add_polars_to_tree_by_name",
28-
"str_to_tree",
29-
"list_to_tree",
30-
"list_to_tree_by_relation",
31-
"dict_to_tree",
32-
"nested_dict_to_tree",
25+
"add_polars_to_tree_by_path",
3326
"dataframe_to_tree",
3427
"dataframe_to_tree_by_relation",
3528
"polars_to_tree",
3629
"polars_to_tree_by_relation",
30+
"add_dict_to_tree_by_name",
31+
"add_dict_to_tree_by_path",
32+
"dict_to_tree",
33+
"nested_dict_to_tree",
34+
"list_to_tree",
35+
"list_to_tree_by_relation",
36+
"render_tree",
37+
"add_path_to_tree",
3738
"newick_to_tree",
39+
"str_to_tree",
3840
]

bigtree/tree/construct/render.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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()

bigtree/tree/export/__init__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,20 @@
1818
from .vis import tree_to_vis # noqa
1919

2020
__all__ = [
21-
"print_tree",
22-
"yield_tree",
23-
"hprint_tree",
24-
"hyield_tree",
25-
"vprint_tree",
26-
"vyield_tree",
2721
"tree_to_dataframe",
2822
"tree_to_polars",
2923
"tree_to_dict",
3024
"tree_to_nested_dict",
3125
"tree_to_dot",
26+
"tree_to_mermaid",
3227
"tree_to_pillow",
3328
"tree_to_pillow_graph",
34-
"tree_to_mermaid",
29+
"hprint_tree",
30+
"hyield_tree",
31+
"print_tree",
3532
"tree_to_newick",
33+
"vprint_tree",
34+
"vyield_tree",
35+
"yield_tree",
3636
"tree_to_vis",
3737
]

bigtree/tree/export/vis.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from bigtree.node import node
66
from bigtree.tree.export.stdout import yield_tree
7-
from bigtree.utils import exceptions, plot
7+
from bigtree.utils import constants, exceptions, plot
88

99
try:
1010
import pyvis
@@ -68,25 +68,27 @@ def tree_to_vis(
6868
custom_node_kwargs: mapping of pyvis Node kwarg to tree node attribute if present. This allows custom node
6969
attributes to be set. Possible keys include value (for node size), color (for node colour)
7070
node_kwargs: kwargs for Node for all nodes, accepts keys: color etc.
71-
custom_node_kwargs: mapping of pyvis Edge kwarg to tree node attribute if present. This allows custom edge
71+
custom_edge_kwargs: mapping of pyvis Edge kwarg to tree node attribute if present. This allows custom edge
7272
attributes to be set. Possible keys include width (for edge weight)
7373
edge_kwargs: kwargs for Edge for all edges, accept keys: weight etc.
7474
network_kwargs: kwargs for Network, accepts keys: height, width, bgcolor, font_color, notebook, select_menu etc.
7575
7676
Returns:
7777
pyvis object for display
7878
"""
79-
DEFAULT_PLOT_KWARGS = {
80-
"sibling_separation": 100,
81-
"subtree_separation": 100,
82-
"level_separation": 100,
79+
pyvis_params = constants.PyVisParameters
80+
plot_kwargs = {**pyvis_params.DEFAULT_PLOT_KWARGS, **(plot_kwargs or {})}
81+
custom_node_kwargs = {
82+
**pyvis_params.DEFAULT_CUSTOM_NODE_KWARGS,
83+
**(custom_node_kwargs or {}),
8384
}
84-
DEFAULT_CUSTOM_NODE_KWARGS = {"title": "node_name"}
85-
DEFAULT_NODE_KWARGS = {"value": 10}
8685

87-
plot_kwargs = {**DEFAULT_PLOT_KWARGS, **(plot_kwargs or {})}
88-
custom_node_kwargs = {**DEFAULT_CUSTOM_NODE_KWARGS, **(custom_node_kwargs or {})}
89-
node_kwargs = {**DEFAULT_NODE_KWARGS, **(node_kwargs or {})}
86+
plot_kwargs = {**pyvis_params.DEFAULT_PLOT_KWARGS, **(plot_kwargs or {})}
87+
custom_node_kwargs = {
88+
**pyvis_params.DEFAULT_CUSTOM_NODE_KWARGS,
89+
**(custom_node_kwargs or {}),
90+
}
91+
node_kwargs = {**pyvis_params.DEFAULT_NODE_KWARGS, **(node_kwargs or {})}
9092
custom_edge_kwargs = custom_edge_kwargs or {}
9193
edge_kwargs = edge_kwargs or {}
9294
network_kwargs = network_kwargs or {}

bigtree/utils/constants.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,13 @@ class NewickCharacter(str, Enum):
399399
@classmethod
400400
def values(cls) -> List[str]:
401401
return [c.value for c in cls]
402+
403+
404+
class PyVisParameters:
405+
DEFAULT_PLOT_KWARGS = {
406+
"sibling_separation": 100,
407+
"subtree_separation": 100,
408+
"level_separation": 100,
409+
}
410+
DEFAULT_CUSTOM_NODE_KWARGS = {"title": "node_name"}
411+
DEFAULT_NODE_KWARGS = {"value": 10}

docs/bigtree/tree/construct.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ Construct Tree from list, dictionary, and pandas DataFrame.
1010

1111
To decide which method to use, consider your data type and data values.
1212

13-
| Construct tree from | Using full path | Using parent-child relation | Using notation | Add node attributes |
14-
|---------------------|---------------------|---------------------------------|---------------------|-----------------------------------------------------------|
15-
| String | ` str_to_tree` | NA | ` newick_to_tree` | No (for ` str_to_tree `)<br>Yes (for `newick_to_tree`) |
16-
| List | ` list_to_tree` | ` list_to_tree_by_relation` | NA | No |
17-
| Dictionary | ` dict_to_tree` | ` nested_dict_to_tree` | NA | Yes |
18-
| pandas DataFrame | `dataframe_to_tree` | `dataframe_to_tree_by_relation` | NA | Yes |
19-
| polars DataFrame | `polars_to_tree` | `polars_to_tree_by_relation` | NA | Yes |
13+
| Construct tree from | Using full path | Using parent-child relation | Using notation | Add node attributes |
14+
|---------------------|---------------------|---------------------------------|------------------|---------------------------------------------------------|
15+
| String | `str_to_tree` | NA | `newick_to_tree` | No (for ` str_to_tree `)<br>Yes (for `newick_to_tree`) |
16+
| List | `list_to_tree` | ` list_to_tree_by_relation` | NA | No |
17+
| Dictionary | `dict_to_tree` | ` nested_dict_to_tree` | NA | Yes |
18+
| pandas DataFrame | `dataframe_to_tree` | `dataframe_to_tree_by_relation` | NA | Yes |
19+
| polars DataFrame | `polars_to_tree` | `polars_to_tree_by_relation` | NA | Yes |
20+
| Interactive UI | NA | `render_tree` | NA | No |
2021

2122
## Tree Add Attributes Methods
2223

0 commit comments

Comments
 (0)