Widget Showcase¶

Tour of every major UI widget category.

â–¶ Run in browser

Tags: ui

Drives every widget category with simulated mouse/keyboard input, tests interactive states, showcases 2D canvas drawing, and doubles as a headless integration test.

The UI is organised into a TabContainer; each tab groups one family of widgets. The scripted demo (build_steps) walks through them in order so you can follow it live or replay it headless. The tabs, built by the _build_*_tab methods:

  1. Controls Button, CheckBox, SpinBox, Slider + ProgressBar, DropDown, TextEdit.

  2. Text Multi-line editor, code editor with syntax highlighting, ANSI rich text.

  3. Layout GridContainer, FormLayout, SplitContainer, ScrollContainer, MarginContainer.

  4. Trees A hierarchical TreeView plus a VirtualScrollContainer with 1000 items.

  5. Menus A MenuBar, a Toolbar with a toggle-button group, and a PopupMenu.

  6. Canvas 2D drawing (CanvasDemo): animated Line2D, Polygon2D, Path2D, noise grid, mesh star.

  7. Advanced GraphEdit node graph, ColourPicker, TerminalEmulator.

  8. Theme Switch the whole UI between built-in themes (Dark, Light, Monokai, …) live.

Run: uv run python examples/features/ui/widget_showcase.py # visual tour uv run python examples/features/ui/widget_showcase.py –test # headless, exit 0/1 uv run python examples/features/ui/widget_showcase.py –mode 2 # instant speed preset

Source¶

   1"""Widget Showcase: Tour of every major UI widget category.
   2
   3# /// simvx
   4# web = { width = 1280, height = 720, root = "UIShowcase" }
   5# ///
   6
   7Drives every widget category with simulated mouse/keyboard input, tests
   8interactive states, showcases 2D canvas drawing, and doubles as a headless
   9integration test.
  10
  11The UI is organised into a TabContainer; each tab groups one family of widgets.
  12The scripted demo (build_steps) walks through them in order so you can follow it
  13live or replay it headless. The tabs, built by the `_build_*_tab` methods:
  14
  15  0. Controls  Button, CheckBox, SpinBox, Slider + ProgressBar, DropDown, TextEdit.
  16  1. Text      Multi-line editor, code editor with syntax highlighting, ANSI rich text.
  17  2. Layout    GridContainer, FormLayout, SplitContainer, ScrollContainer, MarginContainer.
  18  3. Trees     A hierarchical TreeView plus a VirtualScrollContainer with 1000 items.
  19  4. Menus     A MenuBar, a Toolbar with a toggle-button group, and a PopupMenu.
  20  5. Canvas    2D drawing (CanvasDemo): animated Line2D, Polygon2D, Path2D, noise grid, mesh star.
  21  6. Advanced  GraphEdit node graph, ColourPicker, TerminalEmulator.
  22  7. Theme     Switch the whole UI between built-in themes (Dark, Light, Monokai, ...) live.
  23
  24Run:
  25    uv run python examples/features/ui/widget_showcase.py          # visual tour
  26    uv run python examples/features/ui/widget_showcase.py --test   # headless, exit 0/1
  27    uv run python examples/features/ui/widget_showcase.py --mode 2 # instant speed preset
  28"""
  29
  30import argparse
  31import math
  32import sys
  33
  34from simvx.core import (
  35    Colour,
  36    FastNoiseLite,
  37    Key,
  38    Line2D,
  39    Mesh2D,
  40    MeshInstance2D,
  41    MouseButton,
  42    Node,
  43    Node2D,
  44    NoiseType,
  45    Path2D,
  46    PathFollow2D,
  47    Polygon2D,
  48    Vec2,
  49)
  50from simvx.core.scripted_demo import Assert, DemoRunner, Do, Narrate, PressKey, TypeText, Wait
  51from simvx.core.ui import (
  52    AnchorPreset,
  53    AppTheme,
  54    Button,
  55    CheckBox,
  56    CodeTextEdit,
  57    ColourPicker,
  58    DropDown,
  59    FormLayout,
  60    GraphEdit,
  61    GraphNode,
  62    GridContainer,
  63    HBoxContainer,
  64    Label,
  65    MarginContainer,
  66    MenuBar,
  67    MenuItem,
  68    MultiLineTextEdit,
  69    Panel,
  70    PopupMenu,
  71    ProgressBar,
  72    RichTextLabel,
  73    ScrollContainer,
  74    Slider,
  75    SpinBox,
  76    SplitContainer,
  77    TabContainer,
  78    TerminalEmulator,
  79    TextEdit,
  80    Toolbar,
  81    ToolbarButton,
  82    TreeItem,
  83    TreeView,
  84    VBoxContainer,
  85    VirtualScrollContainer,
  86    set_theme,
  87)
  88
  89# ---------------------------------------------------------------------------
  90# Layout constants (design-resolution sizing for tab contents; the top-level
  91# panel and tab container are anchor-driven and follow the live window size)
  92# ---------------------------------------------------------------------------
  93W, H = 1280, 720
  94PANEL_X, PANEL_Y = 10, 0
  95PANEL_W, PANEL_H = W - 20, H
  96TITLE_H = 36
  97MENU_H = 28
  98TOOLBAR_H = 32
  99TAB_Y = PANEL_Y + TITLE_H + MENU_H + 4
 100TAB_HEADER_H = 28
 101TAB_W = PANEL_W - 40
 102TAB_H = PANEL_H - (TAB_Y - PANEL_Y) - TAB_HEADER_H - 40
 103STATUS_Y = PANEL_H - 28
 104
 105
 106# ============================================================================
 107# Canvas demo node: 2D drawing animated in process()
 108# ============================================================================
 109
 110
 111class CanvasDemo(Node2D):
 112    """2D drawing canvas with animated Line2D, Polygon2D, Path2D, noise grid."""
 113
 114    # on_draw animates the noise grid from a plain float, not from a Property,
 115    # so the retained 2D pipeline is told to re-capture it every frame.
 116    dynamic = True
 117
 118    def on_ready(self):
 119        self._time = 0.0
 120        ox, oy = 0.0, 0.0
 121
 122        # -- Line2D: sine wave --
 123        self._line = Line2D(position=(ox + 20, oy + 60), name="SineWave")
 124        self._line.colour = (0.0, 0.9, 0.9, 1.0)
 125        self._line.width = 2.0
 126        self.add_child(self._line)
 127
 128        # -- Polygon2D: hexagon --
 129        self._hex = Polygon2D(position=(ox + 500, oy + 120), name="Hexagon")
 130        self._hex.colour = (0.85, 0.2, 0.85, 0.8)
 131        self._hex_base = self._make_hexagon(40)
 132        self._hex.polygon = self._hex_base
 133        self.add_child(self._hex)
 134
 135        # -- Path2D + PathFollow2D --
 136        self._path = Path2D(position=(ox + 20, oy + 200), name="BezierPath")
 137        self._path.curve.add_point(Vec2(0, 0), handle_out=Vec2(80, -80))
 138        self._path.curve.add_point(Vec2(200, 0), handle_in=Vec2(-80, -80), handle_out=Vec2(80, 80))
 139        self._path.curve.add_point(Vec2(400, 0), handle_in=Vec2(-80, 80))
 140        self.add_child(self._path)
 141
 142        self._follower = PathFollow2D(name="Follower")
 143        self._follower.loop = True
 144        self._path.add_child(self._follower)
 145
 146        # Marker riding the follower: a child of the PathFollow2D, so it inherits
 147        # the follower's transform and needs no drawing code of its own.
 148        self._marker = Polygon2D(name="Marker")
 149        self._marker.polygon = [(-6, -6), (6, -6), (6, 6), (-6, 6)]
 150        self._marker.colour = (1.0, 0.9, 0.2, 1.0)
 151        self._follower.add_child(self._marker)
 152
 153        # -- MeshInstance2D: star --
 154        self._star = MeshInstance2D(position=(ox + 680, oy + 120), name="Star")
 155        self._star.mesh = self._make_star_mesh(5, 45, 20)
 156        self._star.modulate = (1.0, 0.6, 0.1, 0.9)
 157        self.add_child(self._star)
 158
 159        # -- Noise setup --
 160        self._noise = FastNoiseLite(seed=42)
 161        self._noise.noise_type = NoiseType.SIMPLEX
 162        self._noise.frequency = 0.06
 163        self._noise_ox = ox + 20
 164        self._noise_oy = oy + 300
 165        self._noise_cols = 16
 166        self._noise_rows = 8
 167        self._noise_cell = 14
 168
 169    @staticmethod
 170    def _make_hexagon(radius: float) -> list[tuple[float, float]]:
 171        return [(radius * math.cos(math.radians(60 * i)), radius * math.sin(math.radians(60 * i))) for i in range(6)]
 172
 173    @staticmethod
 174    def _make_star_mesh(points: int, outer_r: float, inner_r: float) -> Mesh2D:
 175        """Build a star as a triangle fan around a centre vertex.
 176
 177        ``Mesh2D.from_polygon`` fans from the first outline vertex, which is only
 178        correct for a convex polygon: a star's notches need the fan to start at
 179        the centre, so the mesh is assembled from explicit vertices and indices.
 180        """
 181        verts = [(0.0, 0.0)]
 182        for i in range(points * 2):
 183            angle = math.pi / 2 + i * math.pi / points
 184            r = outer_r if i % 2 == 0 else inner_r
 185            verts.append((r * math.cos(angle), r * math.sin(angle)))
 186        indices = []
 187        for i in range(1, points * 2):
 188            indices.extend([0, i, i + 1])
 189        indices.extend([0, points * 2, 1])
 190        return Mesh2D(vertices=verts, indices=indices)
 191
 192    def on_update(self, dt: float):
 193        self._time += dt
 194
 195        # Animate sine wave
 196        pts = []
 197        for i in range(80):
 198            x = i * 5.0
 199            y = math.sin(self._time * 2.0 + i * 0.15) * 40.0
 200            pts.append((x, y))
 201        self._line.points = pts
 202
 203        # Rotate hexagon
 204        self._hex.rotation = self._time * 0.8
 205
 206        # Advance path follower
 207        self._follower.progress += dt * 80.0
 208
 209        # Rotate star
 210        self._star.rotation = -self._time * 0.5
 211
 212    def on_draw(self, renderer):
 213        """Draw the noise grid and the baked path curve."""
 214        # Noise grid
 215        t = self._time * 0.5
 216        gp = self.world_position
 217        ox = self._noise_ox + gp.x
 218        oy = self._noise_oy + gp.y
 219        cell = self._noise_cell
 220        for r in range(self._noise_rows):
 221            for c in range(self._noise_cols):
 222                val = self._noise.get_noise_2d(c + t, r + t * 0.7)
 223                brightness = (val + 1.0) * 0.5
 224                colour = (brightness * 0.3, brightness * 0.7, brightness, 1.0)
 225                renderer.draw_rect(
 226                    (ox + c * cell, oy + r * cell),
 227                    (cell - 1, cell - 1),
 228                    colour=colour,
 229                    filled=True,
 230                )
 231
 232        # Path curve visualisation
 233        path_pts = self._path.curve.get_baked_points()
 234        pp = self._path.world_position
 235        for i in range(len(path_pts) - 1):
 236            a, b = path_pts[i], path_pts[i + 1]
 237            renderer.draw_line(
 238                (a.x + pp.x, a.y + pp.y),
 239                (b.x + pp.x, b.y + pp.y),
 240                colour=(1.0, 0.8, 0.0, 0.6),
 241                thickness=2.0,
 242            )
 243
 244
 245# ============================================================================
 246# Main showcase node
 247# ============================================================================
 248
 249
 250class UIShowcase(Node):
 251    """Root node for the comprehensive UI showcase."""
 252
 253    def on_ready(self):
 254        self._clicks = 0
 255        self._status = None  # created below
 256
 257        # -- Main panel --
 258        panel = Panel(name="MainPanel")
 259        panel.set_anchor_preset(AnchorPreset.FULL_RECT)
 260        panel.margin_left = PANEL_X
 261        panel.margin_right = PANEL_X
 262        # Panel bg comes from theme's panel_style: no override needed
 263        self.add_child(panel)
 264
 265        # Title
 266        title = Label("SimVX UI Showcase")
 267        title.font_size = 20.0
 268        # title text_colour follows theme via ThemeColour("text") default
 269        title.set_anchor_preset(AnchorPreset.TOP_WIDE)
 270        title.margin_top = 4
 271        title.size_y = TITLE_H
 272        title.alignment = "center"
 273        title.tooltip = "Comprehensive widget demonstration"
 274        panel.add_child(title)
 275
 276        # -- Menu bar --
 277        self._menubar = MenuBar(name="MainMenu")
 278        self._menubar.set_anchor_preset(AnchorPreset.TOP_WIDE)
 279        self._menubar.margin_top = TITLE_H
 280        self._menubar.size_y = MENU_H
 281        self._menubar.add_menu(
 282            "File",
 283            [
 284                MenuItem("New", callback=lambda: self._set_status("File > New")),
 285                MenuItem("Open", callback=lambda: self._set_status("File > Open")),
 286                MenuItem(separator=True),
 287                MenuItem("Exit", callback=lambda: self._set_status("File > Exit")),
 288            ],
 289        )
 290        self._menubar.add_menu(
 291            "Edit",
 292            [
 293                MenuItem("Undo", callback=lambda: self._set_status("Edit > Undo")),
 294                MenuItem("Redo", callback=lambda: self._set_status("Edit > Redo")),
 295            ],
 296        )
 297        self._menubar.add_menu(
 298            "View",
 299            [
 300                MenuItem("Fullscreen", callback=lambda: self._set_status("View > Fullscreen")),
 301            ],
 302        )
 303        panel.add_child(self._menubar)
 304
 305        # -- Tab container --
 306        self._tabs = TabContainer(name="ShowcaseTabs")
 307        self._tabs.set_anchor_preset(AnchorPreset.FULL_RECT)
 308        self._tabs.margin_left = 20
 309        self._tabs.margin_right = 20
 310        self._tabs.margin_top = TAB_Y - PANEL_Y
 311        self._tabs.margin_bottom = 40
 312        panel.add_child(self._tabs)
 313
 314        # Build all 8 tabs
 315        self._build_controls_tab()
 316        self._build_text_tab()
 317        self._build_layout_tab()
 318        self._build_trees_tab()
 319        self._build_menus_tab()
 320        self._build_canvas_tab()
 321        self._build_advanced_tab()
 322        self._build_theme_tab()
 323
 324        # -- Status bar --
 325        self._status = Label("Ready.")
 326        self._status.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
 327        self._status.margin_left = 20
 328        self._status.margin_right = 20
 329        self._status.margin_top = -(PANEL_H - STATUS_Y)
 330        self._status.margin_bottom = -(PANEL_H - STATUS_Y - 24)
 331        self._status.font_size = 11.0
 332        # status text_colour follows theme via ThemeColour default
 333        self._status.tooltip = "Status bar"
 334        panel.add_child(self._status)
 335
 336    def _set_status(self, text: str):
 337        if self._status:
 338            self._status.text = text
 339
 340    # ================================================================ Tab 0: Controls
 341
 342    def _build_controls_tab(self):
 343        page = VBoxContainer(name="Controls", separation=10.0)
 344
 345        form = FormLayout(name="ControlsForm", separation=8.0)
 346
 347        # Button
 348        btn_row = HBoxContainer(separation=8.0)
 349        self._btn = Button("Click Me", on_press=self._on_btn_click)
 350        self._btn.tooltip = "Click to increment counter"
 351        btn_row.add_child(self._btn)
 352        self._btn_disabled = Button("Disabled")
 353        self._btn_disabled.disabled = True
 354        self._btn_disabled.tooltip = "This button is disabled"
 355        btn_row.add_child(self._btn_disabled)
 356        form.add_field("Button:", btn_row)
 357
 358        # CheckBox
 359        self._cb = CheckBox("Enable feature", checked=False)
 360        self._cb.toggled.connect(self._on_checkbox)
 361        self._cb.tooltip = "Toggle a feature on/off"
 362        form.add_field("CheckBox:", self._cb)
 363
 364        # SpinBox
 365        self._spin = SpinBox(min_value=0, max_value=100, value=42, step=1)
 366        self._spin.value_changed.connect(lambda v: self._set_status(f"SpinBox: {int(v)}"))
 367        self._spin.tooltip = "Numeric input with +/- buttons"
 368        form.add_field("SpinBox:", self._spin)
 369
 370        # Slider + ProgressBar
 371        slider_col = VBoxContainer(separation=4.0)
 372        self._slider = Slider(min_value=0, max_value=100, value=50)
 373        self._slider.value_changed.connect(self._on_slider)
 374        self._slider.tooltip = "Drag to change value"
 375        slider_col.add_child(self._slider)
 376        self._progress = ProgressBar(min_value=0, max_value=100, value=50)
 377        self._progress.tooltip = "Displays slider value"
 378        slider_col.add_child(self._progress)
 379        form.add_field("Slider:", slider_col)
 380
 381        # DropDown
 382        self._dd = DropDown(items=["Low", "Medium", "High", "Ultra"], selected_index=1)
 383        self._dd.item_selected.connect(self._on_dropdown)
 384        self._dd.tooltip = "Select quality level"
 385        form.add_field("DropDown:", self._dd)
 386
 387        # TextEdit
 388        self._te = TextEdit(placeholder="Type here...")
 389        self._te.text_changed.connect(lambda t: self._set_status(f"TextEdit: {t}"))
 390        self._te.tooltip = "Single-line text input"
 391        form.add_field("TextEdit:", self._te)
 392
 393        page.add_child(form)
 394        self._tabs.add_child(page)
 395
 396    def _on_btn_click(self):
 397        self._clicks += 1
 398        self._btn.text = f"Clicked {self._clicks}x"
 399        self._set_status(f"Button clicked {self._clicks} time(s)")
 400
 401    def _on_checkbox(self, checked):
 402        self._set_status(f"CheckBox: {'ON' if checked else 'OFF'}")
 403
 404    def _on_slider(self, value):
 405        self._progress.value = value
 406        self._set_status(f"Slider: {int(value)}")
 407
 408    def _on_dropdown(self, index):
 409        self._set_status(f"DropDown: {self._dd.selected_text}")
 410
 411    # ================================================================ Tab 1: Text
 412    def _build_text_tab(self):
 413        page = SplitContainer(name="Text")
 414        page.size = Vec2(TAB_W - 4, TAB_H - 4)
 415
 416        # Left: MultiLineTextEdit
 417        mle_text = (
 418            "SimVX Engine\n\nA node-based game engine\nin pure Python.\n\n"
 419            "Features:\n- Node hierarchy\n- Vulkan rendering\n- Signal system\n"
 420            "- Animation\n- Audio\n- 50+ UI widgets"
 421        )
 422        self._mle = MultiLineTextEdit(text=mle_text)
 423        self._mle.show_line_numbers = True
 424        self._mle.size = Vec2(TAB_W // 2 - 10, TAB_H - 10)
 425        self._mle.tooltip = "Multi-line text editor"
 426        page.add_child(self._mle)
 427
 428        # Right: Code + RichText
 429        right = VBoxContainer(name="TextRight", separation=8.0)
 430
 431        code_text = (
 432            'def hello():\n    """Greet the world."""\n    print("Hello, SimVX!")\n\n'
 433            "for i in range(10):\n    hello()"
 434        )
 435        self._code = CodeTextEdit(text=code_text)
 436        self._code.size = Vec2(TAB_W // 2 - 10, TAB_H // 2 - 10)
 437        self._code.tooltip = "Python code editor with syntax highlighting"
 438        right.add_child(self._code)
 439
 440        rich_text = (
 441            "\033[1;36mSimVX\033[0m \033[32mEngine\033[0m\n"
 442            "\033[1;33mWarning:\033[0m This is \033[1;31mcolourful\033[0m text\n"
 443            "\033[34mBlue\033[0m \033[35mMagenta\033[0m \033[36mCyan\033[0m"
 444        )
 445        self._rich = RichTextLabel(text=rich_text)
 446        self._rich.size = Vec2(TAB_W // 2 - 10, TAB_H // 2 - 10)
 447        self._rich.tooltip = "Rich text with ANSI colour codes"
 448        right.add_child(self._rich)
 449
 450        page.add_child(right)
 451        self._tabs.add_child(page)
 452
 453    # ================================================================ Tab 2: Layout
 454    def _build_layout_tab(self):
 455        page = VBoxContainer(name="Layout", separation=10.0)
 456
 457        # Section 1: GridContainer
 458        sec1_label = Label("GridContainer (4 columns)")
 459        sec1_label.font_size = 13.0
 460        page.add_child(sec1_label)
 461
 462        grid = GridContainer(columns=4, name="ColourGrid", separation=4.0)
 463        colours = ["#E63946", "#457B9D", "#1D3557", "#F4A261", "#2A9D8F", "#E9C46A", "#264653", "#A8DADC"]
 464        for i, c in enumerate(colours):
 465            p = Panel(name=f"GridCell{i}")
 466            p.size = Vec2(100, 40)
 467            p.bg_colour = Colour.hex(c)
 468            p.tooltip = f"Colour: {c}"
 469            grid.add_child(p)
 470        page.add_child(grid)
 471
 472        # Section 2: FormLayout
 473        sec2_label = Label("FormLayout")
 474        sec2_label.font_size = 13.0
 475        page.add_child(sec2_label)
 476
 477        form = FormLayout(name="DemoForm", separation=6.0)
 478        name_edit = TextEdit(text="Player1")
 479        name_edit.size = Vec2(200, 26)
 480        form.add_field("Name:", name_edit)
 481        speed_spin = SpinBox(min_value=0, max_value=500, value=120, step=10)
 482        speed_spin.size = Vec2(140, 26)
 483        form.add_field("Speed:", speed_spin)
 484        active_cb = CheckBox("Active", checked=True)
 485        active_cb.size = Vec2(140, 26)
 486        form.add_field("Status:", active_cb)
 487        page.add_child(form)
 488
 489        # Section 3: SplitContainer + ScrollContainer
 490        sec3_label = Label("SplitContainer + ScrollContainer")
 491        sec3_label.font_size = 13.0
 492        page.add_child(sec3_label)
 493
 494        split = SplitContainer(name="LayoutSplit")
 495        split.size = Vec2(TAB_W - 10, 140)
 496
 497        left_panel = Panel(name="SplitLeft")
 498        left_panel.size = Vec2(200, 130)
 499        left_panel.bg_colour = None  # theme default: smoke test that None reverts to theme
 500        left_label = Label("Left pane")
 501        left_label.set_anchor_preset(AnchorPreset.TOP_LEFT)
 502        left_label.margin_left = 10
 503        left_label.margin_top = 10
 504        left_label.size = Vec2(180, 24)
 505        left_panel.add_child(left_label)
 506        split.add_child(left_panel)
 507
 508        # The rows are buttons, not labels: the wheel crosses a widget that
 509        # handles input on its way to the list, so the list scrolls wherever
 510        # the cursor rests over it.
 511        self._scroll = ScrollContainer(name="LayoutScroll")
 512        self._scroll.size = Vec2(TAB_W - 220, 130)
 513        scroll_content = VBoxContainer(name="ScrollContent", separation=2.0)
 514        self._scroll_rows = []
 515        for i in range(20):
 516            row = Button(f"Scrollable item {i + 1}", name=f"ScrollRow{i}")
 517            row.size = Vec2(TAB_W - 240, 22)
 518            scroll_content.add_child(row)
 519            self._scroll_rows.append(row)
 520        self._scroll.add_child(scroll_content)
 521        split.add_child(self._scroll)
 522
 523        page.add_child(split)
 524
 525        # Section 4: MarginContainer
 526        margin = MarginContainer(margin=12, name="MarginDemo")
 527        margin.size = Vec2(300, 50)
 528        inner = Panel(name="MarginInner")
 529        inner.size = Vec2(276, 26)
 530        inner_label = Label("Inside MarginContainer")
 531        inner_label.set_anchor_preset(AnchorPreset.TOP_LEFT)
 532        inner_label.margin_left = 8
 533        inner_label.margin_top = 2
 534        inner_label.size = Vec2(260, 22)
 535        inner.add_child(inner_label)
 536        margin.add_child(inner)
 537        page.add_child(margin)
 538
 539        self._tabs.add_child(page)
 540
 541    # ================================================================ Tab 3: Trees
 542    def _build_trees_tab(self):
 543        page = SplitContainer(name="Trees")
 544        page.size = Vec2(TAB_W - 4, TAB_H - 4)
 545
 546        # Left: TreeView
 547        tree_col = VBoxContainer(name="TreeCol", separation=4.0)
 548        tree_label = Label("Scene Hierarchy")
 549        tree_label.font_size = 13.0
 550        tree_col.add_child(tree_label)
 551
 552        root_item = TreeItem("Scene")
 553        player = root_item.add_child(TreeItem("Player"))
 554        player.add_child(TreeItem("Sprite"))
 555        player.add_child(TreeItem("Collider"))
 556        enemies = root_item.add_child(TreeItem("Enemies"))
 557        enemies.add_child(TreeItem("Goblin"))
 558        enemies.add_child(TreeItem("Dragon"))
 559        ui_item = root_item.add_child(TreeItem("UI"))
 560        ui_item.add_child(TreeItem("HUD"))
 561        ui_item.add_child(TreeItem("Menu"))
 562
 563        self._tree_view = TreeView(root=root_item, name="SceneTree")
 564        self._tree_view.size = Vec2(TAB_W // 2 - 10, TAB_H - 40)
 565        self._tree_view.item_selected.connect(lambda item: self._set_status(f"Tree: {item.text}"))
 566        self._tree_view.tooltip = "Scene hierarchy tree"
 567        tree_col.add_child(self._tree_view)
 568        page.add_child(tree_col)
 569
 570        # Right: VirtualScrollContainer
 571        vs_col = VBoxContainer(name="VSCol", separation=4.0)
 572        vs_label = Label("Virtual Scroll (1000 items)")
 573        vs_label.font_size = 13.0
 574        vs_col.add_child(vs_label)
 575
 576        self._vs = VirtualScrollContainer(item_height=24.0, show_scrollbar=True, name="VScroll")
 577        self._vs.size = Vec2(TAB_W // 2 - 10, TAB_H - 40)
 578        self._vs.tooltip = "Virtual scrolling: only visible items are rendered"
 579
 580        def _make_item(index, recycled):
 581            lbl = recycled or Label()
 582            lbl.text = f"  Item {index + 1:04d}"
 583            lbl.font_size = 12.0
 584            return lbl
 585
 586        self._vs.set_data_source(1000, _make_item)
 587        vs_col.add_child(self._vs)
 588        page.add_child(vs_col)
 589
 590        self._tabs.add_child(page)
 591
 592    # ================================================================ Tab 4: Menus
 593    def _build_menus_tab(self):
 594        page = VBoxContainer(name="Menus", separation=10.0)
 595
 596        # Secondary MenuBar
 597        sec_label = Label("Secondary MenuBar")
 598        sec_label.font_size = 13.0
 599        page.add_child(sec_label)
 600
 601        self._menu2 = MenuBar(name="SecondMenu")
 602        self._menu2.size = Vec2(TAB_W - 10, MENU_H)
 603        self._menu2.add_menu(
 604            "Actions",
 605            [
 606                MenuItem("Build", callback=lambda: self._set_status("Actions > Build")),
 607                MenuItem("Deploy", callback=lambda: self._set_status("Actions > Deploy")),
 608            ],
 609        )
 610        self._menu2.add_menu(
 611            "Options",
 612            [
 613                MenuItem("Settings", callback=lambda: self._set_status("Options > Settings")),
 614            ],
 615        )
 616        page.add_child(self._menu2)
 617
 618        # Toolbar
 619        tb_label = Label("Toolbar with toggle group")
 620        tb_label.font_size = 13.0
 621        page.add_child(tb_label)
 622
 623        self._toolbar2 = Toolbar(name="DemoToolbar")
 624        self._toolbar2.size = Vec2(TAB_W - 10, TOOLBAR_H)
 625        self._brush_btn = ToolbarButton("Brush", toggle_mode=True, group="tools")
 626        self._brush_btn.tooltip = "Brush tool"
 627        self._toolbar2.add_child(self._brush_btn)
 628        self._eraser_btn = ToolbarButton("Eraser", toggle_mode=True, group="tools")
 629        self._eraser_btn.tooltip = "Eraser tool"
 630        self._toolbar2.add_child(self._eraser_btn)
 631        self._fill_btn = ToolbarButton("Fill", toggle_mode=True, group="tools")
 632        self._fill_btn.tooltip = "Fill tool"
 633        self._toolbar2.add_child(self._fill_btn)
 634        tb_apply = ToolbarButton("Apply", on_press=lambda: self._set_status("Toolbar: Apply"))
 635        self._toolbar2.add_child(tb_apply)
 636        tb_reset = ToolbarButton("Reset", on_press=lambda: self._set_status("Toolbar: Reset"))
 637        self._toolbar2.add_child(tb_reset)
 638        page.add_child(self._toolbar2)
 639
 640        # Popup menu trigger
 641        popup_label = Label("PopupMenu")
 642        popup_label.font_size = 13.0
 643        page.add_child(popup_label)
 644
 645        self._popup_btn = Button("Show Popup Menu", on_press=self._show_popup)
 646        self._popup_btn.size = Vec2(180, 30)
 647        self._popup_btn.tooltip = "Click to show a context menu"
 648        page.add_child(self._popup_btn)
 649
 650        self._popup = PopupMenu(
 651            items=[
 652                MenuItem("Cut", callback=lambda: self._set_status("Popup: Cut")),
 653                MenuItem("Copy", callback=lambda: self._set_status("Popup: Copy")),
 654                MenuItem("Paste", callback=lambda: self._set_status("Popup: Paste")),
 655                MenuItem(separator=True),
 656                MenuItem("Delete", callback=lambda: self._set_status("Popup: Delete")),
 657            ]
 658        )
 659        page.add_child(self._popup)
 660
 661        self._tabs.add_child(page)
 662
 663    def _show_popup(self):
 664        self._popup.show(x=200, y=350)
 665        self._set_status("Popup menu shown")
 666
 667    # ================================================================ Tab 5: Canvas
 668    def _build_canvas_tab(self):
 669        page = VBoxContainer(name="Canvas", separation=4.0)
 670
 671        canvas_label = Label("2D Drawing: Line2D, Polygon2D, Path2D, Noise, MeshInstance2D")
 672        canvas_label.font_size = 13.0
 673        canvas_label.size = Vec2(TAB_W - 10, 20)
 674        page.add_child(canvas_label)
 675
 676        # 2D drawing canvas: Node2D child of this Control page, so it
 677        # automatically draws offset to the page's screen position and
 678        # clipped to its bounds.
 679        self._canvas = CanvasDemo(name="CanvasDemo")
 680        page.add_child(self._canvas)
 681
 682        self._tabs.add_child(page)
 683
 684    # ================================================================ Tab 6: Advanced
 685    def _build_advanced_tab(self):
 686        page = VBoxContainer(name="Advanced", separation=8.0)
 687
 688        top = SplitContainer(name="AdvancedTop")
 689        top.size = Vec2(TAB_W - 10, TAB_H // 2)
 690
 691        # GraphEdit
 692        graph_col = VBoxContainer(name="GraphCol", separation=4.0)
 693        graph_label = Label("GraphEdit")
 694        graph_label.font_size = 13.0
 695        graph_col.add_child(graph_label)
 696
 697        self._graph = GraphEdit(name="DemoGraph")
 698        self._graph.size = Vec2(TAB_W // 2 - 20, TAB_H // 2 - 30)
 699
 700        source = GraphNode(name="Source", title="Source")
 701        source.add_output("Data", type="float")
 702        source.graph_position = (20, 20)
 703        self._graph.add_graph_node(source)
 704
 705        process_node = GraphNode(name="Process", title="Process")
 706        process_node.add_input("In", type="float")
 707        process_node.add_output("Out", type="float")
 708        process_node.graph_position = (220, 40)
 709        self._graph.add_graph_node(process_node)
 710
 711        output_node = GraphNode(name="Output", title="Output")
 712        output_node.add_input("Result", type="float")
 713        output_node.graph_position = (420, 20)
 714        self._graph.add_graph_node(output_node)
 715
 716        self._graph.connect_node("Source", 0, "Process", 0)
 717        self._graph.connect_node("Process", 0, "Output", 0)
 718        self._graph.tooltip = "Node graph editor"
 719        graph_col.add_child(self._graph)
 720        top.add_child(graph_col)
 721
 722        # ColourPicker
 723        picker_col = VBoxContainer(name="PickerCol", separation=4.0)
 724        picker_label = Label("ColourPicker")
 725        picker_label.font_size = 13.0
 726        picker_col.add_child(picker_label)
 727
 728        self._picker = ColourPicker(name="DemoPicker")
 729        self._picker.size = Vec2(TAB_W // 2 - 20, TAB_H // 2 - 30)
 730        self._picker.colour_changed.connect(lambda c: self._set_status(f"Colour: ({c[0]:.2f}, {c[1]:.2f}, {c[2]:.2f})"))
 731        self._picker.tooltip = "HSV colour picker"
 732        picker_col.add_child(self._picker)
 733        top.add_child(picker_col)
 734
 735        page.add_child(top)
 736
 737        # Terminal
 738        term_label = Label("TerminalEmulator")
 739        term_label.font_size = 13.0
 740        page.add_child(term_label)
 741
 742        self._term = TerminalEmulator(name="DemoTerminal")
 743        self._term.size = Vec2(TAB_W - 10, TAB_H // 2 - 40)
 744        self._term.tooltip = "VT100 terminal emulator"
 745        self._term.write("\033[1;36m=== SimVX Terminal ===\033[0m\n")
 746        self._term.write("\033[32mWelcome\033[0m to the integrated terminal.\n")
 747        self._term.write("\033[33m$\033[0m Ready for input.\n")
 748        page.add_child(self._term)
 749
 750        self._tabs.add_child(page)
 751
 752    # ================================================================ Tab 7: Theme
 753    def _build_theme_tab(self):
 754        page = VBoxContainer(name="Theme", separation=10.0)
 755
 756        theme_label = Label("Theme Switching")
 757        theme_label.font_size = 14.0
 758        page.add_child(theme_label)
 759
 760        # Radio-like toggle group for theme selection
 761        theme_row = HBoxContainer(name="ThemeRow", separation=8.0)
 762        self._theme_btns: dict[str, ToolbarButton] = {}
 763        for label, key in [
 764            ("Dark", "dark"),
 765            ("Abyss", "abyss"),
 766            ("Midnight", "midnight"),
 767            ("Light", "light"),
 768            ("Monokai", "monokai"),
 769            ("Solarised", "solarised_dark"),
 770            ("Nord", "nord"),
 771        ]:
 772            b = ToolbarButton(label, toggle_mode=True, group="theme")
 773            b.toggled.connect((lambda k: lambda active: self._apply_theme(k) if active else None)(key))
 774            b.tooltip = f"{label} theme"
 775            theme_row.add_child(b)
 776            self._theme_btns[key] = b
 777        self._theme_btns["dark"].active = True
 778        page.add_child(theme_row)
 779
 780        # Preview panel
 781        preview_label = Label("Live Preview")
 782        preview_label.font_size = 13.0
 783        page.add_child(preview_label)
 784
 785        preview = VBoxContainer(name="ThemePreview", separation=8.0)
 786
 787        preview_btn = Button("Preview Button")
 788        preview_btn.size = Vec2(160, 30)
 789        preview_btn.tooltip = "A themed button"
 790        preview.add_child(preview_btn)
 791
 792        preview_slider = Slider(min_value=0, max_value=100, value=65)
 793        preview_slider.size = Vec2(280, 24)
 794        preview.add_child(preview_slider)
 795
 796        preview_progress = ProgressBar(min_value=0, max_value=100, value=65)
 797        preview_progress.size = Vec2(280, 20)
 798        preview.add_child(preview_progress)
 799
 800        preview_edit = TextEdit(text="Theme preview text")
 801        preview_edit.size = Vec2(280, 28)
 802        preview.add_child(preview_edit)
 803
 804        preview_cb = CheckBox("Preview checkbox", checked=True)
 805        preview_cb.size = Vec2(200, 26)
 806        preview.add_child(preview_cb)
 807
 808        page.add_child(preview)
 809        self._tabs.add_child(page)
 810
 811    def _apply_theme(self, name: str):
 812        themes = {
 813            "dark": AppTheme.dark,
 814            "abyss": AppTheme.abyss,
 815            "midnight": AppTheme.midnight,
 816            "light": AppTheme.light,
 817            "monokai": AppTheme.monokai,
 818            "solarised_dark": AppTheme.solarised_dark,
 819            "nord": AppTheme.nord,
 820        }
 821        factory = themes.get(name)
 822        if factory:
 823            set_theme(factory())
 824            self._set_status(f"Theme: {name.capitalize()}")
 825
 826
 827# ============================================================================
 828# Demo steps
 829# ============================================================================
 830
 831
 832def _find_showcase(root: Node) -> UIShowcase:
 833    """Find the UIShowcase node in the tree."""
 834    for child in root.children:
 835        if isinstance(child, UIShowcase):
 836            return child
 837    return root
 838
 839
 840def _centre(widget) -> tuple[float, float]:
 841    """Return the screen-space centre of a widget's global rect."""
 842    x, y, w, h = widget.get_global_rect()
 843    return (x + w / 2, y + h / 2)
 844
 845
 846def _click_widget(steps: list, getter, desc: str = "", at: tuple[float, float] = (0.5, 0.5)):
 847    """Append a Do+Click sequence that clicks a widget found via getter(showcase).
 848
 849    ``at`` picks the point as fractions of the widget's rect (centre by default).
 850    """
 851    # Use a mutable container to pass coordinates from Do to Click
 852    pos = [0.0, 0.0]
 853
 854    def _resolve(g):
 855        s = _find_showcase(g)
 856        widget = getter(s)
 857        x, y, w, h = widget.get_global_rect()
 858        pos[0], pos[1] = x + w * at[0], y + h * at[1]
 859
 860    steps.append(Do(_resolve, f"Resolve {desc}"))
 861    # Click at a fixed location that gets updated by the Do step above
 862    # Since DemoRunner processes steps sequentially, we use a deferred click
 863    steps.append(
 864        Do(
 865            lambda g: g.tree.ui_input(mouse_pos=(pos[0], pos[1]), button=MouseButton.LEFT, pressed=True),
 866            f"Press {desc}",
 867        )
 868    )
 869    steps.append(Wait(0.05))
 870    steps.append(
 871        Do(
 872            lambda g: g.tree.ui_input(mouse_pos=(pos[0], pos[1]), button=MouseButton.LEFT, pressed=False),
 873            f"Release {desc}",
 874        )
 875    )
 876
 877
 878def _scroll_widget(steps: list, getter, desc: str = "", ticks: int = 3):
 879    """Append a deferred wheel-down over the centre of a widget found via getter(showcase)."""
 880    pos = [0.0, 0.0]
 881
 882    def _resolve(g):
 883        pos[0], pos[1] = _centre(getter(_find_showcase(g)))
 884
 885    steps.append(Do(_resolve, f"Resolve {desc}"))
 886    for _ in range(ticks):
 887        steps.append(
 888            Do(
 889                lambda g: g.tree.ui_input(mouse_pos=(pos[0], pos[1]), key="scroll_down", pressed=True),
 890                f"Wheel over {desc}",
 891            )
 892        )
 893
 894
 895def _select_tab(steps: list, index: int):
 896    """Switch to tab by index using the TabContainer API."""
 897
 898    def _switch(g, idx=index):
 899        tabs = _find_showcase(g)._tabs
 900        tabs.current_tab = idx
 901        tabs._update_layout()
 902
 903    steps.append(Do(_switch, f"Switch to tab {index}"))
 904    steps.append(Wait(0.2))
 905
 906
 907def build_steps() -> list:
 908    steps: list = []
 909
 910    # -- Intro --
 911    steps.append(Narrate("SimVX UI Showcase: 50+ widgets, 2D canvas, automated testing", duration=2.5))
 912    steps.append(Wait(0.5))
 913
 914    # ======== Tab 0: Controls (already active) ========
 915    steps.append(Narrate("Controls: buttons, sliders, checkboxes, dropdowns", duration=2.0))
 916    steps.append(Wait(0.3))
 917
 918    # Click "Click Me" button
 919    _click_widget(steps, lambda s: s._btn, "Click Me button")
 920    steps.append(Wait(0.2))
 921    steps.append(Assert(lambda g: _find_showcase(g)._clicks >= 1, "Button click registered"))
 922
 923    # Toggle checkbox
 924    _click_widget(steps, lambda s: s._cb, "CheckBox")
 925    steps.append(Wait(0.2))
 926    steps.append(Assert(lambda g: _find_showcase(g)._cb.checked, "CheckBox toggled on"))
 927
 928    # Move slider via programmatic set (slider drag requires precise coord sequence)
 929    steps.append(Do(lambda g: setattr(_find_showcase(g)._slider, "value", 75), "Set slider to 75"))
 930    steps.append(Do(lambda g: _find_showcase(g)._on_slider(75), "Trigger slider callback"))
 931    steps.append(Wait(0.2))
 932    steps.append(Assert(lambda g: _find_showcase(g)._slider.value > 50, "Slider at 75"))
 933    steps.append(Assert(lambda g: _find_showcase(g)._progress.value > 50, "ProgressBar follows slider"))
 934
 935    # Set dropdown
 936    steps.append(Do(lambda g: setattr(_find_showcase(g)._dd, "selected_index", 2), "Select 'High'"))
 937    steps.append(Wait(0.2))
 938    steps.append(
 939        Assert(
 940            lambda g: _find_showcase(g)._dd.selected_text == "High",
 941            "DropDown selected 'High'",
 942            actual_fn=lambda g: f"selected={_find_showcase(g)._dd.selected_text}",
 943        )
 944    )
 945
 946    # Click TextEdit and type
 947    _click_widget(steps, lambda s: s._te, "TextEdit")
 948    steps.append(Wait(0.1))
 949    steps.append(TypeText("SimVX"))
 950    steps.append(Wait(0.2))
 951    steps.append(
 952        Assert(
 953            lambda g: "SimVX" in _find_showcase(g)._te.text,
 954            "TextEdit contains 'SimVX'",
 955            actual_fn=lambda g: f"text='{_find_showcase(g)._te.text}'",
 956        )
 957    )
 958
 959    # ======== Tab 1: Text ========
 960    _select_tab(steps, 1)
 961    steps.append(Narrate("Text: multi-line editor, code editor with syntax highlighting, rich text", duration=2.0))
 962    steps.append(Wait(0.5))
 963
 964    # Click low in the code editor (the cursor clamps to the last line), go to
 965    # its end, and type onto a FRESH line -- typing into the middle of the
 966    # pre-filled code reads as corruption, not as a demo.
 967    # The editor binds ctrl+end for exactly this, which would replace the
 968    # click-at-92%-height with something that cannot miss, but a demo step
 969    # cannot hold a modifier across another key press: PressKey releases within
 970    # its own step. Switch once the runner grows key-down / key-up steps.
 971    _click_widget(steps, lambda s: s._code, "CodeTextEdit", at=(0.08, 0.92))
 972    steps.append(Wait(0.1))
 973    steps.append(PressKey(Key.END))
 974    steps.append(Wait(0.1))
 975    steps.append(TypeText("\nx = 42"))
 976    steps.append(Wait(0.2))
 977    steps.append(
 978        Assert(
 979            lambda g: _find_showcase(g)._code.text.endswith("x = 42"),
 980            "Code editor gained 'x = 42' on a new last line",
 981            actual_fn=lambda g: f"text tail={_find_showcase(g)._code.text[-30:]!r}",
 982        )
 983    )
 984
 985    # ======== Tab 2: Layout ========
 986    _select_tab(steps, 2)
 987    steps.append(Narrate("Layout: grid, form, split, scroll, margin containers", duration=2.0))
 988    steps.append(Wait(0.8))
 989
 990    # The wheel over a button row still scrolls the list the row belongs to.
 991    _scroll_widget(steps, lambda s: s._scroll_rows[1], "a scrollable row")
 992    steps.append(Wait(0.2))
 993    steps.append(
 994        Assert(
 995            lambda g: _find_showcase(g)._scroll.scroll_y > 0.0,
 996            "Wheel over a button row scrolled the list",
 997            actual_fn=lambda g: f"scroll_y={_find_showcase(g)._scroll.scroll_y}",
 998        )
 999    )
1000
1001    # ======== Tab 3: Trees ========
1002    _select_tab(steps, 3)
1003    steps.append(Narrate("Trees: hierarchical tree view, virtual scroll with 1000 items", duration=2.0))
1004    steps.append(Wait(0.5))
1005
1006    # ======== Tab 4: Menus ========
1007    _select_tab(steps, 4)
1008    steps.append(Narrate("Menus: menu bar, toolbar with toggle groups, popup menus", duration=2.0))
1009    steps.append(Wait(0.5))
1010
1011    # Toggle "Eraser" tool
1012    _click_widget(steps, lambda s: s._eraser_btn, "Eraser tool")
1013    steps.append(Wait(0.2))
1014    steps.append(Assert(lambda g: _find_showcase(g)._eraser_btn.active, "Eraser tool active"))
1015
1016    # ======== Tab 5: Canvas ========
1017    _select_tab(steps, 5)
1018    steps.append(
1019        Narrate(
1020            "Canvas: Line2D sine wave, Polygon2D hexagon, Path2D bezier, noise grid, star mesh",
1021            duration=2.5,
1022        )
1023    )
1024    steps.append(Wait(2.0))
1025
1026    # ======== Tab 6: Advanced ========
1027    _select_tab(steps, 6)
1028    steps.append(Narrate("Advanced: graph editor with connected nodes, colour picker, terminal emulator", duration=2.5))
1029    steps.append(Wait(0.8))
1030
1031    # Verify graph connections
1032    steps.append(
1033        Assert(
1034            lambda g: len(_find_showcase(g)._graph.get_connections()) == 2,
1035            "Graph has 2 connections",
1036        )
1037    )
1038
1039    # Write to terminal
1040    steps.append(
1041        Do(
1042            lambda g: _find_showcase(g)._term.write("\033[32m>>> Demo test passed!\033[0m\n"),
1043            "Write to terminal",
1044        )
1045    )
1046    steps.append(Wait(0.3))
1047
1048    # ======== Tab 7: Theme ========
1049    _select_tab(steps, 7)
1050    steps.append(Narrate("Theme: switch between Dark, Light, and Monokai themes live", duration=2.0))
1051
1052    # Cycle through a few themes
1053    _click_widget(steps, lambda s: s._theme_btns["monokai"], "Monokai theme")
1054    steps.append(Wait(0.5))
1055    _click_widget(steps, lambda s: s._theme_btns["abyss"], "Abyss theme")
1056    steps.append(Wait(0.5))
1057    _click_widget(steps, lambda s: s._theme_btns["light"], "Light theme")
1058    steps.append(Wait(0.5))
1059    _click_widget(steps, lambda s: s._theme_btns["dark"], "Dark theme")
1060    steps.append(Wait(0.3))
1061
1062    # -- Finish --
1063    steps.append(Narrate("Demo complete! All 50+ widgets showcased and tested.", duration=2.0))
1064    steps.append(Wait(1.0))
1065
1066    return steps
1067
1068
1069# ============================================================================
1070# Entry points
1071# ============================================================================
1072
1073
1074def run_headless(speed: float = 50.0) -> bool:
1075    root = Node(name="DemoRoot")
1076    root.add_child(UIShowcase(name="Showcase"))
1077    return DemoRunner.run_headless(
1078        root,
1079        build_steps(),
1080        speed=speed,
1081        screen_size=(W, H),
1082        delay_between_steps=0.0,
1083    )
1084
1085
1086def run_visual(speed: float | None = None, speed_mode: int = 0, backend: str | None = None):
1087    root = Node(name="DemoRoot")
1088    root.add_child(UIShowcase(name="Showcase"))
1089    DemoRunner.run_visual(
1090        root,
1091        build_steps(),
1092        speed=speed,
1093        speed_mode=speed_mode,
1094        title="SimVX UI Showcase",
1095        width=W,
1096        height=H,
1097        backend=backend,
1098    )
1099
1100
1101if __name__ == "__main__":
1102    parser = argparse.ArgumentParser(description="SimVX Comprehensive UI Showcase Demo")
1103    parser.add_argument("--test", action="store_true", help="Headless test (exit 0/1)")
1104    parser.add_argument("--speed", type=float, default=None, help="Speed multiplier")
1105    parser.add_argument(
1106        "--mode",
1107        type=int,
1108        choices=[0, 1, 2],
1109        default=0,
1110        help="Speed preset: 0=slow, 1=medium, 2=instant",
1111    )
1112    parser.add_argument("--backend", type=str, default=None, choices=["glfw", "sdl3"], help="Windowing backend")
1113    args = parser.parse_args()
1114
1115    if args.test:
1116        ok = run_headless(args.speed or 50.0)
1117        sys.exit(0 if ok else 1)
1118    else:
1119        run_visual(speed=args.speed, speed_mode=args.mode, backend=args.backend)