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