Navigation

Day has two navigation pieces: nav selects a section, and nav_stack opens detail pages with a path back. Both use platform navigation controls and keep their state in signals. Routes connect that state to deep links and automated tests.

The UI snippets use generated res::str accessors from an app’s Fluent catalog; see the example messages and catalog setup.

Sections: nav

let section = Signal::new("home".to_string());

nav(section)
    .style(NavStyle::Sidebar)
    .title(res::str::my_app())
    .item("home", res::str::nav_home(), || home_page())
    .item("library", res::str::nav_library(), || library_page())
    .item("settings", res::str::nav_settings(), || settings_page())

The selection signal holds the active item’s key. Set it from anywhere (section.set("settings".into())) and the nav host 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 state that must survive a switch belongs in signals created above the destination builder. Signals created inside that builder are disposed with its page. A Tabs host 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: nav_stack

let path = Signal::new(Vec::<String>::new());

nav_stack(path, library_page())
    .title(res::str::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(res::str::open_album(), "album:42");  // a button that pushes
navigate("album:42");                    // push from code; returns false if no surface (nav host, tabs, or stack) recognizes it
nav_back();                              // pop
current_route();                         // Option<String>

nav_stack delegates native back handling to each backend, including UIKit navigation controllers and Android back callbacks. Guarded and unguarded navigation can use different gesture paths. Check edge swipes, system back, and any predictive preview on the OS versions you ship; the repository implementation is not evidence of those behaviors passing on devices. Desktop back headers also depend on the chosen toolkit.

Data-driven items

A nav’s items can come from a signal, so a sidebar or tab set follows your data:

nav(current)
    .style(NavStyle::Tabs)
    .items(nav_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 nav host .local() when it is a second one-of-N control inside an already-routing page (a filter strip beside the main tabs). Two routed nav hosts 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 nav host nested one level deeper (a Tabs inside a Sidebar section) should stay routed. 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.

nav_stack(path, editor())
    .destination(|k| detail(k))
    .on_back(move |req| {
        if dirty.get() {
            day::task(async move {
                if confirm(res::str::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.

Route::title() may read reactive metadata. The stack updates the current native header when that title changes, even if the route still compares equal. This lets a destination open immediately with a loading title and acquire its final title after fetching data without rebuilding the page.

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, which suits 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:
nav_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 without any current_route() calls:

nav(section).restore("nav.section");  // reopens on the last-viewed section
nav_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 the app’s preferences, which day::launch sets up, and they are disk-backed, so restore also survives an Android process death.

dayscript uses the same mechanism: navigate: { route: controls } in a script performs the write your UI would, and assert_route compares the full current_route(). A navigation test asserts on route 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

Routes travel as strings; in code you can declare them as an enum. Declare the keys as an enum and both nav and nav_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)
nav(section)
    .item(Section::Home, res::str::nav_home(), || home_page())
    .item(Section::Library, res::str::nav_library(), || library_page())
    .item(Section::Settings, res::str::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. Each variant maps to its declared string, so deep links, dayscript, and current_route() are unchanged.

Typed routes pay off most for routes that carry data. Implement the Route trait by hand (key() encodes, from_key() parses), and stack destinations receive the typed value:

#[derive(Clone, PartialEq)]
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());
nav_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, 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(
    res::str::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 nav host over a String stack is fine).

Patterns and limits

  • Desktop split layouts: NavStyle::Sidebar gives the two-pane desktop shape. Give the detail pane .grow() or it collapses to its content width.
  • State restoration: .restore(key) persists and restores selection and path through the installed nav store, as described above. Persist your own signals (see parts: prefs) only for custom state beyond navigation.
  • More windows: day::open_window opens secondary windows on every backend (WindowKind::Normal, Preferences or About; day-piece-settings registers the Settings and About windows for you): native windows on the desktop backends, iPad scenes, Android activities, and OHOS abilities; iPhone and the web present them as a fullscreen cover. See windows. Dialogs and alerts are separate (dialogs reference).
  • Android process death: if Android kills a backgrounded process, relaunch is a cold start, but .restore reads back through the disk-backed prefs store, so navigation state survives it.

The navigation reference has the per-platform mapping details.

Verify route and state lifetime

The examples above are integration fragments; app page functions and route enums must be provided. Use the navigation lesson for complete compiled code. The current signatures are nav<K: Route, S: Binding<K>>(selection: S) -> Nav<S, K> and nav_stack<K: Route, S: Binding<Vec<K>>>(path: S, root: impl Piece) -> NavStack<S, K>. A route is not limited to String; its encode/parse contract is part of the app’s data model.

Keep shared state above replaceable destination closures. Persisted navigation restores route selection/path, not arbitrary page signals. navigate(&str) -> bool reports whether routing succeeded; it does not validate application data or grant permission to open a document. Secondary windows have app-owned lifetimes and do not extend captured page-local signals.

Evidence: navigation builders, route dispatch, and prefs nav store. Test unknown routes, back behavior, restoration, and close/reopen on the target you ship.

Share state between sections

Create shared signals before the replaceable page builders. Both pages below read the same count; switching away from the counter does not discard it. Section keys are protocol data, while the labels are localized. The selection is supplied by the caller for programmatic navigation and tests.

Complete module for src/navigation_recipe.rs in the technical recipe harness. Add the matching Fluent messages below. This module is compiled as part of the harness; it is not a standalone app.

use crate::res;
use day::prelude::*;

day::routes! {
    pub enum Section { Counter => "counter", Summary => "summary" }
}

pub fn sections(section: Signal<Section>, count: Signal<i64>) -> impl Piece {
    nav(section)
        .style(NavStyle::Sidebar)
        .item(Section::Counter, res::str::section_counter(), move || {
            crate::events::counter(count)
        })
        .item(Section::Summary, res::str::section_summary(), move || {
            label(res::str::section_total(count))
                .id(id!(section_total))
                .padding(Space::L)
        })
        .id(id!(recipe_sections))
        .grow()
}
section_counter = Counter
section_summary = Summary
section_total = Total: { $count }

This module also uses the event module. For the mobile sidebar’s unselected list state, use Signal<Option<Section>> and set it to None. The bare enum above always names a selected section.