Navigation (nav, nav_stack)
Navigation state lives in signals. Selecting a tab or pressing Back updates the signal; changing the signal from app code updates the native container.
navselects one destination, such as a tab or sidebar item. Its signal holds the active key.nav_stackholds a path of destinations in aSignal<Vec<_>>. Pushing or removing a key opens or closes a level of the native stack.
Keys implement Route. Start with strings, or use an app-defined enum when
you want the compiler to check destinations and their associated data. The string adapter
(navigate, nav_back, current_route) lets deep links and dayscript address either form.
nav: one-of-N
let section = Signal::new("home".to_string());
nav(section) // adaptive by default; .style() pins a shape
.title(tr("app-title"))
.header(sidebar_header) // optional piece above the list
.item("home", tr("home"), home_page)
.item("settings", tr("settings"), settings_page)
Styles
NavStyle | What it draws |
|---|---|
Automatic (default) | The platform’s answer at this width — see the ladder below. |
Tabs | A tab bar at every size, however wide the window gets. |
Sidebar | A NavigationSplitView: both panes where there is room, collapsing to a list that pushes the detail where there is not. |
Automatic walks one ladder, and only its bottom rung is platform-specific:
| window width | presentation |
|---|---|
Expanded and wider (≥ 840pt) | Split — sidebar beside detail |
Medium (600–839pt) | Rail — a narrow strip beside the content |
Compact (< 600pt) | Tabs where a tab bar is the idiom, Stack where it is not |
That last row is Cap::NavTabsAdaptive, and it describes the platform rather than its widget
set. Every desktop can draw a tab bar (an app is free to pin NavStyle::Tabs), and none of
them grows one when its window is dragged narrow: a narrow Mail.app hides its sidebar and pushes.
The phones and the web are the opposite, and they are the surfaces whose window size ranges from
a phone to a desktop. So Automatic and Sidebar behave identically on a desktop, and differ on
iOS, Android, HarmonyOS and the web.
Rail is not gated by that capability: a narrow sidebar is an ordinary desktop shape, and on
Windows it is what NavigationView does at that width. A backend with no rail control rounds it
to a neighbor of its own choosing: macOS has nothing that is a vertical strip of icon-only
destinations, so appkit draws an ordinary sidebar there.
Page residency
A presentation whose rows are the chrome (Tabs, Rail) keeps every visited page resident:
switching is a NavPatch::Select, nothing is torn down, and each tab keeps its scroll offset and
focused field, which is what every native tab container does. A Split or Stack presentation
keeps only the shown page and switches by pop-then-push.
Residency follows the presentation rather than the host. Making every nav page resident would keep effects running for pages nobody is looking at; making none resident would rebuild a tab’s content on every tap. Splitting it this way means a morph only ever disposes pages that are not on screen, or lazily builds ones that were not built yet; the visible page is never rebuilt, which is the invariant a morph has to keep. Pages build on first visit.
A chrome presentation is the exception, because its bar needs an item per destination: every page
is built up front, in row order, before the initial selection is applied. NavPatch::Select names
a page by attach order while the chrome draws the rows, so page i has to be row i; that pairing
is all a suite has. Building the selected destination first (the selection a .restore(key) came
back with, say) would attach it at index 0 and leave the bar highlighting the first row while
another destination’s page is the one on screen.
The active key is a Signal<String>, two-way exactly like Picker/Toggle: set it and the UI
switches; the user picking natively writes it back (origin-tagged, no echo).
| Presentation | Native container |
|---|---|
Split | a NavigationSplitView: macOS NSSplitViewController with a sidebar NSSplitViewItem (system material, source-list NSOutlineView, full-height under the titlebar) + detail; GTK two nested GtkPaneds (sidebar |
Stack | one page at a time, back-navigable: UINavigationController, AdwNavigationView, the Android back stack, or a desktop back-header above the pages. |
Tabs | the rows drawn as a tab bar: UITabBarController / Material NavigationBarView / NavigationView.PaneDisplayMode = Top / an NSTabView on macOS / an AdwViewSwitcher on GTK / a QTabWidget on Qt / a composed bar on web-dom. |
Rail | the rows as a narrow strip: Material NavigationRailView, PaneDisplayMode = LeftCompact, an ArkUI vertical Tabs; roundable where a toolkit has none. |
nav is one primitive, a selection-bound switcher, and a presentation is chrome plus page
lifetime rather than a different host. That is why a window crossing a breakpoint re-presents
(NavPatch::Presentation) rather than rebuilding: the pages it already has are re-homed, so
nothing loses a scroll offset, a focused field, or an animation in flight.
What each backend draws. The rows become the platform’s destination chrome, and where a
platform has no such widget the presentation rounds to the neighbor it does have (Rail lands on
an ordinary sidebar on macOS and Qt). A backend that answers Cap::NavTabs = Unsupported sends
Automatic through the sidebar ladder instead, which is what every backend did before adaptive
navigation existed.
| Backend | Tabs presentation | Adaptive |
|---|---|---|
| macos-appkit | NSTabView (top tabs on a bezel), the detail pages as its items; labels from the host’s rows | no; a Mac narrows to a stack |
| ios-uikit | UITabBarController in .tabSidebar mode, driven by UITab on iOS 18+ and by viewControllers below it (2026-09) — see below | yes |
| android-mdc | navigation suite: BottomNavigationView → NavigationRailView → permanent NavigationView drawer, by width. A NavStyle::Sidebar pane is a NavigationView too (2026-09) — see below | yes |
| linux-gtk | AdwViewSwitcher over an AdwViewStack of resident pages, each in a filling DayCell | no |
| linux-qt | QTabWidget — Qt’s own one-of-N container | no |
| web-dom | a composed tab bar (.day-nav.tabs) | yes |
| harmony-arkui | a composed bottom bar over resident pages; ArkUI’s native node set has no tab container, so it is built from Day’s primitives | yes |
| windows-xaml | the same NavigationView with PaneDisplayMode = Top; Rail is LeftCompact, a real rail | no |
Only the phones and the web grow a tab bar as the window narrows (Cap::NavTabsAdaptive); a
desktop may pin one with NavStyle::Tabs, but narrowing hides its sidebar and pushes.
Keyboard
A sidebar walks with the arrow keys: ↑/↓ move to the next destination, Home and End to the first
and last, and the selection reports exactly what a click on that row reports. The desktops get
this from the widget the style maps to (an NSOutlineView source list, a NavigationView) with
nothing of Day’s in front of it: keys go to whatever has focus and no route sits above them
(docs/menus.md).
web-dom has no such widget, so the backend builds the behavior: .day-navmenu carries the tab
stop and role="listbox", its rows carry role="option" and aria-selected, and the shim moves
the selection by reporting the new index the way the click handler does. It also holds the focus
across the page swap its own key press caused: a detail pane can contain an element the browser
focuses as soon as it appears (a <video>, a web view’s iframe), and without that the sidebar
would answer one arrow and then go quiet.
Immersive items (.immersive())
.item(…).immersive() marks the last-added destination as an immersive-chrome page: on
backends with an immersive nav mode (day-android’s edge-to-edge opt-in today) its pushed page
keeps the floating transparent bar over full-bleed content, while unmarked pages and the root
get the standard opaque bar. Every other backend ignores the flag. Pair it with
day::safe_area() (docs/layout): the immersive page paints its background unpadded and pads
its content by the reported insets.
nav_stack: push/pop with a value path
let path = Signal::new(Vec::<String>::new());
nav_stack(path, home_view)
.destination(|key| detail_view(key))
// push: path.update(|p| p.push("item-42".into()));
// pop: path.update(|p| { p.pop(); });
// the native back button writes the pop back into `path` (origin-tagged).
Day reconciles the native stack to path (keep the common prefix, pop the rest, push the new
suffix; the same diff NavigationStack/React-Navigation do). The native containers:
UINavigationController (iOS), AdwNavigationView (GTK), Android back-stack, and a
top-page-only presentation on macOS NSSplitView / Qt QSplitter in stack mode. The path is
data, so deep-linking is “parse the URL into a path and set it,” and the stack is unit-testable
without the framework.
Commands on the chrome
There is no bar_action. A command that belongs on the chrome rather than in the page is a
TOOLBAR ITEM declared on the piece it acts on (docs/toolbars.md), and which bar it
rides follows from that:
nav(section)
.toolbar(toolbar_button("add", tr("add")).icon(Symbol::Add).action(add_item)) // the list's
.destination(|k| page(k).toolbar(share_button())) // the page's
Nav::toolbar and NavStack::toolbar put items on that host’s own chrome — the sidebar column
where the presentation has one, the root list when it has collapsed. Items declared on a
destination page ride the detail. Both leave the bar when what they act on leaves the screen, so
neither needs a scope to be spelled out.
The affordance that shows and hides the sidebar is the toolkit’s, not the app’s: a
nav(Sidebar) supplies it, and .sidebar_toggle(false) suppresses it.
Data-driven items (nav().items)
nav items can come from a signal, so a sidebar or tab set grows and shrinks with your data
(a rooms list, open documents). Static .items and dynamic .items blocks mix; pair .items
with .destination to build the page for a data-driven key (like nav_stack).
let tabs = Signal::new(vec!["general".to_string(), "random".to_string()]);
nav(current)
.style(NavStyle::Tabs)
.items(move || tabs.get(), |k: &String| item(k.clone(), k.clone()))
.destination(|k: &String| room_page(k))
The row set re-derives whenever a block’s signal changes: rows are added/removed on the native
widget, and if the selected key disappears the selection resets (to None for an Option key).
The same effect resolves every row title tracked, so a runtime set_locale retitles the native
rows in place, static .items included.
item(key, title).icon(name) is the row spec; .icon_tint(color) recolors the row’s glyph
(docs/vectors.md), .context_menu(vec![…]) attaches a per-row context menu (docs/menus.md),
and .immersive() on it marks that row’s pushed page immersive-chrome, same as the static
form above.
.badge_icon(name) puts a glyph at the row’s trailing edge, with .badge_tint(color) for a
color that carries meaning (a starred page’s yellow star). It shares the trailing slot with the
text .badge(…) and is drawn after it, so a row can show a count and a status at once:
item(section, title)
.icon(res::vectors::nav_grid)
.badge_icon(res::vectors::star)
.badge_tint(AMBER)
Both are template glyphs, so an untinted one takes the backend’s neutral row color and follows
the theme; a tinted one keeps its color because the color carries meaning. Every backend draws it:
the trailing slot of the AppKit cell, the GTK row box, a QStyledItemDelegate on Qt (a
QListWidgetItem has only the leading icon slot), the end compound drawable on Android, the
composed NavigationViewItem.Content on XAML, between label and chevron on ArkUI, a masked
element on web-dom, and a trailing UIImageView on UIKit, which is also where the nav badge slot
first appeared on that backend. A nav host used as a self-contained widget inside a
page that already routes should call .local() so it does not add a segment to current_route or
intercept navigate.
Section headers. nav(…).section(title) opens a header before the NEXT item added —
a static .item or the first row of the next .items block — and item(key, title).section(title)
does the same from inside a data-driven mapper, which is how a derived list keeps its groups
under a search filter: attach the header to the first surviving row of each group, and a group
whose rows are all filtered out disappears with them. Headers are a grouping hint: a backend
without grouped rows (a tab strip, a plain phone list) shows one flat list, so a header is never
the only way a row is reachable, and a row’s icon tint is the grouping signal that survives
there. AppKit draws them as source-list group rows (small, bold, secondary, pinned as their
group scrolls under them); the outline keys its rows by index, not by title, so a header may
share its text with an item (“Controls” over Controls) without the two collapsing into one row.
UIKit and Android draw them from their list’s own header slot, described below. GTK draws them
in its list box’s header slot. Qt, whose QListWidget has no header slot, draws each as a small
bold dimmed item of its own that takes no click, hover or selection, and web-dom as a heading
element between the rows. Windows adds a NavigationViewItemHeader to the pane’s items (a disabled
item in the plain ListView sidebar), collapsing them while the pane is a tab strip or an icon
rail, and HarmonyOS a secondary-colored title above the group’s first row. These four translate
indices at the list, so a row keeps the index Day gave it however many headings sit above it.
nav(section)
.section(res::str::smart_feeds())
.item_icon("today", res::str::today(), res::images::today, today_page)
.section(res::str::feeds())
.items(move || st.feeds.get(), |f| item(f.id, f.name))
A data-driven item is a label + optional icon: the native sidebar/tab row. It is not an
arbitrary rich row (an avatar + preview + badge); a master list that needs those is a list, and
combining a rich master list with native master-detail push is a separate, not-yet-built feature.
Backend support (2026-07): dynamic add/remove/reselect renders on macos-appkit, linux-gtk,
linux-qt, and web-dom (and their host variants), verified in the showcase walkthrough. The
ios-uikit sidebar and tab selection are wired; dynamic rendering on the UIKit/Android/ArkUI/XAML
tab widgets is in progress (those backends ignore the item-set patch until then, so the initial set
still shows). The item logic is backend-independent and covered by
mock_e2e::nav_data_driven_items_reconcile.
The iOS tabs host is driven by UITab
A Tabs presentation on ios-uikit is a UITabBarController in .tabSidebar mode, and since
2026-09 its destinations are UITab
objects rather than a viewControllers array of UITabBarItems. Apple’s guidance since iOS 18 is
that adopting UITab is what gives a tab bar its automatic adaptivity — the tab bar and the
sidebar become two renderings of ONE list of tabs, which is Day’s model exactly, and it is what
.tabSidebar is built to consume. The array still works, but the controller has to infer
everything from view controllers, and the sidebar-side affordances have no tab to hang on.
Five things this backend learned the hard way:
- A
UITabis a model object with an identity, not a per-render descriptor. Its provider hands UIKit a view controller and UIKit then owns that controller as the tab’s. This host re-syncs on every page insert and every rows change; minting fresh tabs each time left two tabs claiming one controller, and UIKit asserts in-[UITab viewController]as soon as it resolves the second. A tab is created once per page and only its title and glyph are re-applied. - The identifier is the PAGE, not the position.
insertcan put a page in the middle, and an identifier is fixed at construction. Day’s index is the tab’s position in the host’stabs. - A tab’s glyph needs a SIZE, and the ASSET is where it comes from. A tab bar draws
UITab.imageat the image’s own size: UIKit scales an SF Symbol to the bar’s metrics, but a catalog image has no metrics to scale by. A Material Symbols export carrieswidth="48" height="48"and drew at 48pt, twice the height of an iOS tab icon and overlapping its own label. The backend hands the image over untouched and the glyph is authored at icon size instead (docs/vectors.md): thumbnailing here downsamples the catalog’s bitmap rendition and throws away the vector representation that put it there. Day-Showcase’s Grids tabs were the last 48pt holdouts (2026-09-05). - A tab switch waits for the visible stack’s transition. Switching tabs hides the outgoing tab’s navigation controller, and a pop still animating there never finishes once its view has left the window: the transition coordinator stays alive, the popped page stays on the stack, and every later screenshot reports the UI still settling (Day-Trader’s back-then-switch on iOS 27, one walkthrough variant in eight). The selection moves in Day’s tree at once; the native switch is deferred a few runloop turns until no on-screen stack is transitioning.
didSelectTab:previousTab:fires for programmatic selection too, where the olddidSelectViewController:fired only for user taps. It therefore needs the same origin guard as every other two-way control here — without it, installing the tabs reported a selection the user never made and the app’s bound signal followed it.
Below iOS 18 the same host runs on viewControllers. UITab and setTabs: are iOS 18’s, so
the sync asks the CONTROLLER whether it answers setTabs: — the same shape the .tabSidebar
setMode: probe takes — and where it does not, each page carries its title and glyph on the
UITabBarItem UIKit makes for it, the roster goes in through setViewControllers:, Select
addresses a page by INDEX, and the delegate’s didSelectViewController: reports a tap. An iOS 17
device therefore gets the plain tab bar an iOS 17 app always had rather than a crash. The
didSelectTab:previousTab: method is declared OUTSIDE the delegate’s protocol block for the same
reason: a protocol block asks the runtime for the nav host’s type encoding, and asking for an iOS
18 nav host on an older runtime fails the class registration itself, which took down the whole
scene as soon as a tabs host was realized.
UITabGroup is deliberately NOT used. Day’s NavMenuProps::sections are headings — “a section
header introducing the row at the same index” — whereas a UITabGroup is a destination that
CONTAINS others: in a sidebar it draws as a heading, but in the compact tab bar the whole group
collapses to a single tab. Mapping flat sections onto it would turn N tab-bar destinations into G.
It becomes the right realization if Day’s nav host ever grows real hierarchy.
The iOS sidebar is a collection-view list
nav(NavStyle::Sidebar) realizes on ios-uikit as a UICollectionView laid out by
UICollectionLayoutListConfiguration with the .sidebar appearance, in the primary column of
a UISplitViewController. That appearance publishes the listEnvironment trait the cells’
adaptive UIListContentConfiguration::cellConfiguration reads, and it is what gives the Settings
sidebar its look: an inset rounded selection pill with a tinted label, and rows with no separators.
A UITableView draws its selection edge to edge whatever background configuration its cells carry,
so the rounded shape is the list appearance’s to give.
The list runs under the bars. A page whose content is one scroll view — this list, a
scroll-rooted detail, a tree — fills the page’s full bounds, and UIKit’s inset adjustment
starts the content below the navigation bar and lets it pass under the translucent bar and the
bottom search field as it scrolls, as Settings and Mail do. A page whose content is a navigation
host fills its bounds too, because the host passes the bars on to its own pages — which is what
lets a tab’s list reach under the tab bar. The window root follows the same rule: a window whose
root is a nav, split or tab host is not padded by the status bar and home indicator, so the bars
reach the screen’s edges and the list runs behind them, the way every UIKit app’s does. A page holding anything else (a heading over a list, a
form, a canvas) is laid out inside the safe area instead, since it has no scroll insets to absorb
a bar with.
Section headings ride the same list configuration. setHeaderMode(.supplementary) turns them
on, and a heading is a supplementary view carrying the adaptive
UIListContentConfiguration::headerConfiguration, so its type, color and insets come from the same
sidebar environment the rows do. A list that declares no heading sets .none instead, so a flat
sidebar reserves no band where a heading would go, and flipping heading-ness re-installs the
layout, header mode being a property of the layout rather than of the data.
The grouping stays private to the list. NavMenuProps::sections is parallel to the rows (a heading
introduces the row at its own index), the backend folds that into runs of (heading, first, len),
and row_of/path_of translate between a flat row index and an NSIndexPath. Everything above
the backend — SelectionChanged, NavMenuProps::selected, the per-row tint and badge arrays —
keeps speaking in flat row indices.
The Android sidebar is a NavigationView
nav(NavStyle::Sidebar) realizes on android-mdc as a Material
NavigationView,
the class Android means for a standing navigation column. It was a LinearLayout of TextView
rows in a ScrollView until 2026-09, with the 48dp height, the padding, the ripple and the 24dp
leading glyph measured out by hand — a comment called that “the Material nav-drawer idiom”, which
it was imitating. The real one brings the M3 row metrics, the ripple, subheaders between
NavMenuProps::sections (the flat list dropped them, so eight groups arrived as twenty bare rows),
a RecyclerView so a long sidebar recycles, and the fully-rounded ACTIVE INDICATOR behind the
checked row — which is why NavMenuPatch::Selected is no longer a no-op there.
Two consequences worth knowing before touching it:
- A row’s menu id is not its index.
NavigationMenuItemViewcopies its item’s id onto ITSELF, which puts menu ids and view ids in the one namespacefindViewByIdsearches — and Day’s own fragment containers takeView.generateViewId(), which counts up from 1. Rows keyed by bare index collided immediately:containerIdwas 1, the second row became a view with id 1, it sits earlier in the traversal than the detail container, andFragmentTransaction.replacebuilt every detail page INSIDE that sidebar row. Ids start atROW_ID_BASE(0x01000000) for rows andSECTION_ID_BASEfor headings, abovegenerateViewId’s ceiling and below aapt’s floor. - Rows recycle, so a per-row context menu (docs/menus.md) cannot be attached once. It goes on
from
OnChildAttachStateChangeListener, reading the row a cell is CURRENTLY bound to from its owngetItemData()rather than from its adapter position, which headings and dividers shift.
How iOS tells a user’s back from its own
There is no mirror of the stack on iOS (2026-09-10; before this, day-uikit kept a copy of the
intended stack, re-applied it wholesale on every change, inferred the user’s pops from didShow
counts through a settle loop, and absorbed Day’s answering pops with a counter — and both back
bugs of that month lived in the bookkeeping). UIKit’s viewControllers is the stack. Two rules:
-
Day’s changes are one delta, computed from what UIKit reports. The insert duty that hands a page’s controller to the host pushes it (
push_page: the active controller’s current pages plus this one, in onesetViewControllers:animated:), and the remove duty that takes it back pops it (pop_page: the current pages minus this one). A collapsed triple column is driven through UIKit’s column APIs instead (showColumn,popToViewController:), since a wholesale set destroys the bookkeeping its merge keeps. A merge on iOS 26 nests the secondary controller onto the primary as one entry, soday_pagesflattens a nested controller into its pages wherever a stack is read. A transition UIKit cancels (a window capture mid-flight does that on iOS 26+) is applied once more from its completion. -
The user’s back is observed where it starts.
DayNavControlleroverridespopViewControllerAnimated:,popToViewController:animated:andpopToRootViewControllerAnimated:: UIKit routes the back button there onceshouldPopItem:agrees, the swipe there under an interactive transition, and the history menu to thepopTo…pair. Day never calls those for its own changes, and its two pops on a collapsed triple column announce themselves (with_day_pop), so a call that reaches super is the user’s.observe_user_popwaits for that pop’s own transition — a swipe let go early is cancelled, and Day hears nothing — andconfirm_user_popemits oneNavBack { already_popped: true }per Day page that left. Day answers each by popping its model and removing the page, andpop_pagefinds the page already gone: that no-op is the whole protocol between the two, and nothing is counted. -
A pop during a size-class transition is UIKit’s own. A split view merges its columns when it collapses and takes them apart again when it expands, and the un-merge is a real
popViewControllerAnimated:on the primary’s controller — indistinguishable at the call site from the user’s back.DayRootVC’swillTransitionToTraitCollection:withTransitionCoordinator:raises a flag for the length of the transition (released by the coordinator’s completion, with a two-second floor under a completion that never arrives), andnote_popreports nothing while it is up. Read as a back instead, the pop deselects the row the user is on, and the widened split then obeys its own “never show an empty detail” rule and opens the FIRST row: resizing an iPad window across the breakpoint threw away the page you were reading and replaced it with the top of the list (2026-09-19). The window root is early enough to see it, because UIKit restructures the columns before it reports the expansion. The cost is that a back tapped during the resize animation itself is ignored, which is the trade this is worth.
NavPatch::ListInStack is not consulted here: UIKit’s collapse folds the content list onto the
merged stack by itself (the list is the supplementary column’s root), and NavPatch::ListVisible
shows or pops it there. NavPatch::Presentation never reaches a toolkit whose container
re-presents (the pieces layer gates it on Cap::NavRepresent).
The guard has the same two doors: shouldPopItem: vetoes the button, and the controller is the
swipe recognizer’s delegate, whose gestureRecognizerShouldBegin: vetoes the swipe; both emit
NavBack { already_popped: false } and let Day decide. The gesture is never disabled, so a guard
that proceeds pops through Day’s rail and the next swipe works with nothing to re-enable.
nav_back: in a walkthrough drives Day’s rail and reaches none of this; nav_back: { native: true }
presses the bar’s button (Toolkit::native_back → DayNavController::press_back), which asks
shouldPopItem: through its selector and pops through the override, so a script covers the code
a tap runs. Trace it with --env DAY_DIAG_NAV=1: user pop levels=1, exec SET native=N -> target=M.
Back interception (on_back)
NavStack::on_back intercepts the user’s back affordance (a native gesture/button, or nav_back())
to run a policy before the pop. It does not run for a programmatic path.set (a write is not a
back), matching Jetpack Compose’s BackHandler.
let dirty = Signal::new(false);
nav_stack(path, home_view)
.destination(|k| detail_view(k))
.on_back(move |req| {
if dirty.get() {
// confirm asynchronously, then perform the deferred pop on "yes"
day::task(async move {
if confirm("Discard changes?").await {
dirty.set(false);
req.proceed(); // performs the pop the guard consumed
}
});
BackResponse::Handled // consume this back
} else {
BackResponse::Proceed // normal pop
}
});
The guard returns Proceed (pop now) or Handled (consume; the pop does not happen). A Handled
guard may hold the BackRequest and call proceed() later: the unsaved-changes → confirm → leave
flow. proceed() performs exactly the pop Proceed would have.
While a guard is armed above the root, Day tells the toolkit to stop auto-popping on a native
gesture and route the back through Day instead (NavPatch::GuardTop). What that means per backend:
| backend | native-gesture arming while guarded |
|---|---|
| iOS (UIKit) | swipe disabled; the back button is vetoed via a UINavigationController subclass’s navigationBar:shouldPopItem:, which emits the back to Day |
| Android | a higher-priority OnBackPressedCallback routes the system/gesture back and the toolbar up-arrow to Day (the predictive-back preview is unavailable while armed) |
| HarmonyOS (ArkUI) | the top NavDestination’s onBackPressed consumes the native back and defers to Day |
| GTK | the top AdwNavigationPage sets can-pop = false (swipe/Escape disabled; the app drives back through its own control, which is guarded) |
| macOS / Qt / XAML / web | no-op — the back button already routes through Day, so the guard runs with no native arming needed |
The guard’s logic (intercept, defer, proceed, never-on-programmatic-write) is identical everywhere
and covered by mock_e2e::nav_stack_on_back_guard_intercepts_and_defers.
A nav host arms the same patch for a reason of its own: while a detail is open beside a composed
content list, so the platform’s back closes the detail instead of popping the page that holds
both (see detail_visible below). The host counts the requests to arm it, so a guarded stack
merged into the host and an open detail never disarm each other.
Routes: the string-route adapter (deep links & dayscript)
Each mounted surface registers a small adapter over its own signal, so a string route can address the whole tree. The grammar:
route = segment *( "/" segment ) [ "?" query ] e.g. mail/inbox/msg-42?hint=shared
segment = a nav host/tabs item key, or a stack destination key
query = name "=" value *( "&" name "=" value ) (params for the destination builders)
Reserved characters inside a segment or param value (/ ? & = %) are percent-encoded;
day_core::nav::{parse_route, encode_route} do this for you. Two addressing modes:
- A single key is relative:
navigate("inbox")reaches the innermost surface first and falls through outward. For anav/tabs it sets the active key; anav_stackclaims only""(pop to root), so sibling keys fall through to the enclosing surface. This is what a button deep inside a page wants: address the nearest thing that knows the key. - A
/-separated path is absolute:navigate("mail/inbox/msg-42")anchors at the outermost surface that knows the first segment, resets every surface inside the anchor to its root, then feeds the remaining segments inward. Segments for surfaces that only mount as the outer switch takes effect are queued and consumed as those surfaces register. One string reaches a stack three levels deep on a cold start. A stack consumes absolute segments unconditionally (its destinations are open-ended); the explicit path is the stack’s state (set-semantics: navigatingmail/inboxwhilemail/inbox/msg-42shows pops the detail).
Params ride the query string: route_param("hint") / route_params() inside a destination
builder return the values of the navigation being applied. They describe the navigation in
flight; a push you perform by writing the path signal directly carries its data in your own
state instead.
nav_back(): pops the innermost surface, falling through when it is already at its root. On a sidebar with a content list, an open detail is the innermost layer, whether it is pushed over the list in a collapsed stack or shown beside it in a split: the call closes it (detail_visible:= false), the same place the native back lands, and only a second call leaves the section.current_route(): the full path, every mounted surface’s contribution from outermost to innermost ("mail/inbox/msg-42"). It round-trips throughnavigate, so persisting the whole route by hand is two lines: savecurrent_route()on the way out (day-part-prefs works),navigate(&saved)after the first mount on the way back. For a single surface,.restore(below) does the same without the plumbing. dayscript’sassert_routecompares against the same full path.- Startup deep links (
DAY_DEEPLINK) and warm links (RouteRequested) route the same way. On hosts with no process environment the platform entry records the launch route withday_core::set_launch_deeplinkinstead; web-dom seeds it from the page’s URL hash (docs/web.md), so…/#controlsopens on that section. The OS side (scheme registration, per-platform intake, and testing) is docs/deep-links.md. - The URL stays live both ways on web-dom: day-core reports every route change to the backend
(
Toolkit::set_route: the hash updates as you navigate, one history entry per step), and a hash change the app didn’t write (browser back/forward, a hand-edited URL) arrives asEvent::RouteRequestedand navigates. Other backends inherit the no-op default.
Because each surface owns its own signal, a nav(Tabs) or a nav_stack nests inside a
nav(Sidebar) section with no extra wiring. There is no global navigation controller
to arbitrate, only this string adapter for addressing.
Sibling one-of-N surfaces need .local(). Every routed surface contributes to the full
route, so two nav/tabs at the same level (a filter tab strip beside a main tab bar)
both feed current_route(): you get section/mainKey/filterKey, and navigate("filterKey") is
ambiguous. Mark all but the primary one .local(); it then drives its own signal without touching
the route. A nav host nested one level deeper (a Tabs inside a Sidebar section) is the
opposite case and should stay routed; that cascade is what nesting is for. In debug builds,
two routed one-of-N surfaces at the same level log a warning naming this fix.
Ordering caveat: relative dispatch and the full route walk the registry in mount order, which equals nesting depth for a single active chain. Two sibling surfaces mounted at once (two independent stacks visible in one window) are ordered by mount time, not focus. Prefer absolute routes (or drive the signals directly) in such layouts.
day lint cross-checks literal navigate("…") calls and dayscript navigate:/assert_route:
routes against the declared keys in your sources (.item("key", …) call sites and
routes! { … => "key" } blocks): a route whose first segment nothing declares is reported
(day::lint::unknown-route) rather than failing silently at runtime.
Restoring state across launches (.restore)
When you want a surface to reopen where the user left it, mark it with .restore(key)
instead of wiring current_route() by hand:
nav(section).restore("nav.section") // reopens on the last-viewed section
nav_stack(path, home).restore("mail.path") // rebuilds the pushed path
The selected key (or the stack’s /-joined path) is saved under key on every change and read
back at build. A pending launch deep link wins: a DAY_DEEPLINK (or a set_launch_deeplink
hint) routes one turn after mount, so .restore steps aside and the link decides where the app
opens. A saved value that no longer fits (a nav host key whose item is gone, a stack segment that
no longer parses) is ignored rather than restoring a broken state.
.restore reads and writes through a store the app installs once at startup; nothing persists
until you install one:
fn main() {
day::prefs::install_nav_store(); // before the UI mounts
// …
}
The prefs store is disk-backed, so restore also survives an Android process death: the OS
reclaims a backgrounded app and rebuilds it on return, and the value is still on disk. With no
store installed, .restore is a silent no-op, so the same code compiles and runs on a target
where you don’t want persistence: the Showcase installs the store on web only, where a reload is
routine, and starts fresh on native. To back .restore with your own storage, implement
day_core::NavStore and hand it to day_core::set_nav_store.
Typed routes
Route keys are data, and strings are their wire format. The Route trait carries the
two-way mapping:
pub trait Route: Clone + PartialEq + 'static {
fn key(&self) -> String; // typed value → path segment
fn from_key(key: &str) -> Option<Self>; // path segment → typed value
fn title(&self) -> String { self.key() } // native nav-bar title (defaults to the key)
}
title() is the label a stack shows in the native navigation bar
for a pushed page. It defaults to the wire key, so override it to display a name when the key
is not presentable (e.g. a route that carries only an id can look the name up from your data).
String implements it (the untyped baseline: every segment parses), and for plain enums the
routes! macro writes both sides:
day::routes! {
pub enum Section { Home => "home", Stack => "stack" }
}
let section = Signal::new(None::<Section>); // None = the collapsed mobile list
nav(section)
.item(Section::Home, tr("home"), home_page) // compile-checked, no raw keys
.item(Section::Stack, tr("stack"), stack_page)
A sidebar nav keys on Option<Section> (None ↔ "", the no-selection list state);
tabs always have a selection, so they key on the bare enum (Signal::new(Tab::One)). Blanket
impls cover both: Option<R> is a Route whenever R is, and .item takes the bare variant
either way.
Variants carry data: this is where typed routes improve on string encoding. Implement
Route by hand and put the payload in the variant:
enum Drill { Depth(u32), Item { id: u32 } } // "3" ↔ Depth(3), "item-42" ↔ Item{id:42}
let path = Signal::new(Vec::<Drill>::new());
nav_stack(path, root).destination(|d: &Drill| match d {
Drill::Depth(n) => level_page(*n), // parsed, not string-split
Drill::Item { id } => item_page(*id),
})
// push: path.update(|p| p.push(Drill::Item { id: 42 }));
The destination builder receives the parsed value; encode/decode lives in exactly one place
(the Route impl). A typed stack also validates absolute routes: a segment from_key
rejects is refused (the navigation stops there) instead of pushing an unparsed key; a String
stack keeps its open-ended accept-anything behavior.
Typed absolute paths compose with route(…), and navigate_to is the typed relative form:
navigate_to(&Section::Home); // ≙ navigate("home")
route(&Section::Stack).then(&Drill::Item { id: 42 })
.param("hint", "linked")
.navigate(); // ≙ navigate("stack/item-42?hint=linked")
nav_link_to(tr("open-42"), route(&Section::Stack).then(&Drill::Item { id: 42 }))
Everything downstream is unchanged: current_route() still returns the encoded string (which
is what you persist), deep links and dayscript still speak segments, and the two layers meet
only at key/from_key. Mixed trees are fine: a typed nav host over a String stack, or
vice versa.
Composition
To reproduce the Mail.app or Files.app navigation pattern, nest the containers:
nav(section).style(NavStyle::Sidebar)
.item("library", tr("library"), || nav_stack(lib_path, library_root).destination(detail))
The sidebar selection drives which section shows; the selected section is itself a nav_stack that
drills down. Each surface owns its signal.
Nested stacks share one native container on mobile. When the enclosing host presents as a
push stack (a phone, or any window too narrow for two panes; see
size classes), a nav_stack built inside one of its pages does not mint a second native
navigation controller; it pushes its own pages onto the enclosing host, so the whole chain
(list → section → drill-down) is one native stack with a single back button. The inner nav_stack
keeps its own path signal and route registration (so current_route(), deep links, and
nav_back() fall-through are unchanged); only the native container is shared. Where the
enclosing host presents as split panes a nested nav_stack is not merged; it renders in the
detail pane with its own back-header, which matches the desktop idiom. A resident container
(nav(Tabs)) is a merge barrier: a nav_stack inside a tab keeps its own host.
Split or stacked
A nav(Sidebar) shows its list beside the selected page in a wide window and pushes the
page over the list in a narrow one. That follows the window, not the platform: it is resolved
from the window’s size class and re-resolved whenever the window crosses a breakpoint, so one
nav is right on a desktop, a tablet, and a phone. .presentation(…) pins it where the
content only works one way. docs/size-classes.md is normative; it covers the
breakpoints, what survives a re-presentation, and which backends morph today.
The content list (three panes)
.content_list(build) gives a nav host the Mail shape: sidebar, content list, detail
(mailboxes, message list, message). The list is built once and stays resident for the
host’s life; its content follows the app’s own signals (the sidebar selection scoping it, the
row chosen from it), so switching sections re-scopes it without a rebuild.
nav(section).style(NavStyle::Sidebar)
.content_list(timeline_pane) // the middle column, built once
.content_list_width(400.0) // preferred; drag limits are the backend's
.content_list_for(|k| k != "settings") // full-page sections collapse the pane
.detail_visible(reader_open) // the compact push gate, two-way
.detail_title(move || open_title.get()) // the detail layer's bar, live
.item(…)…
.destination(…) // the DETAIL only — the list is not in here
- Where the pane lands is
Cap::NavContentList’s answer.Native(macos-appkit, Qt, GTK): a real pane at every presentation, with a draggable divider on each side; a narrow window collapses the sidebar and keeps the list, as a narrow Mail.app does. On Qt the pane is the middle of the same three-paneQSplitterthe sidebar lives in, hidden on a host that declared no list, so pane indices and the back header never move; on GTK it is the start child of the inner of the host’s two nestedGtkPaneds, hidden the same way (since 2026-09; the backend notes below have the shape).Emulated(ios-uikit): a real column while expanded that merges into the navigation stack when the host collapses. AUISplitViewControllerfixes its column count at creation, never shows the primary without the supplementary, and drops a controller re-mounted in another of its columns, so a destination without a list gets a double-column host and one with a list a triple-column host: a change between the two on a wide window rebuilds the host with fresh column controllers and moves the pages across, which is what SwiftUI does when aNavigationSplitViewchanges column count. Day’s handle for the host is a container the split’s view fills, so the rebuild never touches the tree.Unsupported(everything else): the nav host composes the list beside each list-backed destination while split, and as the root layer of the gated push flow while compact. detail_visibleis the compact flow’s gate, two-way like every binding. Wide layouts never gate on it (the detail pane is always on screen, showing the app’s empty state until a row is chosen), but a back still treats it as the open layer: while it istruebeside the list, the back closes the detail (writesfalse) and only the next back leaves the section, so clear what the pane shows when the signal goesfalse. Where the list is composed into the destination’s page, a platform back (Android’s, HarmonyOS’s) would pop that page, list and detail together, so the host armsNavPatch::GuardTopwhile a detail is open side by side and closes it through Day instead. Stacked, the content list is the top of the stack until the app writestrue(a row was opened); the detail pushes then, and the platform’s back writesfalseon the way out. In a chrome presentation (a tab bar, a rail) the same flow runs inside the tab: the destination’s page is a nested navigation host (aUINavigationControllerin the tab, a Material toolbar over the fragment back stack), so the tab gets a navigation bar, a title, and a native back, and the nav host’s bar actions ride that bar (a tabs chrome otherwise draws none). Withoutdetail_visiblea stacked host behaves classically (the detail pushes on selection).detail_titlenames the detail layer’s navigation bar: the pushed editor on a phone, the detail page’s bar wherever the toolkit titles one. Reactive like every title: a closure reading your own state retitles the live bar (NavPatch::Title) as that state changes, so the bar can carry the open item’s name. Unset, the detail layer keeps its destination’s title.content_list_forcollapses the pane per destination (NavPatch::ListVisible): a settings page takes the whole detail area, and selecting a list-backed section brings the pane back.- A host with a content list joins the split’s default-selection rule at every presentation: the pane needs a selection to scope itself to, so a collapsed host opens on the list rather than on bare sidebar rows.
- The native resident pane (
Native, andEmulatedwhile expanded) is a merge barrier like a chrome page: anav_stackinside it keeps its own container, with the desktop back header above its pages. The composed compact flow is the opposite: there the list is the root page of a push stack (the tab’s own navigation controller, or the enclosing host’s), so anav_stackinside it merges, and a drill-down from the list (a category, then its stations) is real pushes with the native back, with the gated detail pushed on top of whatever the stack pushed.
Keyboard: pair the list’s content with .focusable() + .focused(sig) + .on_key(…)
(docs/focus.md) so the arrows walk the selection, and a scroll_target signal
(ScrollTarget::Id) to keep the selected row in view.
Backend notes
- GTK adopts libadwaita throughout (
adw::Applicationloads the Adwaita stylesheet). The window is anAdwApplicationWindowwhose content is anAdwToolbarView(anAdwHeaderBarsupplies the title, window controls, and drag; Day’s content sits below it). Navigation:Sidebar→ two nestedGtkPaneds — sidebar | (list | detail) — with a user-draggable divider on each side, the AppKitNSSplitViewshape;nav_stack→AdwNavigationView(push/pop + back gesture; itspoppedsignal writes native back into the path). A split pane’s page (aGtkFixed) sits in a fillingDayCell— day-gtk’s custom container, which asks GTK for no size and hands its child the whole allocation — so a page’s Day-laid frame never becomes a pane’s GTK minimum; each cell carries its pane’s honest minimum as its size request instead (NAV_SIDEBAR_MIN_W,NAV_LIST_MIN_W, and 200pt for the detail), no pane may shrink below it, and the divider stops there (the maximums are clamped on the position notify). Each pane cell reports its page’s size from its ownsize_allocate, before it allocates the page, and day-core dispatches the report at once when its tree is free — so the page is re-laid inside the same GTK layout pass and follows a divider drag live, as AppKit’ssetFrameSize:report does. (Reporting from an idle after the position notify, as the first paned version did, trailed the divider by a frame or more.) The sidebar folds the way an AppKit split item does: when the window has no room for it beside the other panes’ minimums it collapses, and it comes back at its last width once there is room again (24pt of slack keeps a resize on the threshold from flapping); the toolbar’s sidebar toggle hides it independently, and a toggled-off sidebar stays off. The inner paned exists on every host, list or not — with no list page, or one collapsed bycontent_list_for, GTK gives the detail the whole inner allocation and draws no handle — so the widget shape never changes under live pages. A stack page’sGtkFixedis wrapped in anAdwNavigationPage. Tabs use anAdwViewStackwith a.linkedtoggle switcher; dialogs useAdwAlertDialog(docs/dialogs.md). Decision (2026-09):AdwOverlaySplitViewis gone from this backend, with no flag to bring it back. libadwaita pins sidebar widths by design (the GNOME HIG has no draggable sidebars), so the Adw split could never give Day’s desktop apps the adjustable columns AppKit and Qt have, and its pane sizing — a min == max sidebar, a content pane whose minimum was its own Day frame — fought every relayout (the issue #19 reveal jump, the External-policy scroll windows it took to break minimum propagation). What the Adw split supplied that the paned does not — a slide on the sidebar toggle, and the adaptivecollapsedbreakpoint — is either not wanted (dayscript screenshots the instant a toggle returns) or done by the fold above.DAY_GTK_SPLIT, which used to select the paned, is no longer read.
- macOS
NSSplitView/ QtQSplitterhonor the resolved presentation:Splitshows both panes; anav_stackcollapses the empty sidebar and stacks every page (top visible) in the detail pane, with a back header (chevron + centered title, hidden at the root) above the pages; desktop has no system back affordance, so a pushed page carries its own way out. The button emits the sameNavBackevent mobile back does, writing the pop into the path signal. - Android hosts each page in an androidx Fragment that retains its Day-owned view
(the react-native-screens pattern: the FragmentManager owns when a page shows, Day owns
what it shows). A push is a
replace()back-stack transaction carryingMaterialSharedAxistransitions, which gets the whole back behavior from the platform:OnBackPressedDispatcherdispatches hardware/gesture back on every API level, the FragmentManager seeks the pop transition live under the predictive back gesture on API 34+ (progress, cancel, commit), and its back callback is enabled only while the back stack is non-empty, so the system’s predictive back-to-home animation stays available at the root (apps opt in withandroid:enableOnBackInvokedCallback="true"; the scaffold does). Native pops are reported to Rust asNavBack { already_popped: true }; Rust-initiated pops runpopBackStack. When testing on Android 13/14 (API 33/34), the system gates predictive-back animation behind Developer options → “Predictive back animations” (adb shell settings put global enable_back_animation 1), and gesture navigation must be active; Android 15+ enables it by default. - Mobile presents the host as a native stack for both
Sidebar(collapsed) andnav_stack. Anav_stacknested inside such a host’s page merges into it (oneUINavigationController/DayNavHost, one back button) rather than nesting a second controller; see Composition. No backend change is involved: the shared host receives NAV_PAGE pushes/pops from both the outer surface and the inner stack identically to a single-surface stack.
Testing
crates/day-pieces/tests/mock_e2e.rs: nav host tabs/sidebar two-way binding, stack
push/pop/reconcile, native-back-into-path, deep-link, nested fall-through, and typed routes
(a Signal<Option<Area>> sidebar over a data-carrying Leg(u32) stack, including segment
validation). The showcase’s top-level nav is a typed nav(Sidebar) over a Section
enum, its Tabs page a typed nav(Tabs), and its Stack page a nav_stack over a
data-carrying Drill enum, all driven through the walkthrough on all five local targets.