Menus (§ menus)
Day renders menus with each toolkit’s native menu machinery: NSMenu, GtkPopoverMenu /
GtkPopoverMenuBar, QMenu / QMenuBar, UIMenu (via UIContextMenuInteraction), Android
PopupMenu / the app-bar overflow, and XAML MenuFlyout / MenuBar. There are two surfaces:
- Context menus: per-Piece, shown on secondary-click (desktop) or long-press (touch), attached
with the [
Decorate::context_menu] modifier. - The app menu: the global menu bar on desktop, installed once with [
app_menu].
Both are described with the same small, toolkit-neutral tree of [MenuEntry] values. Day owns the
model; the backend owns the rendering, so a menu looks and behaves like any other native menu on
the host platform without the app making any per-platform assumptions.
Building a menu
use day_pieces::*;
label("Right-click me")
.context_menu(vec![
menu_item("Rename").action(|| rename()),
menu_item("Duplicate").key("d").action(|| duplicate()), // ⌘D / Ctrl+D
menu_separator(),
sub_menu("Move to", vec![ // nested submenu
menu_item("Inbox").action(|| move_to(Inbox)),
menu_item("Archive").action(|| move_to(Archive)),
]),
menu_separator(),
menu_role(MenuRole::Copy), // standard Edit ▸ Copy
menu_item("Delete").shortcut(Shortcut::plain("Delete")).action(|| delete()),
])
The pieces, all in the day_pieces prelude:
| Builder | Produces |
|---|---|
menu_item(label) | A clickable command. Chain .action(f), .key("s"), .shortcut(_), .enabled(bool), .id("…"), .checked(bool). |
sub_menu(label, vec![…]) | A nested submenu (arbitrarily deep on desktop; see platform notes). |
menu_separator() | A divider between groups. |
menu_role(role) | A standard system command; see Standard roles. |
Naming an item: .id
.key("s") sets the item’s ⌘/Ctrl SHORTCUT. To NAME an item, use .id:
menu_item(tr("view-grid")).id("view-grid").action(|| grid.set(!grid.get()))
An id is what something outside the menu addresses the item by — a dayscript
menu: { id: "view-grid" } step, or a tap on the row of the
composed presentation. A label cannot do that job: it is
localized, so it changes with the run’s language, and a checked item rewrites its
own label whenever the state moves. Give an id to any item a script drives; leave it off the rest.
Ids are the app’s own vocabulary and need only be unique within the menu they are installed in. They never reach the user.
Checked items
.checked(bool) draws the platform’s check mark beside the item — for a setting the menu
toggles, or one of several mutually exclusive choices:
app_menu_reactive(move || vec![sub_menu(tr("menu-view"), vec![
menu_item(tr("view-grid")).id("view-grid").checked(grid.get()).action(move || grid.set(!grid.get())),
menu_separator(),
menu_item(tr("view-day")).id("view-day").key("1").checked(mode.get() == 0).action(move || mode.set(0)),
menu_item(tr("view-both")).id("view-both").key("2").checked(mode.get() == 1).action(move || mode.set(1)),
])]);
An item that never calls .checked is a plain command and reserves no room for a mark. One that
calls it is checkable whichever way it sits, so a run of choices lines up on its titles instead of
stepping in and out by a tick’s width as the selection moves.
The app owns the state. Choosing a checked item runs its .action and nothing else — no backend
flips the mark by itself, and the ones whose native control would (GTK’s stateful action, Qt’s
checkable QAction, WinUI’s ToggleMenuFlyoutItem) have that flip suppressed. The mark moves when
the menu is re-installed carrying the new value, which is what app_menu_reactive
does on its own as soon as the builder reads the signal. So what the menu shows can never disagree
with what the app thinks, and an action that declines to change anything leaves the mark where it was.
| Backend | Drawn as |
|---|---|
| appkit | NSMenuItem.state — the system ✓ |
| uikit | UIAction.state — the system ✓ |
| gtk | a stateful boolean GAction; GTK renders the check from the action’s state |
| qt | a checkable QAction |
| xaml | ToggleMenuFlyoutItem |
| android | MenuItem.setCheckable — Material’s check |
| dom | a ✓ in a fixed check column of the composed row |
Icons
An item can carry the platform’s glyph beside its title:
menu_item(tr("rectangle")).icon(Symbol::Rectangle).action(place_rect)
menu_item(tr("brand")).image("brand-mark").action(insert_brand)
.icon(Symbol) takes the same standard vocabulary toolbars take (each backend draws its own
glyph: an SF Symbol, a freedesktop icon name, a Segoe Fluent code point), and .image(name) a
bundled picture from resource/images for something only this app has.
An icon is always an addition to a menu that reads correctly without one, because not every platform’s menus carry pictures:
| icons in menus | |
|---|---|
| AppKit, UIKit | yes — NSMenuItem.image / UIAction image, from the shared SF Symbol table |
| GTK | yes — the GMenuModel “icon” attribute, drawn by GtkPopoverMenu |
| Qt | yes — QAction::setIcon, resolved like a toolbar icon (theme name, then Day’s outline, then the QStyle standard set) |
| XAML | symbols only — a FontIcon from the Segoe Fluent table; a bundled image would need the toolbar’s three-field icon channel |
| Android | ignored: Material’s overflow menu is text-only, and an icon belongs on an app-bar action |
| ArkUI, web-dom | no menus of this kind to put an icon in |
Keyboard shortcuts
A [Shortcut] is a key plus modifiers. primary is the platform’s command modifier (⌘ on Apple,
Ctrl elsewhere), so one spec is correct everywhere:
menu_item("Save").key("s") // ⌘S / Ctrl+S (primary + key, the common case)
menu_item("Save As…").shortcut(Shortcut::new("s").shift()) // ⇧⌘S / Ctrl+Shift+S
menu_item("Delete").shortcut(Shortcut::plain("Delete")) // no primary modifier
Shortcut::new(key) sets primary; Shortcut::plain(key) sets no modifiers; .shift(), .alt(),
.control() add the others (.control() is the physical Control key, distinct from primary on
macOS). Named keys ("Return", "Delete", "Space", "F5", arrows) are recognized alongside
single characters. The shortcut is drawn in the native accelerator position and is live whenever the
menu (or its window) is in the responder/focus chain. On GTK, primary spells the Command key on
macOS (<Meta>, since GTK4’s <Primary> is a plain <Control> alias, unlike GTK3’s) and <Primary>
elsewhere, so the one spec stays right on macos-gtk too.
The standard roles need no spelling at all: menu_role(Undo/Redo/Cut/Copy/Paste/SelectAll) items
take the platform-neutral defaults (primary+Z/X/C/V/A, shift for redo) unless the app sets its
own. AppKit’s native items always carried these; the lowering gives GTK, Qt, and the other
action-routed backends the same accelerators.
Shortcuts render on the platforms that draw a menu bar: the three desktops, which is
Cap::AppMenu. The UIKit and Android menu builders take the item and drop
its accelerator, so an app that wants a key to work there routes it through .on_key on the
focused piece instead; probe Cap::AppMenu to decide which, or the key fires twice where both
apply. Day-Sketch’s canvas_key is the worked example.
Standard roles
menu_role(MenuRole::…) emits the platform’s built-in command rather than a custom action, so the
familiar items keep their native label, default shortcut, automatic enable/disable, and their
focus targeting: Edit ▸ Copy copies from whatever control has focus, with no wiring:
app_menu(vec![
sub_menu("Edit", vec![
menu_role(MenuRole::Undo), menu_role(MenuRole::Redo),
menu_separator(),
menu_role(MenuRole::Cut), menu_role(MenuRole::Copy),
menu_role(MenuRole::Paste), menu_role(MenuRole::SelectAll),
]),
])
| Role | AppKit | GTK | Qt | UIKit | Android | XAML |
|---|---|---|---|---|---|---|
| Cut/Copy/Paste | cut:/copy:/paste: nav hosts — a focused text view answers first, then the app’s edit bridge⁴ | clipboard.* actions | dispatched to the focused QLineEdit/QTextEdit | responder chain, then the edit bridge⁴ | edit bridge⁴ (text keeps its own selection toolbar¹) | accelerator² |
| SelectAll | selectAll: nav host — a focused text view answers first, then the edit bridge⁴ | selection.select-all | focused editor | responder chain, then the edit bridge⁴ | edit bridge⁴ | accelerator² |
| Undo/Redo | undo:/redo: (responder chain — the acting NSUndoManager, a focused text field’s before the document’s) | stock actions (text.undo) | focused editor | installed undo bridge³ | installed undo bridge³ | — |
| Quit / Close / Minimize / Fullscreen | standard App-menu items | window actions | window / qApp | — | — | Quit closes the window |
| About / Preferences | moved into the App menu | — | menuRole → app menu (mac) | — | — | — |
You can override a role’s label (menu_role(r) starts empty and the backend fills the standard label;
supply your own via MenuEntry::role on a menu_item if you want a custom title). Roles with no native
equivalent on a platform render as an inert labeled item; no behavior is imposed.
³ On a toolkit with no native undo responder, MenuRole::Undo/Redo items lower onto a
standing dispatcher (day_core::undo_action_id) that invokes the undo history installed via
day::install_undo (docs/model.md), the same stack the platform’s route reaches on
macOS/iOS, so one menu_role pair behaves consistently everywhere the menu renders.
⁴ The edit bridge (Cap::EditBridge). An app that can cut/copy/paste its own objects installs
handlers once:
day::install_edit_commands(
|| !selection().get().is_empty(), // can_copy — a tracked read
copy_selection, // -> Option<String>: the serialized payload
cut_selection, // -> Option<String>, and removes the objects
|text| paste_payload(text), // whatever text the clipboard held
select_all, // Edit ▸ Select All (⌘A/Ctrl+A)
);
copy/cut place their payload on the system clipboard, and Paste asks for it back — but the
answer is not guaranteed. Android grants clipboard READS only to the app holding input focus
(clipboard.md), and its own copy overlay can take that focus for a moment, so a
Paste seconds after a Copy can be handed nothing. Day keeps what the app last placed there and
pastes that when the platform comes back empty, which is what makes an app’s own Cut/Copy ▸ Paste
work on every target; a clip from another app is still what the platform answers with, and it
always wins.
Two companions round out the platform’s input idioms. day::modifiers() answers the keyboard
modifiers held right now (shift, primary, which is ⌘ on Apple platforms and Ctrl elsewhere,
and alt), for interactions whose meaning they change: shift-click adding to a selection instead
of replacing it. It is a live query, not an event field, so it is right wherever it is asked. A
backend that cannot answer reports all-false, which is correct for touch platforms and something
to check before relying on it elsewhere (the modifiers row of the duty
matrix). A dayscript tap: or drag: step’s declared modifiers: take
precedence while it dispatches.
.on_key(f) handles the non-text keys, under the web KeyboardEvent.key names and with the
held modifiers on the event (ev.shift() scales a nudge from 1px to 10). The four arrows
arrive everywhere, and so do the ten digits, "0" through "9" from the main row or the
numeric keypad (ev.digit() reads the value). A digit arrives only while neither primary nor
alt is held (nor a Mac’s Control), since ⌘1 and Ctrl+1 are accelerators. Most backends name
the digit a keypress types, so a layout that needs Shift for digits reports "1" with
ev.shift() set; XAML and ArkUI read the physical key instead. Delete and Backspace arrive
only where Cap::AppMenu is unsupported, where there is no menu bar and so
no accelerator that could own them.
That split keeps one key to one owner. A focused piece
that claims a key stops any accelerator from ever seeing it: the platform offers the key to
the focus chain, and a piece with an .on_key handler claims every key the route carries, not
just the ones its handler acts on. So a canvas that received Delete on a menu-bar platform
would swallow the very Delete its own Edit menu was about to act on. Route a key through the
menu or through .on_key, never both, and probe Cap::AppMenu to decide which. Day-Sketch’s
canvas_key is the worked example, and the delete keys are routed to match it.
The two delete keys keep the names the platform gives the physical key, so a handler meaning
“remove this” takes both: a Mac’s ⌫ reports Backspace, a full-size keyboard’s Del reports
Delete. Keys follow focus. The handler hangs off a piece and fires only while that piece is the focused
one, so it is scoped the way every other input is:
canvas(draw)
.on_key(nudge_selection_by) // only while the canvas has the keyboard
.focused(canvas_focused) // …which it takes at mount and on every press
That scoping is why there is no window-level key handler to pair it with. A global route cannot tell a nudge the app wants from the keys a focused widget needs: it has to run ahead of the platform’s dispatch, which means guessing whether the first responder would have wanted the key, and every guess is wrong for something. Day-Sketch’s arrow-nudge used to be installed that way, and it took the arrow keys away from every list and sidebar in every app that had one. Hanging the handler on the canvas removes the question: AppKit’s responder chain and the DOM’s focus already answer it.
The cost is that a piece must be able to hold focus for its keys to arrive
(docs/focus.md). A canvas can on every toolkit (each backend
makes its drawing surface a tab stop and reports focus both ways), which makes it the piece a
drawing app hangs its keys on. A piece that cannot take focus on a given backend never
hears a key there, and a canvas nobody gave a handler to keeps none of them: an unclaimed
arrow keeps walking, so an enclosing scroll view still scrolls and the platform’s focus
navigation still moves between controls.
The payload format is the app’s own; Day-Sketch uses a standalone SVG document, so shapes
paste into anything that reads SVG and SVG from other editors pastes back. day places the
payload on the system clipboard (day-part-clipboard) and reads it back for paste; on web-dom
the browser’s copy/cut/paste events themselves are the route, with the event’s
clipboardData as transport. Precedence is the platform’s own: on macOS/iOS the responder
chain lets a focused text field keep its clipboard behavior, and the app’s handlers see only
what falls through, with the same items, shortcuts, and menu validation
(can_copy greys Cut/Copy; Paste additionally requires clipboard text). On toolkits whose
role items come back as plain menu actions (Android’s app bar, web context menus), the
menu_role(Cut/Copy/Paste) items dispatch to the same handlers via standing ids.
¹ Android editable views raise the system selection toolbar for Cut/Copy/Paste; a role in a Day menu is
shown for parity and dispatches nothing.
² XAML carries the standard accelerator; the focused TextBox handles the keystroke itself.
The app menu
app_menu(vec![
sub_menu("File", vec![
menu_item("New").key("n").action(|| …),
menu_item("Open…").key("o").action(|| …),
menu_separator(),
menu_item("Save").key("s").action(|| …),
menu_role(MenuRole::CloseWindow),
]),
sub_menu("Edit", vec![ /* roles, as above */ ]),
])
Top-level entries are the menu-bar menus. Call app_menu at startup or any time the menu changes; it
replaces the previous app menu.
Claiming a standard slot
Each desktop fills the standard menu-bar slots it knows (Edit, View, Help) with its own stock menu
for any slot the app did not claim, so an app never restates the platform’s furniture. Tag your own
version with .bar_role(...) to take a slot:
sub_menu("View", vec![ /* … */ ]).bar_role(MenuBarRole::View)
A tagged menu replaces the stock one in place, so it also lands where the platform expects that
menu to sit: File, Edit, View in the bar’s leading order rather than adrift after them.
The tag identifies the slot, not the title, because of localization: day’s catalog and your
app’s may translate the same menu differently (day’s day-view is Présentation; the showcase’s is
Affichage), so a bar matched on titles would show both under --locale fr. An untagged submenu
whose title does equal the slot’s standard name still takes the slot (that stops the most common
accidental duplicate), but it is a safety net, not the contract. Tag the menu.
Where each backend puts the bar:
- AppKit: the system menu bar. Day prepends the standard App menu (About/Quit) automatically,
so your
sub_menus start at File. - GTK: a
GtkPopoverMenuBarat the top of the window; accelerators registered on theGtkApplication. On macOS the model goes togtk_application_set_menubarinstead; GTK’s quartz backend renders it in the system menu bar, and the stock GTK app menu’s Settings… item enables through anapp.preferencesaction wired to the Preferences dispatch id. - Qt: a
QMenuBar(the native global bar on macOS-qt). - Android: the app-bar overflow (⋮), built by
DayActivity.onCreateOptionsMenu. - XAML: a
MenuBardocked at the top of the window. - iOS/iPhone: a no-op, because touch platforms have no persistent global menu bar; the native
affordances are the per-Piece context menu and the system edit menu. (iPad/Catalyst
UIMenuBuilderwiring is a future addition.)
How it works
The builder lowers to a flat, toolkit-neutral [day_spec::MenuItem] tree. Each item’s closure is
registered with day-core, which hands back a process-unique action id; only the id travels into the
native menu. When the user chooses an item the backend emits Event::MenuAction(id); the event pump
routes it to dispatch_menu_action, which runs the closure inside a reactive batch (so signal writes
made from a menu coalesce into one update, just like a button tap). Standard roles carry no id; they
resolve to the toolkit’s command instead. This keeps the crossing minimal (an integer), avoids
holding native handles across the FFI boundary, and lets any backend add menu support by implementing
just two Toolkit methods: set_app_menu and set_context_menu.
Re-installing the same menu
set_app_menu compares the incoming model with the installed one, ignoring the action ids, because an app
declares its menu inside the page build, so every route change re-installs the same commands behind
freshly registered closures. A menu that differs only in those ids rebinds them onto the ids the
platform already holds and makes no toolkit call, which keeps a menu the user has open from closing
under them and stops the menu bar being rebuilt on every navigation. Anything else (a label, a
shortcut, an enablement, a new command) installs as before.
This is the rule the toolbar follows for the same reason (docs/toolbars.md, “Re-installing the same bar”), where the rebuild also took the keyboard focus out of the search field.
Nav-row context menus
A nav host’s rows can each carry their own context menu (item(…).context_menu(vec![…])
inside the .items mapper, docs/navigation.md) for the sidebar idioms every desktop app
grows: per-feed “Mark all read”, per-project “Reveal in Finder”, the Showcase’s per-page
“Show Source”. The entries are the same builders as everywhere else and lower through the
same action registry, so a chosen entry dispatches identically to a piece context menu; the
menus re-lower (re-localizing their labels) whenever the rows re-derive, like the
row titles.
Per backend: AppKit serves them through the outline’s menuForEvent: (NSTableView-family
views consume right-clicks themselves, so a menu attached to a cell’s subviews would never
be consulted); UIKit through the table delegate’s row-context hook (the standard long-press
row menu); GTK a per-row PopoverMenu with secondary-click + long-press gestures; Qt one
QMenu per row popped from the list’s custom-context request; Android a best-effort
setNavRowMenus follow-up after the nav mounts (the same off-critical-path rule as the row
tints, docs/vectors.md). Web and ArkUI drop them for now, same as the piece decorator’s
matrix.
Platform notes
- Nested submenus are unlimited on the desktop backends and iOS. Android menus support a single level of submenu (a platform limit); deeper submenus flatten into the nearest one.
- Separators render as dividers everywhere; on Android they become menu-group boundaries (dividers on API 28+).
- A
context_menu(vec![])(empty) or a later reconfigure detaches/replaces the menu on the Piece. - Checked items reach every backend that renders a menu at all. Android draws the check inside a
single level of submenu like any other item; the composed presentation draws its own ✓ column.
One observed gap: under macos-gtk the item dispatches and its state is correct, but the mark
does not appear in the global menu bar — GTK derives the check from the action’s state, and the
quartz bridge that mirrors the model into
NSMenudoes not carry it across. Linux GTK’s in-windowPopoverMenuBarresolves the same action group directly and is unaffected.
Future surfaces: dock, taskbar, and launcher menus
The same [MenuEntry] tree is the right shape for the app-wide surfaces day does not drive
yet: a macOS Dock menu is the existing builder plus one delegate hook
(applicationDockMenu(_:)), while Windows jump lists and .desktop Actions persist while
the app is closed, so their dispatch would have to lower to a relaunch argument; that is the
design gap, gated on those platforms’ deep-link intake.
Launcher shortcuts already shipped by another road: Day.toml [[shortcuts]] drives iOS,
Android, and HarmonyOS as route-keyed saved deep links, and
docs/deep-links.md describes that end to end.
Runtime language changes: app_menu_reactive
app_menu(vec) resolves labels once, in the install-time locale. An app whose language can
change at runtime (a preferences language picker, docs/windows.md) installs with
app_menu_reactive(builder) instead: the builder re-runs whenever a locale-tracked read
inside it changes (menu_role labels, res::str titles, and day::tr all read the locale
signal), re-lowering and re-installing the whole bar in the new language. Replacement drops
the previous install’s action closures; context menus share the dispatch map and are
untouched, and the durable Preferences/New Window dispatch ids always survive.
The auto Preferences item + the Window menu
day::register_preferences* gives every platform its standard Settings…/Preferences item
(⌘, / Ctrl+comma) without any menu code in the app, and macOS also auto-installs the standard
Window menu. The mechanics (injection into an installed menu, role rewiring, and the
register_new_window builder) are described in docs/windows.md.
Driving menus from dayscript
menu: { id: "view-grid" } invokes the item the app NAMED with .id. Prefer
it to everything below: it is the only address that survives a locale change or a moving check mark,
and it never falls back to matching a label, so a step naming an id that is not in the menu fails
instead of quietly hitting some other item that reads the same.
menu: { item: "Save" } invokes a unique app-menu action by exact label;
menu: { key: menu_save } resolves a Fluent key in the run’s locale first (locale-portable:
app keys and the day-* role keys both work, so the auto Preferences item is
key: day-preferences, with or without an installed app menu). path: [File]
disambiguates by ancestor submenu: each entry matches a submenu’s literal label or its
Fluent key resolved in the run’s locale, so path: [menu_file] works wherever
key: menu_file does and one script stays valid in every language. The step dispatches the
registered day action directly (toolkit-uniform, no native menu automation), so role-only
items that run a native selector (Cut, Quit, …) are not invokable this way.
Dynamic context menus
.context_menu(items) is declarative: one menu, set at build time. Some surfaces cannot
know their menu until the click lands (a canvas whose commands describe the selection under
the pointer, a tree whose rows each mean something different), so two summon-time forms
exist (docs/tree.md is the driving case):
.context_menu_fn(|point| … -> Vec<MenuEntry>)on any piece: the closure runs when the user summons the menu (right-click on desktop, long-press on touch), receives the location in the piece’s own coordinates, and whatever it returns is shown. An empty result shows nothing. The closure may adjust app state first (select what is under the pointer, then build); it runs on the UI thread, outside any day-core borrow, like every other synchronous Toolkit callback.tree(…).row_context_menu(|key| … -> Vec<MenuEntry>): the per-row form, handed the row’s key. By convention, a summon on a row outside the current selection selects that row first, so the menu describes what it acts on.
Action closures are lowered per summon into their own scope, disposed when the next summon (or the piece’s teardown) replaces them, so per-click menus never accumulate registrations.
Backends: the duty is Toolkit::set_context_menu_fn (default no-op). AppKit serves it from
menuForEvent: on the canvas view and the tree’s outline; GTK from a button-3 click (and a
long-press) building a one-summon PopoverMenu; UIKit from UIContextMenuInteraction,
whose configuration callback is already summon-time, and the tree’s rows from the
collection view’s own contextMenuConfigurationForItemAtIndexPath. On AppKit the covered
surfaces are the canvas and tree rows (other views keep the static .menu path); GTK’s
form is generic over any widget. Qt connects customContextMenuRequested to a callback
that asks the provider and pops a fresh QMenu per summon (day_qt_context_menu_fn),
generic over any widget like GTK’s.
The composed presentation (web-dom)
A toolkit with no native menu to hand the model to reports the summon instead: web-dom’s
shim listens for the browser’s contextmenu (default prevented; primary-button taps and
drags ignore the right button, or the summon’s own pointer-up would come out as a tap and
re-target the selection under the menu it just built) and emits
Event::ContextMenu { local, window }, carrying the point in the node’s coordinates for the
app’s provider and the same point in window coordinates for placement. The .context_menu*
decorators mount a lazily-armed, unrouted cover beside the decorated node (inside an
overlay-host wrapper, so single-child layouts still reach it in the place pass); on the
event they run the same provider and present the lowered model as day pieces at the summon
point, pulled inside the window near edges: item rows (day-menu-item-N ids, so a browser
test can click them), separators, inlined submenus, role items resolved to their standing
dispatchers, and disabled items dimmed. A tap outside dismisses. Toolkits that serve
menus natively never emit the event, so the fallback costs them nothing, not even a node,
until a first summon that never comes.
An app whose context menu commands need the clipboard (Cut/Copy/Paste items) calls
day::invoke_edit(EditOp::…), the same handler (transport included) that the platform’s own
Edit route reaches, instead of duplicating the clipboard plumbing.