Navigation
Day’s navigation model is two Pieces and a route registry. selector handles “one of several
top-level sections” (a sidebar on desktop, tabs where that’s the platform idiom); stack handles
“drill in, come back” (push/pop with the platform’s own transitions and back gestures). Both are
driven by plain signals, so navigation state is app state: inspectable, settable, testable.
Sections: selector
let section = Signal::new("home".to_string());
selector(section)
.style(SelectorStyle::Sidebar)
.title("My App")
.item("home", tr("nav-home"), || home_page())
.item("library", tr("nav-library"), || library_page())
.item("settings", tr("nav-settings"), || settings_page())
The selection signal holds the active item’s key. Set it from anywhere
(section.set("settings".into())) and the selector switches; the UI and programmatic navigation
can’t disagree because they’re the same state. Residency differs by style: Tabs builds every page at mount and keeps them alive, so tab
pages retain their state (field contents, scroll position) across switches; Sidebar builds
the selected page on demand and disposes it when the selection changes, so sidebar page state
lives in your signals, not in the widgets. The cost is that a selector with many heavy pages pays for all
of them up front; keep expensive content behind a when inside the page if that
matters.
Drill-down: stack
let path = Signal::new(Vec::<String>::new());
stack(path, library_page())
.title(tr("nav-library"))
.destination(|key| detail_page(key))
The path signal is the navigation stack: ["album:42"] means one page pushed above the root.
.destination maps a pushed key to its page. Pushing is a vector edit
(path.update(|p| p.push(key))) or, more commonly, the helpers:
nav_link(tr("open-album"), "album:42") // a button that pushes
navigate("album:42"); // push from code; returns false if no surface (selector, tabs, or stack) recognizes it
nav_back(); // pop
current_route(); // Option<String>
Underneath, stack uses the platform’s navigation machinery (UINavigationController on
iOS, the androidx Fragment back stack on Android), so you get the iOS edge-swipe back gesture
and Android’s back button without writing either. On Android 14+ the back gesture is fully
predictive: the system seeks the actual pop transition under your finger; the page
tracks it and either completes the pop or springs back when you release (on Android 13/14 the OS gates
this behind Developer options → “Predictive back animations”; Android 15 enables it by
default). On desktop, pushed pages get an in-window back header: a chevron and title above
the page on macOS and Qt, libadwaita’s own header on GTK.
Data-driven items
A selector’s items can come from a signal, so a sidebar or tab set follows your data:
selector(current)
.style(SelectorStyle::Tabs)
.items(move || rooms.get(), |r: &Room| item(r.id.clone(), r.name.clone()))
.destination(|k| room_page(k))
Rows are added and removed on the native widget as the signal changes; if the selected item
disappears the selection resets. A data-driven item is a label plus an optional icon (the native
row). For a rich master list (avatar, preview, badge) use a list.
Mark a selector .local() when it is a second one-of-N control inside an already-routing page (a
filter strip beside the main tabs). Two routed selectors at the same level both feed
current_route(), so you’d get section/main/filter and navigate would be ambiguous; .local()
keeps the secondary one out of the route. A selector nested one level deeper (a Tabs inside a
Sidebar section) should stay routed; that cascade is intended. Debug builds warn when two routed
one-of-N surfaces share a level.
Intercepting back
Guard the user’s back (a native gesture, the back button, or nav_back()) with on_back, for
the unsaved-changes-confirm case. A programmatic path.set is never guarded (a write is not a
back); this mirrors Jetpack Compose’s BackHandler.
stack(path, editor())
.destination(|k| detail(k))
.on_back(move |req| {
if dirty.get() {
day::task(async move {
if confirm("Discard changes?").await {
dirty.set(false);
req.proceed(); // perform the pop the guard held
}
});
BackResponse::Handled // consume the back for now
} else {
BackResponse::Proceed // pop normally
}
});
Return Proceed to pop now or Handled to consume it; a Handled guard can hold the
BackRequest and call proceed() later. While a guard is armed Day stops the toolkit from
auto-popping on a native gesture and routes the back through your guard instead: on iOS the
swipe is disabled and the back button is intercepted, on Android a back callback takes priority
(the predictive-back preview is unavailable while armed), on GTK the page’s swipe is disabled.
Routes and deep links
A route is segments/joined/by/slashes with an optional ?name=value query. A single key is
relative: the innermost surface that knows it wins, falling through outward, right for a
button deep inside a page. A multi-segment path is absolute: it anchors at the outermost
surface that knows the first segment, resets everything inside, and descends. One string
reaches a stack several levels deep, even on a cold start where the inner surfaces haven’t
mounted yet:
navigate("library/album-42?hint=shared"); // section, then push, with params
// in the destination builder:
stack(path, root).destination(|key| {
let hint = route_param("hint"); // Some("shared") when opened via that route
album_page(key, hint)
})
current_route() returns the full path ("library/album-42"), and it round-trips through
navigate, so persisting the whole route across launches is: save current_route() on the way
out, navigate(&saved) on the way back in.
For a single surface, .restore(key) does that for you (no current_route() plumbing):
selector(section).restore("nav.section") // reopens on the last-viewed section
stack(path, home).restore("mail.path") // rebuilds the pushed path
It saves the selected key (or the stack’s path) on every change and reads it back at build. A
launch deep link outranks it, and a saved value that no longer fits is ignored. Persistence runs
through a store you install once (day_part_prefs::install_nav_store() in main), which is
disk-backed, so restore also survives an Android process death. With no store installed, .restore
is a no-op, so you can persist on one target and start fresh on another with the same code.
The same mechanism is what dayscript uses: navigate: { route: controls } in
a script performs the write your UI would, and assert_route compares the full
current_route(). Testing a navigation flow is asserting on strings, and day lint checks
that every literal route in your sources and scripts starts with a declared item key, so a typo
is a lint warning instead of a silently-ignored tap.
Typed routes
Strings are the wire format; your code doesn’t have to speak it. Declare the keys as an enum
and both selector and stack accept it directly; every .item, destination match, and
navigation call site is then compile-checked:
day::routes! {
pub enum Section { Home => "home", Library => "library", Settings => "settings" }
}
let section = Signal::new(None::<Section>); // None = nothing selected (mobile list)
selector(section)
.item(Section::Home, tr("nav-home"), || home_page())
.item(Section::Library, tr("nav-library"), || library_page())
.item(Section::Settings, tr("nav-settings"), || settings_page())
A sidebar keys on Option<Section> (None is the collapsed mobile list); tabs key on the bare
enum since a tab is always selected. Under the hood each variant maps to its declared string, so
deep links, dayscript, and current_route() are unchanged.
Where this earns its keep is routes that carry data. Implement the Route trait by hand
(key() encodes, from_key() parses), and stack destinations receive the typed value:
enum Media { Album { id: u32 }, Track { id: u32 } } // "album-42" ↔ Album { id: 42 }
impl Route for Media {
fn key(&self) -> String {
match self {
Media::Album { id } => format!("album-{id}"),
Media::Track { id } => format!("track-{id}"),
}
}
fn from_key(key: &str) -> Option<Self> {
if let Some(id) = key.strip_prefix("album-") {
return id.parse().ok().map(|id| Media::Album { id });
}
key.strip_prefix("track-")?.parse().ok().map(|id| Media::Track { id })
}
}
let path = Signal::new(Vec::<Media>::new());
stack(path, library_page()).destination(|m: &Media| match m {
Media::Album { id } => album_page(*id), // parsed, not string-split
Media::Track { id } => track_page(*id),
})
The encode/parse pair lives in one place instead of being scattered across every push and
destination, and a typed stack validates incoming deep links: a segment from_key rejects
stops the navigation instead of pushing a garbage page. Typed navigation helpers mirror the
string ones:
navigate_to(&Section::Library); // relative, ≙ navigate("library")
route(&Section::Library).then(&Media::Album { id: 42 })
.param("hint", "shared")
.navigate(); // absolute, with params
nav_link_to(tr("open-album"), route(&Section::Library).then(&Media::Album { id: 42 }))
String implements Route too, so the untyped examples above are the same API: start
stringly, move to an enum when the app grows, mix the two freely (a typed selector over a
String stack is fine).
Patterns and limits
- Desktop split layouts:
SelectorStyle::Sidebargives the two-pane desktop shape. Give the detail pane.grow()or it collapses to its content width, the most common layout mistake in navigation code. - State restoration: Persist the two signals (see parts: prefs) and write them back at startup, and your app reopens where it closed. Nothing does this automatically.
- One window: Day currently drives a single window per process; multi-window is designed but not built. Dialogs and alerts are separate (dialogs reference).
- Android process death: if Android kills a backgrounded process, relaunch is a cold start; Day doesn’t yet snapshot navigation state into the saved-instance mechanism, so restoring is your code (the prefs pattern above).
The navigation reference has the per-platform mapping details.