Native application menus
Design for system-owned menu bars (macOS, Win32, Linux global menu) with a LavaUI-shaped declarative API. Menus are not Yoga children and are not in the draw list when the OS owns them. On Linux without a global-menu host (Vala Panel appmenu, xfce4-panel applet, etc.), the same description is drawn with the existing Vulkan path so the app always has a working menu bar.
Goals
- Declarative Swift API in the spirit of LavaUI primitives (
Menu,MenuItem, …), not an imperative “call CreateMenu” surface. - Real system menus where the platform provides them:
- macOS — AppKit
NSMenu/ main menu bar - Windows — Win32
HMENU+SetMenu - Linux — DBusMenu + AppMenu Registrar for Vala Panel / compatible panels
- macOS — AppKit
- Linux fallback: if no global-menu host is available, render an in-window menu bar with LavaUI + Vulkan (same IR, different host).
- No GTK/Qt widgets in the client area. Linux global menu uses D-Bus only
(
libdbusmenu-glib+ GLib main context), notGtkMenuBarinside the GLFW window. - Activations feed the same main-loop / invalidation path as pointer and key input.
Non-goals (initial)
- Custom-drawn menu items with arbitrary LavaUI content inside an OS menu
(macOS
NSMenuItem.viewis a later option). - Full Wayland global-menu parity (Registrar is X11-window-id centric; see below).
- Replacing in-canvas dropdowns (
Overlaycomboboxes, color pickers). Those stay app-drawn; this doc is about application command menus.
Precedent in the repo
| Piece | Lesson |
|---|---|
FileDialog |
Clean Swift API, platform backend, no Yoga, no draw list |
Overlay |
In-app popup geometry, paint-after-tree, hit-test first |
LavaApp.menuH |
Already reserved client offset for a bar (currently 0) |
window_platform.cpp |
Native handles via glfwGetX11Window / Win32 / future Cocoa |
Menus are the retained, evented cousin of FileDialog: long-lived,
reconciled when app state changes, callback-driven rather than modal-blocking.
Public API sketch
Menus are declared at the app / window boundary, not nested arbitrarily
inside arbitrary view bodies. The bar is process-global (macOS), window chrome
(Windows), or panel-global (Linux DBusMenu) — layout ownership belongs next to
LavaApp, not next to a random VStack.
LavaApp.run(
editor: editor,
menu: {
MenuBar {
// First top-level menu = application menu (Chrome: "Google Chrome").
// Global panels put it beside the window icon; omit it and that
// slot looks empty/invisible.
Menu("MyApp") {
MenuItem("New", shortcut: KeyShortcut(KeyCode.n, .primary)) { newDoc() }
MenuSeparator()
MenuItem("Quit", shortcut: KeyShortcut(KeyCode.q, .primary)) {
editor.requestClose()
}
}
Menu("File") {
MenuItem("Open…", shortcut: KeyShortcut(KeyCode.o, .primary)) { open() }
MenuItem("Save", shortcut: KeyShortcut(KeyCode.s, .primary), isEnabled: canSave) {
save()
}
}
Menu("Edit") {
MenuItem("Copy", shortcut: KeyShortcut(KeyCode.c, .primary)) { copy() }
}
}
},
makeRoot: { RootView() }
)
Context menus (later) attach to views:
row.contextMenu {
MenuItem("Delete") { delete(row.id) }
}
Those still share the same IR and hosts; only placement differs (popup at pointer vs menubar).
Connecting menu actions to view state
The menubar is built outside the view tree (LavaApp.run(menu:)), so it
cannot close over a view’s @State — those wrappers are value copies that
die with the builder call. Share an @Observable class instead:
@Observable
final class MySession {
var document = ""
var showSettings = false
func openFile() { … }
}
let session = MySession()
LavaApp.run(editor: editor, menu: {
MenuBar {
Menu("File") {
MenuItem("Open…") { session.openFile() }
MenuItem("Settings…") { session.showSettings = true }
}
}
}) {
MyRoot(session: session) // body reads session.* → Observation invalidates
}
TraceLoom uses this as TraceLoomSession.
Why the model write reaches the screen
Observation only notices a write if some body read that property while
withObservationTracking was recording. Most controls do read: Expand
evaluates isExpanded.wrappedValue in its own body, so a menu toggling
session.showLog invalidates correctly.
overlay(isPresented:) deliberately does not — it holds a live closure and
reads it at emit time, so presentation costs a redraw instead of a body pass.
That means an @Observable write which only an overlay reads registers no
dependency and invalidates nothing.
Handlers therefore request a redraw themselves: LavaApp marks one after a
click action, and MenuHost after a menu activation. .redraw is the floor,
not .body — anything that genuinely changed the tree has already raised
.body through observation. Menu activations especially need this, because
panel clicks arrive from poll() outside any input event: on an idle window
nothing else would ask for a frame, and the action would sit unpainted until
the user happened to move the mouse.
Types
MenuBar { menus… } // top-level container
Menu(title) { items… } // one top-level title ("File")
MenuItem(title, …) // leaf command
MenuSeparator()
// later: MenuToggle, nested Menu as submenu, MenuRadioGroup
These can implement View / builder DSL for ergonomics (Body == Never) but
must not enter the Yoga tree as normal primitives. They produce a
MenuModel IR consumed by a MenuHost.
Intermediate representation
Platform code never sees View types. It sees a pure value tree plus a side table of actions:
struct MenuModel: Equatable {
var menus: [MenuNode]
}
struct MenuNode: Equatable, Identifiable {
var id: MenuID
var title: String
var items: [MenuEntry]
}
enum MenuEntry: Equatable {
case item(MenuItemModel)
case separator
case submenu(MenuNode)
}
struct MenuItemModel: Equatable {
var id: MenuID
var title: String
var isEnabled: Bool
var isChecked: Bool?
var shortcut: KeyShortcut?
}
// Actions live outside Equatable structure:
// [MenuID: () -> Void]
Reconcile: each invalidation rebuilds model + action map from the
declarative closure. Diff against the last applied model. If structure or
labels/flags changed, call host.apply(model). Activations look up MenuID
in the action map on the main loop.
Full menubar rebuilds are cheap; micro-diffing OS items is an optional later optimization.
Platform hosts
MenuModel + action map
│
┌──────┴──────┐
│ MenuHost │ (Swift: pick backend, own last model)
└──────┬──────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
CocoaHost Win32Host LinuxHost
NSMenu HMENU ┌─────────────┐
│ probe panel │
└──────┬──────┘
yes │ no
▼ │ ▼
DBusMenuHost │ VulkanMenuHost
+ Registrar │ (in-window bar)
macOS — AppKit
- Build / replace
NSApp.mainMenufromMenuModel. - Key equivalents from
KeyShortcut(Command/Option mapping). - Item target is a small ObjC/Swift trampoline that enqueues
MenuIDinto the LavaUI main queue and wakes the loop. - GLFW: after window creation, use the Cocoa native handle; replace GLFW’s default menu rather than fighting it.
No client-area inset (menuH = 0).
Windows — Win32
CreateMenu/CreatePopupMenu/InsertMenuItem/SetMenu(hwnd)/DrawMenuBar.hwnd = glfwGetWin32Window(...).- Subclass WndProc (
SetWindowLongPtr(GWLP_WNDPROC)): handleWM_COMMAND, forward everything else withCallWindowProc. - Client area shrinks under the menu bar → measure bar height and set
menuHso layout/hit-test/agent coords stay correct (existingLavaAppoffset path).
Linux — dual path
A. Global menu (preferred when available)
Vala Panel Application Menu (and xfce4-panel / mate-panel ports) consume the Unity-style stack:
- Export a menu tree over DBusMenu (
com.canonical.dbusmenu) vialibdbusmenu-glib(DbusmenuServer+DbusmenuMenuitem). - Register the window with
com.canonical.AppMenu.Registrar:RegisterWindow(xid, menu_object_path)wherexidisglfwGetX11Window. - The panel applet displays the menu; the app does not draw a bar.
Requirements at runtime:
- Session bus reachable
- Registrar name/owner present (appmenu-registrar / panel stack)
- X11 window (primary path; see Wayland note)
GLib: iterate g_main_context_iteration(NULL, FALSE) once per frame after
glfwPollEvents / glfwWaitEvents so D-Bus activations arrive without a
second thread. Soft-link dependencies; if libraries are missing at build or
load time, treat as “no global menu”.
B. Vulkan in-window bar (fallback)
Used when any of:
- Registrar not on the bus
- DBusMenu / GLib not linked or failed to init
- Wayland (or other backend) where window registration is unsupported
- Explicit env override (e.g.
LAVA_MENU=vulkanfor debugging)
Then the same MenuModel is shown as an in-window menu bar drawn by
LavaUI:
- A reserved strip at the top of the window (
menuH= theme bar height) - Top-level titles as hoverable cells
- Open menus as
Overlay(or the same overlay machinery): paint above content, hit-test first, dismiss on outside click / Escape - Items invoke the same action map as the D-Bus path
Visual style follows Theme (not the DE’s GTK theme). That is acceptable:
the fallback exists so the product works without Vala Panel, not so it clones
a GNOME/KDE menubar pixel-for-pixel.
Probe order (once at startup, re-check on bus name owner change if cheap):
if env LAVA_MENU == vulkan → VulkanMenuHost
else if dbusmenu + registrar available → DBusMenuHost
else → VulkanMenuHost
Switching live from DBusMenu to Vulkan when the panel dies mid-session is nice-to-have; v1 can pick once at open.
Wayland
AppMenu Registrar historically keys menus by X11 window id. On this compositor Lava uses three paths:
- Lava clients — the registrar's key is a
uon the wire; clients register under their surface id (the same idSubscribeActiveWindowreports). - Foreign Wayland clients (Qt6 / Chrome / KDE) — the compositor advertises
org_kde_kwin_appmenu_manager. The client exports dbusmenu as usual and callsset_address(service, path)on the surface. Focus carries those strings to the panel, which opensDbusmenuClienton them directly. This is the same protocol Dolphin uses on Plasma. - X11 / Xwayland clients (Qt5 xcb, GTK with appmenu-gtk-module) — the app
still calls
RegisterWindow(xid, path)with its X11 window id. Focus reports that XID asActiveWindow.surfaceId, so the panel's registrar lookup matches. - Qt5 on Wayland —
libQt5WaylandClienthas noorg_kde_kwin_appmenu(that is Qt6) andRegisterWindowusesQWindow.winId(), which is often just1. Focus also carries the client's Unixpid; the panel matches the registrar entry by that pid when the window id misses. Electron (Teams, VSCode) does the same with 1, 2, 3, so the registrar keeps every registration rather than a map keyed only by that id — otherwise focusing Teams after VSCode finds no pid and draws no menu.
ActiveWindow carries the registrar key as its own field, registrarId,
next to the compositor surfaceId. They are the same number for everything
except X11 clients; keeping them apart is what stops a panel correlating
focus against a window list from silently matching an XID against a surface
id, in a u32 namespace where the two can collide.
Because collisions are the normal case (1, 2, 3), the registrar keeps a
list rather than a map — and a list needs a way to shrink. Applications
essentially never call UnregisterWindow; they exit. Each distinct registrant
is therefore watched with g_bus_watch_name, and its registrations go when its
bus name does. Without that the list only grows and, since Linux recycles pids,
the pid fallback eventually matches a dead client and the bar goes blank for
one window with nothing in any log.
LAVA_MENU_DEBUG=1 logs the registrar calls, the lookup (kde-appmenu /
registrar-id / registrar-pid / none), pid resolutions, names leaving the
bus, and imported top-level titles. The panel and the compositor read the same
switch.
Chromium's empty stubs
Chromium (VSCode, Teams, and Chrome's own bar) exports File/Edit with
children-display=submenu and no children. AboutToShow fills them and
returns needUpdate=false, so libdbusmenu never refetches. Two paths fill
them, split by who is waiting:
- The dropdown being opened — synchronously, in
aboutToShow: AboutToShow, GetLayout the object directly, splice, and rebuild before the first frame that shows it. Nested empty submenus under that title get the same treatment so "Open Recent" is not a dead header. Bounded to three levels, since an application that answers every AboutToShow with another stub would otherwise walk forever. - The rest of the bar — asynchronously, from
poll. The titles are already inGetLayout(0), so nothing on screen is waiting for the children; a submenu that answers and is still empty is remembered and not asked again.
The split is the point. Chromium fills one stub at a time, so covering the bar
means a round trip per title, and doing that synchronously on every
LayoutUpdated — which Electron sends for ordinary state changes — put up to a
dozen blocking D-Bus calls on the compositor's frame loop. The one subtree that
genuinely cannot wait is the open dropdown, which GetLayout(0) has just
emptied again; that one is re-spliced in the rebuild.
Under the lava compositor that is now solved, and the answer was smaller
than the archaeology suggested: the registrar's key is a u on the wire and
nothing on the panel side looks it up in an X server. So a Lava client registers
under its surface id — the number the compositor already uses to name its
window, and the same number it reports as focused.
app (LavaUI client) LavaTaskbar
──────────────────── ───────────
DbusmenuServer at owns the registrar name
/com/canonical/menu/<surfaceId> ──┐ (canonical, else org.lavaui.…)
│
RegisterWindow(surfaceId, path)
│
▼
DbusmenuClient reads the layout
▲
compositor ──SubscribeActiveWindow──┘ which window's menu to show
Three pieces make it work, and each is where it is for a reason:
MenuImportHost(canvas/src/menu/menu_import.*) is the panel's half: it serves the registrar and imports layouts. Separate class fromAppMenuHost— publishing a menu and reading other applications' menus are different jobs and no process should accidentally do both.SubscribeActiveWindowon the control plane says whose menu. Focus belongs to the compositor; a panel guessing would show the wrong app's File menu. It carries the client's pid so Qt5 Wayland menus can be found at all.SetPanelThicknesslets the 32pt strip grow while a dropdown is open and shrink afterwards, reserving the strip either way, so windows do not move when a menu opens.
Bus name: the panel takes com.canonical.AppMenu.Registrar when it is free —
then Qt and GTK applications export to it with nothing changed on their side —
and org.lavaui.AppMenu.Registrar when a host desktop (KDE, say) already owns
the canonical one. Applications prefer the lava name where both exist, since
both means "a lava panel inside somebody else's session".
Applications also re-register when a registrar appears on the bus
(g_bus_watch_name), so restarting the panel does not empty it. What still
does not recover is an app that started when there was no registrar at all: it
picked the in-window bar at startup and keeps it, because the backend is
chosen once. Start the panel before the apps — which a session does anyway.
Vulkan menu host (Linux fallback) — layout integration
┌──────────────────────────────────────────┐
│ File Edit View (menuH) │ ← MenuBar strip (Yoga or fixed)
├──────────────────────────────────────────┤
│ │
│ app root (bodyH) │
│ │
│ ┌─────────────┐ │
│ │ Open… │ ← Overlay when open │
│ │ Save │
│ │ ───────── │
│ │ Quit │
│ └─────────────┘
└──────────────────────────────────────────┘
LavaAppalready subtractsmenuHfrom content height and from pointer coordinates. Vulkan host setsmenuHto the strip height; DBusMenu / Cocoa hosts leave it0.- Open dropdowns use existing overlay rules (see
Overlay.swift): emit after the tree, escape scissor, take input first, dismiss outside. - Keyboard: Left/Right move among top-level menus when open; Up/Down among
items; Enter activates; Escape closes. Shortcuts from
KeyShortcutare matched inonRawKey(also useful on platforms where the OS does not deliver every accelerator into the app).
Implementation options for the strip itself:
- Framework-owned chrome —
MenuHostinstalls a small retained tree above the user’s root (not part ofmakeRoot()). Cleaner separation. - Injected into the host root —
VStack { MenuBarView(model); userRoot }. Simpler plumbing, easier for users to accidentally style-conflict.
Prefer (1): the bar is platform chrome that happens to be drawn by us when the OS will not.
Event and invalidation flow
User picks item
→ OS / Overlay
→ MenuID
→ main queue (same as agent wake / MainQueue)
→ action map[id]?()
→ @State / observables dirty
→ next frame: body → maybe new MenuModel → host.apply if changed
Do not run heavy work inside Win32 WndProc or D-Bus signal handlers; enqueue only.
Shortcuts
KeyShortcut is shared:
- Seed native key equivalents (macOS / Win32 accelerators / DBusMenu shortcut properties when the panel supports them).
- Always also match in-app on key events so Linux Vulkan fallback and partial DBusMenu implementations still honor Ctrl/Cmd+S etc.
Primary modifier: Command on macOS, Control on Linux/Windows (or a single
.primary that resolves per OS).
File / module layout
Sources/LavaMenu/ // pure IR + DSL (no C++ / GPU)
Menu.swift
Sources/LavaUI/
MenuHost.swift // vulkan | dbusMenu selection, export, poll
MenuBarView.swift // Vulkan strip + overlays
LavaApp.swift // run(..., menu: { MenuBar { … } })
canvas/src/menu/
app_menu.hpp/cpp // DBusMenu server + AppMenu Registrar (optional)
Dependencies
| Platform | Build | Runtime |
|---|---|---|
| macOS | AppKit (system) | — |
| Windows | user32 | — |
| Linux DBusMenu | optional libdbusmenu-glib, GLib |
Registrar + panel applet |
| Linux Vulkan fallback | none beyond existing LavaUI | — |
Missing optional Linux libs → compile with DBus path disabled → always Vulkan fallback. No hard GTK dependency.
Testing
- IR / diff: pure Swift tests — build model from DSL, equate, enable flags.
- Vulkan host: existing headless/agent layout tools can see the strip and open overlay (labels, hit targets) like any other view.
- DBusMenu: manual on a machine with vala-panel-appmenu; optional CI job later with a mock registrar.
- Win32 / Cocoa: when those platforms are first-class build targets.
Phased delivery
MenuModel+ DSL + action table — no platform, unit-tested. Done (Sources/LavaMenu/,Tests/LavaMenuTests/): pure target likeLavaTextso headless tests need no C++/Vulkan.LavaUIdepends on and@_exported importsLavaMenu. Types:MenuBar/Menu/MenuItem/MenuSeparator,MenuModelIR,MenuActionTable,MenuController,KeyShortcut.- VulkanMenuHost — Linux always works; proves overlay menus, shortcuts,
integration with
LavaApp. Done (MenuHost,MenuBarView/MenuChromeRoot,LavaApp.run(menu:)): in-window strip +Overlaydropdowns; shortcuts viaMenuHost.activate(matchingKey:). The strip is inside the view tree (not a separatemenuHclient offset). - Linux DBusMenuHost — probe Registrar; export tree; fall back to (2).
Done (
canvas/src/menu/app_menu.*,MenuHostbackend selection):- Optional build dep
dbusmenu-glib-0.4+gio-2.0(CANVAS_HAVE_DBUSMENU) com.canonical.AppMenu.Registrar+ X11 window idLAVA_MENU=vulkan|dbus|autooverride- GLib pump before/after
pumpEvents; idle wait capped (~20ms) while DBus is active so panel GetLayout/AboutToShow always get replies (unboundedglfwWaitEventsfreezes XFCE/the panel). Activations alsoglfwPostEmptyEvent.
- Optional build dep
- Win32 when Windows is a real target.
- Cocoa when macOS is a real target.
- Context menus sharing IR (Overlay and/or
TrackPopupMenu/popUpContextMenu).
Default on Linux: try global menu, else Vulkan strip — a menu bar always exists.
Decisions
| Topic | Decision |
|---|---|
| Declaration site | App-level LavaApp.run(..., menu:) for the bar |
| Linux without Vala Panel | Draw menubar + menus with Vulkan / LavaUI |
| Linux with Vala Panel (or compatible) | DBusMenu + Registrar; no in-window bar |
| GTK widgets in-process | No |
| Wayland global menu | Not required for v1; use Vulkan fallback |
| Overlay vs native | Overlay only for fallback and in-canvas UI; command menus prefer OS |
menuH |
Non-zero for Win32 native bar only; Vulkan fallback embeds the strip in the view tree (menuH stays 0) |
| Nested submenus | A second overlay level: the row keeps its children and presents its own MenuDropdownPanel beside itself, opening on hover, flipping to the left when the right side is out of room. Overlays nest — OverlayScan steps across an attachment root, and emitTree walks the pending list by index so one presented from inside another is laid out and painted after it |
Open questions
- Exact probe for “Registrar available” (name owner vs successful
RegisterWindow). - Icon support on items (defer; labels first).
- Whether
LAVA_MENU=dbus|vulkan|autois worth documenting for users or only for developers.
Related
Sources/LavaUI/FileDialog.swift— native dialog patternSources/LavaUI/Overlay.swift— in-app popup rules used by Vulkan fallbackSources/LavaUI/LavaApp.swift—menuHcontent offsetcanvas/src/window/window_platform.cpp— native window handles- Vala Panel Application Menu: https://github.com/rilian-la-te/vala-panel-appmenu
- DBusMenu / AppMenu Registrar (Unity protocol used by the panel)