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.

Run: uv run python examples/features/ui/widget_showcase.py uv run python examples/features/ui/widget_showcase.py –test uv run python examples/features/ui/widget_showcase.py –mode 2

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