Menu Bar¶
a desktop-style menu strip with dropdown menus.
▶ Run in browserTags: ui menu menubar desktop-ui
A MenuBar pinned to the top of the window with File, Edit and View menus.
Each dropdown lists items with shortcut hints (File and Edit group theirs with
separators), opens over the content panel below, and closes when an item is
chosen. A status label along
the bottom edge echoes the last action so every entry is visibly wired up.
What it demonstrates¶
MenuBar.add_menu(title, items)building a menu strip from plain data.MenuItem(text=..., callback=..., shortcut=...)andMenuItem(separator=True).Reserving the bar’s fixed height inside a stretching
VBoxContainerwithMenuBar.BAR_HEIGHT,min_sizeandstretch_ratio = 0.Dropdowns drawing over the content panel beneath them.
Run: uv run python examples/features/ui/menus.py Headless self-check: uv run python examples/features/ui/menus.py –test
Source¶
1"""Menu Bar: a desktop-style menu strip with dropdown menus.
2
3A `MenuBar` pinned to the top of the window with File, Edit and View menus.
4Each dropdown lists items with shortcut hints (File and Edit group theirs with
5separators), opens over the content panel below, and closes when an item is
6chosen. A status label along
7the bottom edge echoes the last action so every entry is visibly wired up.
8
9# /// simvx
10# tags = ["ui", "menu", "menubar", "desktop-ui"]
11# web = { root = "MenuDemo", width = 800, height = 600, responsive = true }
12# ///
13
14## What it demonstrates
15- `MenuBar.add_menu(title, items)` building a menu strip from plain data.
16- `MenuItem(text=..., callback=..., shortcut=...)` and `MenuItem(separator=True)`.
17- Reserving the bar's fixed height inside a stretching `VBoxContainer` with
18 `MenuBar.BAR_HEIGHT`, `min_size` and `stretch_ratio = 0`.
19- Dropdowns drawing over the content panel beneath them.
20
21Run: uv run python examples/features/ui/menus.py
22Headless self-check: uv run python examples/features/ui/menus.py --test
23"""
24
25from simvx.core import (
26 AnchorPreset,
27 Colour,
28 Label,
29 MenuBar,
30 MenuItem,
31 Node,
32 Panel,
33 SizingMode,
34 VBoxContainer,
35 Vec2,
36)
37from simvx.graphics import App
38
39
40class MenuDemo(Node):
41 """Root node for menu bar demo scene."""
42
43 def on_ready(self):
44 root = VBoxContainer(name="Root")
45 root.set_anchor_preset(AnchorPreset.FULL_RECT)
46 root.separation = 0
47 root.sizing = SizingMode.EXPAND
48 self.add_child(root)
49
50 # --- Menu bar (fixed height, full width) ---
51 menubar = MenuBar(name="menubar")
52 menubar.min_size = Vec2(0, MenuBar.BAR_HEIGHT)
53 menubar.stretch_ratio = 0.0
54 root.add_child(menubar)
55
56 status = Label("Ready")
57 status.text_colour = Colour.LIGHT_GRAY
58
59 def make_action(label):
60 def handler():
61 status.text = f"Action: {label}"
62
63 return handler
64
65 # File menu
66 menubar.add_menu(
67 "File",
68 [
69 MenuItem(text="New", callback=make_action("New"), shortcut="Ctrl+N"),
70 MenuItem(text="Open", callback=make_action("Open"), shortcut="Ctrl+O"),
71 MenuItem(text="Save", callback=make_action("Save"), shortcut="Ctrl+S"),
72 MenuItem(separator=True),
73 MenuItem(text="Quit", callback=lambda: self.app.quit(), shortcut="Ctrl+Q"),
74 ],
75 )
76
77 # Edit menu
78 menubar.add_menu(
79 "Edit",
80 [
81 MenuItem(text="Undo", callback=make_action("Undo"), shortcut="Ctrl+Z"),
82 MenuItem(text="Redo", callback=make_action("Redo"), shortcut="Ctrl+Y"),
83 MenuItem(separator=True),
84 MenuItem(text="Cut", callback=make_action("Cut"), shortcut="Ctrl+X"),
85 MenuItem(text="Copy", callback=make_action("Copy"), shortcut="Ctrl+C"),
86 MenuItem(text="Paste", callback=make_action("Paste"), shortcut="Ctrl+V"),
87 ],
88 )
89
90 # View menu
91 menubar.add_menu(
92 "View",
93 [
94 MenuItem(text="Zoom In", callback=make_action("Zoom In"), shortcut="Ctrl++"),
95 MenuItem(text="Zoom Out", callback=make_action("Zoom Out"), shortcut="Ctrl+-"),
96 MenuItem(text="Reset Zoom", callback=make_action("Reset Zoom"), shortcut="Ctrl+0"),
97 ],
98 )
99
100 # --- Main content panel (takes the remaining window height) ---
101 content = Panel(name="ContentPanel")
102 content.bg_colour = Colour.hex("#1A1A2E")
103 root.add_child(content)
104
105 center_label = Label("Click File, Edit or View to open a menu")
106 center_label.text_colour = Colour.GRAY
107 center_label.font_size = 16.0
108 center_label.set_anchor_preset(AnchorPreset.CENTER)
109 center_label.margin_left = -220
110 center_label.margin_right = 220
111 center_label.margin_top = -15
112 center_label.margin_bottom = 15
113 center_label.alignment = "center"
114 content.add_child(center_label)
115
116 # --- Status bar ---
117 status.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
118 status.margin_left = 10
119 status.margin_right = 10
120 status.margin_top = -30
121 status.margin_bottom = -10
122 content.add_child(status)
123
124
125def _selftest() -> bool:
126 """Headless: open the menus by clicking their titles and choose items by row.
127
128 A title is found where the bar draws it, so the click lands on the strip the
129 user aims at rather than at a computed guess; an item is clicked at the row the
130 open popup occupies, from its own rect and ``item_height``. The status label is
131 the demo's one output and every assertion reads it back.
132 """
133 from simvx.core.ui.testing import UITestHarness
134
135 harness = UITestHarness(MenuDemo(name="MenuDemo"), screen_size=(800, 600))
136 ok = True
137
138 def check(label: str, passed: bool, detail: str) -> None:
139 nonlocal ok
140 ok = ok and passed
141 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
142
143 harness.tick()
144 menubar = harness.find_by_name("menubar")
145 status = harness.find_by_text("Ready")
146 _, bar_y, _, bar_h = menubar.get_global_rect()
147
148 def title_point(name: str) -> tuple[float, float]:
149 """The middle of the bar strip where ``name`` is drawn."""
150 drawn = next(c for c in harness.draw_log.calls_of_type("text") if c.text == name)
151 return (drawn.x + 2, bar_y + bar_h / 2)
152
153 def open_menu(name: str):
154 harness.click(title_point(name))
155 harness.tick()
156 return next(popup for title, popup in menubar.menus if title == name)
157
158 def click_item(popup, index: int) -> None:
159 gx, gy, gw, _ = popup.get_global_rect()
160 harness.click((gx + gw / 2, gy + popup.item_height * (index + 0.5)))
161 harness.tick()
162
163 check(
164 "the bar reserves its own fixed height and lists all three menus",
165 bar_h == MenuBar.BAR_HEIGHT and [title for title, _ in menubar.menus] == ["File", "Edit", "View"],
166 f"{bar_h:.0f}px bar: " + ", ".join(title for title, _ in menubar.menus),
167 )
168
169 file_menu = open_menu("File")
170 check(
171 "clicking a title drops its menu open below the bar",
172 file_menu.visible and file_menu.get_global_rect()[1] >= bar_y + bar_h,
173 f"File popup at y={file_menu.get_global_rect()[1]:.0f}, below a bar ending at {bar_y + bar_h:.0f}",
174 )
175
176 click_item(file_menu, 2) # Save
177 check(
178 "choosing an item runs its callback and closes the menu",
179 status.text == "Action: Save" and not file_menu.visible,
180 f"{status.text!r}, popup visible = {file_menu.visible}",
181 )
182
183 # A separator is a row that draws but does nothing: clicking it must leave the
184 # status line where it was, while still dismissing the menu like any click.
185 file_menu = open_menu("File")
186 separator = next(i for i, item in enumerate(file_menu.items) if item.separator)
187 click_item(file_menu, separator)
188 check(
189 "clicking a separator changes nothing but still closes the menu",
190 status.text == "Action: Save" and not file_menu.visible,
191 f"row {separator} of File left the status at {status.text!r}",
192 )
193
194 # Hovering a different title while one menu is open switches to it, which is
195 # the behaviour a desktop menu strip is expected to have.
196 file_menu = open_menu("File")
197 harness.mouse_move(title_point("Edit"))
198 harness.tick()
199 edit_menu = next(popup for title, popup in menubar.menus if title == "Edit")
200 check(
201 "hovering another title while a menu is open switches the open menu",
202 edit_menu.visible and not file_menu.visible,
203 f"File {file_menu.visible}, Edit {edit_menu.visible}",
204 )
205
206 click_item(edit_menu, 4) # Copy, past Edit's separator
207 check(
208 "and the second menu's items are wired up just as the first's are",
209 status.text == "Action: Copy",
210 status.text,
211 )
212
213 view_menu = open_menu("View")
214 check(
215 "every item carries the shortcut hint it was given",
216 [item.shortcut for item in view_menu.items] == ["Ctrl++", "Ctrl+-", "Ctrl+0"],
217 ", ".join(f"{item.text} {item.shortcut}" for item in view_menu.items),
218 )
219 harness.click((400.0, 400.0)) # anywhere off the chain dismisses it
220 harness.tick()
221 check(
222 "clicking away from the bar dismisses the open menu",
223 not view_menu.visible,
224 f"View popup visible = {view_menu.visible}",
225 )
226
227 harness.teardown()
228 print("SELFTEST:", "PASS" if ok else "FAIL")
229 return ok
230
231
232if __name__ == "__main__":
233 import sys
234
235 if "--test" in sys.argv:
236 sys.exit(0 if _selftest() else 1)
237 app = App(title="SimVX Menu Demo", width=800, height=600)
238 app.run(MenuDemo())