TreeView

live scene tree inspector with expand/collapse.

▶ Run in browser

Tags: 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())