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:

BuilderProduces
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.

BackendDrawn as
appkitNSMenuItem.state — the system ✓
uikitUIAction.state — the system ✓
gtka stateful boolean GAction; GTK renders the check from the action’s state
qta checkable QAction
xamlToggleMenuFlyoutItem
androidMenuItem.setCheckable — Material’s check
doma ✓ 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, UIKityes — NSMenuItem.image / UIAction image, from the shared SF Symbol table
GTKyes — the GMenuModel “icon” attribute, drawn by GtkPopoverMenu
Qtyes — QAction::setIcon, resolved like a toolbar icon (theme name, then Day’s outline, then the QStyle standard set)
XAMLsymbols only — a FontIcon from the Segoe Fluent table; a bundled image would need the toolbar’s three-field icon channel
Androidignored: Material’s overflow menu is text-only, and an icon belongs on an app-bar action
ArkUI, web-domno 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),
    ]),
])
RoleAppKitGTKQtUIKitAndroidXAML
Cut/Copy/Pastecut:/copy:/paste: nav hosts — a focused text view answers first, then the app’s edit bridge⁴clipboard.* actionsdispatched to the focused QLineEdit/QTextEditresponder chain, then the edit bridge⁴edit bridge⁴ (text keeps its own selection toolbar¹)accelerator²
SelectAllselectAll: nav host — a focused text view answers first, then the edit bridge⁴selection.select-allfocused editorresponder chain, then the edit bridge⁴edit bridge⁴accelerator²
Undo/Redoundo:/redo: (responder chain — the acting NSUndoManager, a focused text field’s before the document’s)stock actions (text.undo)focused editorinstalled undo bridge³installed undo bridge³
Quit / Close / Minimize / Fullscreenstandard App-menu itemswindow actionswindow / qAppQuit closes the window
About / Preferencesmoved into the App menumenuRole → 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 GtkPopoverMenuBar at the top of the window; accelerators registered on the GtkApplication. On macOS the model goes to gtk_application_set_menubar instead; GTK’s quartz backend renders it in the system menu bar, and the stock GTK app menu’s Settings… item enables through an app.preferences action 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 MenuBar docked 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 UIMenuBuilder wiring 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.

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 NSMenu does not carry it across. Linux GTK’s in-window PopoverMenuBar resolves 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.