Menus, toolbars, and windows
Menus, keyboard shortcuts, and toolbars expose an app’s commands through familiar platform controls. For one operation shared across these surfaces, define a reusable command. Day uses a shared Rust API to define them, along with secondary windows and a Settings window. A menu item can call the same action as a button in the interface:
menu_item("Save").key("s").action(save) // ⌘S on macOS, Ctrl+S everywhere else
Works on: context menus render natively everywhere (NSMenu, GtkPopoverMenu, QMenu,
UIMenu, Android PopupMenu, XAML MenuFlyout). The app menu is a menu bar on the four
desktop backends and the app-bar overflow (⋮) on Android; on iPhone it is a no-op, since
touch platforms have no global menu bar. Toolbars exist only where the platform has them:
Cap::Toolbar is Native on the four desktop backends and Unsupported everywhere else.
Secondary windows work on every backend: native windows on the desktops, iPad, Android, and
HarmonyOS; on iPhone and web the same call presents the content as a fullscreen cover.
1. Install the app menu
Call app_menu once at startup, with one sub_menu per menu-bar menu:
use day::prelude::*;
app_menu(vec![
sub_menu("File", vec![
menu_role(MenuRole::NewWindow), // File ▸ New Window, ⌘N (step 5)
menu_item("Open…").key("o").action(|| open_file()),
menu_item("Save").key("s").action(|| save()),
menu_separator(),
menu_role(MenuRole::CloseWindow),
menu_role(MenuRole::Quit),
])
.bar_role(MenuBarRole::File),
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),
])
.bar_role(MenuBarRole::Edit),
]);
- Roles are the platform’s items.
menu_role(MenuRole::Copy)emits the native Edit ▸ Copy: correct localized label, default shortcut, automatic enable/disable, and focus targeting, so it copies from whatever control has focus with no wiring. Custommenu_items run your closure instead. .key("s")is the primary modifier (⌘ on Apple, Ctrl elsewhere), so one spec reads right on every desktop. For anything else, build aShortcut:Shortcut::new("s").shift()is ⇧⌘S / Ctrl+Shift+S,Shortcut::plain("Delete")has no modifier, and.alt()/.control()add the rest. Named keys ("Return","Delete","F5", arrows) work alongside single characters..bar_role(…)claims a standard slot. Each desktop fills the standard menus (Edit, View, Help) with its own stock version for any slot you didn’t claim. Tagging your submenu withMenuBarRole::File/Edit/Viewreplaces the stock menu in place, in the bar’s standard order. The tag identifies the slot, not the title; Day’s catalog and yours may translate the same menu name differently, and a bar matched on titles would show both.
The bar appears in the system menu bar on macOS (Day prepends the standard App menu with
About and Quit, so your submenus start at File), in a bar at the top of the window on GTK and
Windows, in a QMenuBar on Qt (the native global bar on macos-qt), and in the app-bar
overflow on Android. Android allows one level of submenu; deeper ones flatten.
app_menu resolves labels once, in the install-time locale. If your app has a runtime
language picker, install with app_menu_reactive(builder) instead; the builder re-runs on a
locale change and reinstalls the bar in the new language.
2. Attach context menus
The same entries attach to any piece with .context_menu(…), shown on secondary-click on
desktop and long-press on touch:
label("Right-click me").context_menu(vec![
menu_item("Rename").action(|| rename()),
menu_item("Duplicate").key("d").action(|| duplicate()),
menu_separator(),
menu_role(MenuRole::Copy),
])
Submenus nest inside a context menu the same way, menu_role items keep their native
behavior, and passing an empty Vec removes the menu.
3. Put commands in a toolbar
Attach .toolbar(…) to the piece whose content the commands affect. A toolbar on the window’s
root piece stays available throughout that window. A toolbar on a page appears with that page
and disappears when it leaves the screen. The platform decides where to place those commands;
the toolbar reference describes each toolkit’s behavior.
item_list(scene).grow().toolbar([
toolbar_toggle("show-done", "Show Done", scene.show_done).icon(Symbol::Filter),
toolbar_button("add", "Add")
.icon(Symbol::Add)
.placement(ToolbarPlacement::Primary)
.action(move || scene.new_item()),
])
The items are toolbar_button(id, label) for a command, toolbar_toggle(id, label, signal)
for a two-state button bound two-way, toolbar_segmented(id, segments, signal) for one native
segmented control, toolbar_menu(id, label, entries) for a pull-down built from the same
MenuEntrys the menu bar takes, toolbar_label(id, text) for static text, and
toolbar_separator(id) for a divider. The modifiers are .icon(Symbol), .image(name),
.action(f), .tooltip(t), .enabled(bool), .enabled_when(f), .placement(…),
.label_style(…), and .prominent().
Search has no toolbar item. Declare it on the navigation surface it filters, with
nav(section).searchable(query), and Day draws the field where the platform puts search.
That lets it move into the navigation list on a window too narrow for a sidebar without your code
changing. A sidebar supplies its own toggle button, so an app declares nothing for that either.
Alignment comes from .placement(…), which names the item’s role rather than a position in the
list, and each backend lays that role out its own way: Navigation sits at the leading edge of its
column, Principal is centered, Primary and Secondary go trailing, with secondaries folding
into an overflow menu first, and Bottom asks for a phone’s bottom bar. .icon(Symbol::Refresh)
names what the icon means; each backend draws its platform’s glyph (an SF Symbol on macOS, a
freedesktop name on GTK and Qt, a Segoe Fluent glyph on Windows).
Per desktop, the bar is an NSToolbar in the unified title-bar style on macOS, where each
column’s items sit over that column; on GTK the items pack into the window’s AdwHeaderBar,
because in GNOME the header bar is the toolbar; on Qt it is a real QToolBar that takes its
icon size and style from the user’s settings; on Windows it is a CommandBar, whose one limit
is that search fields and labels always render on the leading side.
.toolbar(…) also takes a closure, page.toolbar(move || vec![…]), which re-runs whenever the
state it reads changes and replaces that piece’s items. Keep the values that change often out of
that closure: a toggle’s signal, a search field’s signal, and
.enabled_when(…) patch the one item in place, so a command greying out never disturbs a search in
progress. To show an item conditionally, put its piece under when; the item leaves with its
subtree. The Toolbars reference covers placement per platform and what
each backend draws.
4. Open a secondary window
let win = day::open_window(
Some("detail:AAPL"), // key: open-or-focus singleton; None = always new
WindowOptions { title: "AAPL".into(), size: Size::new(720.0, 640.0), ..Default::default() },
WindowKind::Normal,
|| detail_page("AAPL"),
);
win.on_close(|| println!("gone"));
The key names the logical window: opening an already-open key focuses it instead of
duplicating, and day::window_by_key("detail:AAPL") finds it later. WindowKind::Normal is
resizable, miniaturizable, and joins the platform’s tabbing group; WindowKind::Preferences
drops resize and minimize and never tabs. The window is app-owned: it survives the page that
opened it. Close is asynchronous everywhere: the title-bar button, a platform gesture, and
WindowHandle::close() all wait for the platform to confirm, then the content is disposed and
on_close runs. Closing the primary window quits the app.
Where the toolkit cannot open windows (iPhone, web, and the Preferences
kind on all mobile), the content presents as a fullscreen cover in the primary window instead, with
the same API, keys, and close path. That tier has no native title bar or close button, so probe
Cap::MultiWindow and give cover-tier content its own close affordance (the system back button
closes it on Android).
5. Add the Settings window and File ▸ New Window
Two registrations in your root builder, before app_menu, give the app its standard window
conventions:
day::register_preferences_with(
WindowOptions { title: "Settings".into(), size: Size::new(520.0, 420.0), ..Default::default() },
|| preferences_page(),
);
day::register_new_window(|| {
shell() // toolbar items come from the pieces in this window
});
register_preferences_with alone enables the Settings item: on macOS,
“Settings…” with ⌘, in the App menu directly under About; on GTK, Qt, and Windows, a
Preferences item with Ctrl+comma, injected into your first menu if you didn’t place a
menu_role(MenuRole::Preferences) yourself. The window opens under the singleton key
day.preferences, so reopening focuses it, and day::open_preferences() opens the same
surface from anywhere, such as a toolbar gear. On the cover tier it presents fullscreen.
register_new_window names the builder behind menu_role(MenuRole::NewWindow) (File ▸ New
Window with ⌘N/Ctrl+N) and the macOS tab-bar ”+”. Each call opens an independent Normal
window. On macOS, Day also installs the standard Window menu (Minimize, Zoom, Bring All to
Front, plus the open-window list) unless your own menu claims MenuRole::Minimize.
Pitfalls
- Register windows before the menu. A
MenuRole::NewWindowitem is disabled when no builder is registered, and the auto Settings item needs the preferences registration. Callregister_preferences_withandregister_new_windowbefore installing the app menu (the showcase’sroot()does exactly this), so those menu items are enabled. - Toolbars follow their pieces into new windows. Items declared on a window’s root piece belong to that window. Items declared on a page appear wherever that page is shown. A builder whose pieces declare none opens a window with an empty bar.
- Keep bound values out of a derived toolbar closure. A reactive rebuild replaces that
piece’s items and would drop the search field’s focus mid-word. Structure and labels go in
the closure; a toggle’s signal, a search signal, and
.enabled_whenpatch single items. - Don’t put the toolkit name in your window title. Debug builds append a
(<version>/<toolkit>)tag to every title so you can tell windows apart; add your own and it appears twice. Release builds never show the tag.
Reference
- menus — the full role table per backend, how action dispatch works, and driving menus from dayscript.
- toolbars — the per-backend realization table, patch semantics,
and the
toolbar:script step. - windows — the backend tier table, the pending-open path, the debug title tag, and per-window screenshots.