Widget Showcase

Play Demo

SimVX Comprehensive UI Showcase Demo.

Tours every major 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 packages/graphics/examples/ui_widget_showcase.py uv run python packages/graphics/examples/ui_widget_showcase.py –test uv run python packages/graphics/examples/ui_widget_showcase.py –mode 2

Source Code

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