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