TreeView¶
live scene tree inspector with expand/collapse.
▶ Run in browserTags: ui
Displays a simulated scene hierarchy in a TreeView widget. Users can
expand/collapse branches, select items, add children to the selected
node, or remove the selected node. A status label reflects the
current selection. Editing the item graph is followed by
TreeView.refresh(), which is what puts the change on screen.
Run: uv run python examples/features/ui/tree.py Headless self-check: uv run python examples/features/ui/tree.py –test
Source¶
1"""TreeView -- live scene tree inspector with expand/collapse.
2
3Displays a simulated scene hierarchy in a TreeView widget. Users can
4expand/collapse branches, select items, add children to the selected
5node, or remove the selected node. A status label reflects the
6current selection. Editing the item graph is followed by
7`TreeView.refresh()`, which is what puts the change on screen.
8
9Run: uv run python examples/features/ui/tree.py
10Headless self-check: uv run python examples/features/ui/tree.py --test
11"""
12
13from simvx.core import Colour, Node, Vec2
14from simvx.core.ui import (
15 AnchorPreset,
16 Button,
17 HBoxContainer,
18 Label,
19 Panel,
20 TreeItem,
21 TreeView,
22 VBoxContainer,
23)
24from simvx.graphics import App
25
26
27class TreeDemo(Node):
28 """Root node for the tree view demo scene."""
29
30 def on_ready(self):
31 self._node_counter = 0
32
33 # --- Background panel ---
34 panel = Panel(name="MainPanel")
35 # Anchored to the viewport with a comfortable gutter, so the panel
36 # tracks the window size (the tree inside keeps its fixed size).
37 panel.set_anchor_preset(AnchorPreset.FULL_RECT)
38 panel.margin_left = 30
39 panel.margin_top = 20
40 panel.margin_right = 30
41 panel.margin_bottom = 20
42 panel.bg_colour = Colour.hex("#1A1A2E")
43 panel.border_colour = Colour.hex("#16213E")
44 self.add_child(panel)
45
46 vbox = VBoxContainer(name="Layout")
47 vbox.set_anchor_preset(AnchorPreset.FULL_RECT)
48 vbox.margin_left = 10
49 vbox.margin_top = 10
50 vbox.margin_right = 10
51 vbox.margin_bottom = 10
52 vbox.separation = 10
53 panel.add_child(vbox)
54
55 # --- Title ---
56 title = Label("Scene Tree Inspector")
57 title.text_colour = Colour.hex("#E94560")
58 title.font_size = 18.0
59 title.size = Vec2(400, 28)
60 title.alignment = "center"
61 vbox.add_child(title)
62
63 # --- Build scene hierarchy ---
64 root_item = TreeItem("Root")
65
66 world = root_item.add_child(TreeItem("World"))
67 world.add_child(TreeItem("Player (Node3D)"))
68 world.add_child(TreeItem("Enemy1 (Node3D)"))
69 world.add_child(TreeItem("Enemy2 (Node3D)"))
70
71 ui_branch = root_item.add_child(TreeItem("UI"))
72 ui_branch.add_child(TreeItem("HUD"))
73 ui_branch.add_child(TreeItem("Menu"))
74
75 root_item.add_child(TreeItem("Camera"))
76
77 # --- TreeView widget ---
78 tree = TreeView(root=root_item, name="SceneTree")
79 tree.size = Vec2(400, 300)
80 tree.bg_colour = Colour.hex("#0A0A1A")
81 tree.select_colour = Colour.hex("#0F3460")
82 tree.text_colour = Colour.LIGHT_GRAY
83 vbox.add_child(tree)
84
85 # --- Status label ---
86 status = Label("Selected: (none)")
87 status.text_colour = Colour.CYAN
88 status.size = Vec2(400, 22)
89 vbox.add_child(status)
90
91 def on_select(item):
92 status.text = f"Selected: {item.text}"
93
94 tree.item_selected.connect(on_select)
95
96 # --- Action buttons ---
97 btn_row = HBoxContainer(name="Actions")
98 btn_row.size = Vec2(400, 35)
99 btn_row.separation = 10
100 vbox.add_child(btn_row)
101
102 # TreeView flattens the item graph into rows once and reuses that list
103 # every frame, so a structural edit is invisible until refresh() drops
104 # the cache. That invalidation is explicit: the widget does not watch
105 # the items for changes.
106 def add_node():
107 parent = tree.selected or root_item
108 self._node_counter += 1
109 parent.add_child(TreeItem(f"NewNode{self._node_counter}"))
110 parent.expanded = True
111 tree.refresh()
112
113 def remove_selected():
114 sel = tree.selected
115 if sel and sel is not root_item and sel.parent:
116 sel.parent.remove_child(sel)
117 tree.selected = None
118 status.text = "Selected: (none)"
119 tree.refresh()
120
121 add_btn = Button("Add Node", on_press=add_node)
122 add_btn.size = Vec2(120, 35)
123 add_btn.bg_colour = Colour.hex("#0F3460")
124 add_btn.hover_colour = Colour.hex("#1A4A7A")
125 btn_row.add_child(add_btn)
126
127 rm_btn = Button("Remove Selected", on_press=remove_selected)
128 rm_btn.size = Vec2(160, 35)
129 rm_btn.bg_colour = Colour.hex("#533483")
130 rm_btn.hover_colour = Colour.hex("#6A4599")
131 btn_row.add_child(rm_btn)
132
133
134def _selftest() -> bool:
135 """Headless: expand, collapse, select, add and remove by clicking the widget.
136
137 Rows are clicked at the coordinates the TreeView draws them at, computed from
138 its own rect, ``row_height`` and ``indent``, so a selection here is the one a
139 pointer would make and an arrow click lands on the arrow. What the tree shows
140 is read back from the draw log, so a row that exists but never renders fails.
141 """
142 from simvx.core.ui.testing import UITestHarness
143
144 harness = UITestHarness(TreeDemo(name="TreeDemo"), screen_size=(800, 600))
145 ok = True
146
147 def check(label: str, passed: bool, detail: str) -> None:
148 nonlocal ok
149 ok = ok and passed
150 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
151
152 harness.tick()
153 tree = harness.find_by_name("SceneTree")
154 status = harness.find_by_text("Selected: (none)")
155 chrome = {"Scene Tree Inspector", "Add Node", "Remove Selected"}
156
157 def rows() -> list[str]:
158 """The row labels the TreeView actually drew this tick."""
159 harness.tick()
160 return [t for t in harness.draw_log.texts() if t not in chrome and not t.startswith("Selected:")]
161
162 def click_row(index: int, depth: int, *, arrow: bool = False) -> None:
163 """Click a drawn row, on its expand arrow or on its label."""
164 x, y, _, _ = tree.get_global_rect()
165 row_x = x + depth * tree.indent
166 cx = row_x + tree.row_height * (0.5 if arrow else 2.0)
167 harness.click((cx, y + index * tree.row_height + tree.row_height / 2))
168 harness.tick()
169
170 full = rows()
171 check(
172 "the tree opens fully expanded, deepest branches and all",
173 full
174 == ["Root", "World", "Player (Node3D)", "Enemy1 (Node3D)", "Enemy2 (Node3D)", "UI", "HUD", "Menu", "Camera"],
175 ", ".join(full),
176 )
177
178 click_row(1, 1)
179 check("clicking a row selects it and the status line says which", status.text == "Selected: World", status.text)
180
181 click_row(1, 1, arrow=True)
182 collapsed = rows()
183 check(
184 "clicking its arrow collapses the branch, hiding the rows under it",
185 collapsed == ["Root", "World", "UI", "HUD", "Menu", "Camera"],
186 ", ".join(collapsed),
187 )
188
189 click_row(1, 1, arrow=True)
190 check("and clicking it again brings them back", rows() == full, ", ".join(rows()))
191
192 # Collapsing the root hides everything beneath it in one go, which is the
193 # claim that the flattening is recursive rather than one level deep.
194 click_row(0, 0, arrow=True)
195 check("collapsing the root leaves only the root", rows() == ["Root"], ", ".join(rows()))
196 click_row(0, 0, arrow=True)
197
198 # A leaf has no arrow, so a click anywhere on its row selects it.
199 click_row(6, 2)
200 check("a leaf row has no arrow to swallow the click", status.text == "Selected: HUD", status.text)
201
202 # Add Node and Remove Selected act on the selection, and the row list they
203 # produce is read back from the draw log, so a change the widget never
204 # repaints does not count as one.
205 click_row(1, 1)
206 harness.click(harness.find_by_text("Add Node"))
207 added = rows()
208 check(
209 "Add Node hangs a new child off the selected branch, and the tree draws it",
210 added == full[:5] + ["NewNode1"] + full[5:],
211 ", ".join(added),
212 )
213
214 click_row(5, 2)
215 selected_new = status.text
216 harness.click(harness.find_by_text("Remove Selected"))
217 removed = rows()
218 check(
219 "Remove Selected takes that row back out again",
220 selected_new == "Selected: NewNode1" and removed == full,
221 f"{selected_new} -> {status.text}: {', '.join(removed)}",
222 )
223
224 harness.teardown()
225 print("SELFTEST:", "PASS" if ok else "FAIL")
226 return ok
227
228
229if __name__ == "__main__":
230 import sys
231
232 if "--test" in sys.argv:
233 sys.exit(0 if _selftest() else 1)
234 app = App(title="SimVX Tree Demo", width=800, height=600)
235 app.run(TreeDemo())