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