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