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