# Navigation state and the back button How an ltk app answers "go back", and why that answer and the state it persists across a relaunch are the same thing. Read [`docs/architecture.md`](./architecture.md) first for the `view` / `update` model this builds on. ## Table of contents - [The model in one paragraph](#the-model-in-one-paragraph) - [Two kinds of state](#two-kinds-of-state) - [What a back request does](#what-a-back-request-does) - [Back is derived, not recorded](#back-is-derived-not-recorded) - [Back and session state are the same state](#back-and-session-state-are-the-same-state) - [`NavStack`](#navstack) - [Apps that embed a web view](#apps-that-embed-a-web-view) - [Apps whose back stack lives elsewhere](#apps-whose-back-stack-lives-elsewhere) - [At the root](#at-the-root) - [Who owns what: app, toolkit, compositor](#who-owns-what-app-toolkit-compositor) - [Checklist](#checklist) --- ## The model in one paragraph Every app has a part of its model that says *where the user is*: which screen, which panel is open, how far into a history. That is its **navigation state**. A back request undoes the top-most piece of it, one piece per request, until nothing is left; then the window steps out of the way. The app never keeps a list of "things to undo": [`App::on_back`] looks at the model and answers. Because the same state is what [`App::save_state`] persists, an app restored after a relaunch or a crash goes back through exactly the places it would have gone back through before. ## Two kinds of state Sort every piece of UI state into one of two groups. The group decides who closes it, whether `Escape` touches it and whether it is persisted. | | Transient UI | Navigation state | |---|---|---| | Examples | context menu, combo popup, tooltip, toast, text selection, a dialog, text-input focus | current screen, open panel or drawer, selected tab when tabs are places, position in a history, full screen | | Closed by back | yes, first | yes, after every transient layer | | Closed by `Escape` | yes | **never** | | Persisted by `save_state` | no | yes | | Owner | mostly the runtime | the app's model | The test for a doubtful case: *if the app were killed right now and came back, would the user expect to find this still there?* An open settings screen, yes. A tooltip, no. A dialog is transient even though the app declares it: it interrupts a place, it is not a place. Give it a `cancel` message (see [`dialog`](./widgets.md#dialog)) and the runtime closes it on back and on `Escape` without the app writing a line. ## What a back request does A back request is the shell's back button or gesture, which the compositor delivers as the `XF86Back` key, or a mouse's back button (`BTN_SIDE` / `BTN_BACK`). Each request peels exactly one layer, top-most first: 1. **Transient UI the runtime owns**: open xdg-popup overlays, the built-in context menu, an active text selection, then the top-most laid-out pressable carrying an `on_escape` message (a dialog's cancel). 2. **Text-input focus.** Dropping it ends the text-input session, which retires the on-screen keyboard on a compositor that did not already close it. It comes before the app on purpose: with a keyboard covering half the screen, back means "put the keyboard away", and navigating instead would throw away what the user was typing. 3. **[`App::on_back`]** — the app's navigation state. 4. **[`App::on_key_with_modifiers`]** with `Keysym::XF86_Back`, for apps that bound the key before `on_back` existed. 5. **[`App::back_at_root`]** — see [At the root](#at-the-root). `Escape` runs step 1 only, then drops the focus and reaches `on_key_with_modifiers`. It is "cancel", not "go back": it never navigates and never leaves the app. Before `on_back` existed `XF86Back` was an alias of `Escape`; an app that relied on that keeps working through step 4. Two details follow from back being a navigation request rather than input. The key does not auto-repeat by default (`App::key_repeats`), since a held button would empty the whole stack; and it is not reported through `App::on_raw_key`, so an embedded web view or guest toolkit never sees it as a keystroke. ## Back is derived, not recorded `on_back` returns the message that undoes the top-most piece of navigation state, computed from the model at that moment: ```rust # use ltk::{ App, BackResponse, Element, text }; # #[ derive( Clone, Copy, PartialEq ) ] enum Screen { List, Detail } #[ derive( Clone ) ] enum Msg { CloseSearch, Show( Screen ) } struct Notes { screen: Screen, searching: bool } impl App for Notes { # type Message = Msg; # fn app_id( &self ) -> &str { "net.example.Notes" } # fn save_state( &self ) -> Option> { None } # fn restore_state( &mut self, _: Vec ) {} # fn view( &self ) -> Element { text( "" ).into() } # fn update( &mut self, _: Msg ) {} fn on_back( &mut self ) -> BackResponse { if self.searching { return BackResponse::Message( Msg::CloseSearch ); } match self.screen { Screen::Detail => BackResponse::Message( Msg::Show( Screen::List ) ), Screen::List => BackResponse::Unhandled, } } } ``` The alternative — pushing an "undo" entry whenever something opens and popping it on back — looks equivalent and is not. The user leaves screens in other ways: the panel's own close button, a link, a notification that jumps elsewhere. Every one of those paths has to remember to remove its entry, and the first that forgets leaves a back press that does nothing visible. A derived answer cannot disagree with the model, because it *is* the model. This is also why the runtime keeps no navigation stack of its own. Its entries would be messages, which are not serialisable (the `App` trait puts no serde bound on `Message`, by design), so it could not survive a relaunch; and it would duplicate what the app already knows. Keep `on_back` cheap and free of side effects beyond returning the message: do the work in `update`, where every other state change lives. ## Back and session state are the same state [`App::save_state`] exists so an app can come back where the user left it (see *Session state* in [`docs/onboarding.md`](./onboarding.md#session-state) and the cookbook recipe [Surviving relaunch](./cookbook.md#surviving-relaunch-session-state)). What it should persist is the navigation state, the same state `on_back` reads: - persist **less** than back walks, and a restored app opens on the right screen but back jumps straight out of it, because the screens underneath were not saved; - persist **more**, and the app restores a menu or a half-typed search the user had mentally closed. When both hooks operate on one object the rule holds by construction. That is the reason to model navigation as a value (a `NavStack`, an enum, a struct of a few fields) rather than as booleans scattered across screens. Transient UI is deliberately left out of `save_state`. So is anything secret, as the `save_state` contract already requires. ## `NavStack` [`NavStack`] is the ready-made navigation state for the common case, an app made of screens: the routes the user went through, root first. It is a plain value in the app's model, never empty, and it serialises whenever the route type does. The example below is compiled as the doctest of `NavStack` itself; it is `ignore`d here only because the Markdown doctest harness links `ltk` alone, without `serde`. The cookbook recipe [Multi-screen app](./cookbook.md#multi-screen-app-via-sub-state-pattern) persists a `NavStack` without serde. ```rust,ignore use ltk::{ text, App, BackResponse, Element, NavStack }; use serde::{ Deserialize, Serialize }; #[ derive( Clone, PartialEq, Serialize, Deserialize ) ] enum Route { Inbox, Thread( u64 ), Settings } #[ derive( Clone ) ] enum Msg { Open( Route ), Back } struct Mail { nav: NavStack } impl App for Mail { type Message = Msg; fn app_id( &self ) -> &str { "net.example.Mail" } fn view( &self ) -> Element { let screen = match self.nav.current() { Route::Inbox => text( "inbox" ), Route::Thread( id ) => text( format!( "thread {id}" ) ), Route::Settings => text( "settings" ), }; screen.into() } fn update( &mut self, msg: Msg ) { match msg { Msg::Open( route ) => self.nav.push( route ), Msg::Back => { self.nav.pop(); } } } // One object answers back… fn on_back( &mut self ) -> BackResponse { self.nav.back( Msg::Back ) } // …and is what survives a relaunch. fn save_state( &self ) -> Option> { serde_json::to_vec( &self.nav ).ok() } fn restore_state( &mut self, state: Vec ) { if let Ok( nav ) = serde_json::from_slice( &state ) { self.nav = nav; } } } ``` `push` goes somewhere new and keeps the current route underneath. `replace` is the sideways move — tab to tab, wizard step to step — that back should not retrace. `pop_to_root` and `reset` cover "home" and "log out". `back( msg )` is the glue for `on_back`: the message while there is a route to return to, `Unhandled` at the root. Put in the route whatever is needed to rebuild the screen (`Thread( id )`, not a loaded thread): the route is persisted, the data it points at is fetched again. An in-app back arrow sends the same message the hook returns (`Msg::Back` above), so the arrow and the system back button cannot drift apart. An app with layers above its screens checks them first and delegates the rest: ```rust # use ltk::{ BackResponse, NavStack }; # #[ derive( Clone ) ] enum Msg { CloseDrawer, Back } # struct Shop { drawer_open: bool, nav: NavStack } # impl Shop { fn on_back( &mut self ) -> BackResponse { if self.drawer_open { return BackResponse::Message( Msg::CloseDrawer ); } self.nav.back( Msg::Back ) } # } ``` ## Apps that embed a web view A browser-like app has two histories: its own chrome (panels, menus, full screen) and the page's session history. The order is chrome first, then the page: ```rust # use ltk::BackResponse; # #[ derive( Clone ) ] enum Msg { Dismiss, LeaveFullscreen, PageBack } # struct Page; impl Page { fn can_go_back( &self ) -> bool { false } } # struct Browser { menu_open: bool, panel_open: bool, fullscreen: bool, page: Page } # impl Browser { fn on_back( &mut self ) -> BackResponse { if self.menu_open || self.panel_open { return BackResponse::Message( Msg::Dismiss ); } if self.fullscreen { return BackResponse::Message( Msg::LeaveFullscreen ); } if self.page.can_go_back() { return BackResponse::Message( Msg::PageBack ); } BackResponse::Unhandled } # } ``` States *inside* a page need no extra work: a single-page application that uses the History API (`pushState`) creates session-history entries, so the engine's "can go back" already covers them. With ltk-webkit these are `WebView::can_go_back` and `WebView::go_back`. The page's session history is navigation state like any other, so the rule of the previous section applies to it: persisting only the current URL restores the right page with nothing behind it, and the first back after a relaunch leaves the app. Persist the engine's session state next to the URL — with ltk-webkit, `WebView::session_state` returns it as bytes and `WebView::restore_session_state` brings it back into a view created with an empty URL. Because `XF86Back` never reaches `on_raw_key`, a host that forwards raw keys into the view does not have to filter it out. ## Apps whose back stack lives elsewhere Some hosts cannot answer ahead of time. An Android app running in an embedded runtime keeps its activities, fragments and back callbacks in its own code; whether back "does something" is only known by delivering it. Such a host hands the request over and returns [`BackResponse::Consumed`]: ```rust # use ltk::BackResponse; # #[ derive( Clone ) ] enum Msg {} # struct Guest; impl Guest { fn deliver_back_key( &mut self ) {} } # struct Host { guest: Guest } # impl Host { fn on_back( &mut self ) -> BackResponse { self.guest.deliver_back_key(); BackResponse::Consumed } # } ``` The guest then decides everything, including leaving: when its last activity finishes, the host returns `true` from [`App::requested_exit`]. `Consumed` tells the runtime not to apply `back_at_root` on top of that. Steps 1 and 2 of the order still run first. For them to match the guest's expectations, project the guest's cancellable dialogs as overlays with an `on_dismiss`, and its non-cancellable ones without. ## At the root When `on_back` answers `Unhandled` and no legacy key binding takes the request, [`App::back_at_root`] decides: - **`Minimize`** (default) — `xdg_toplevel.set_minimized`. The window leaves the screen, the process and its state stay, and the compositor shows whatever was behind. This is what a root activity does on Android since version 12, and it suits anything the user comes back to: a browser keeps its tabs, a player keeps playing. - **`Close`** — close the window through [`App::on_close_requested`], which can still veto. For apps with nothing worth keeping alive. - **`Ignore`** — do nothing. For kiosk-style apps that must not be left by accident. It only applies to [`ShellMode::Window`] surfaces. Layer-shell and session-lock surfaces never react to back at the root: a panel or a lock screen that minimised itself would be a bug. Since an app minimised from the root is not closed, no close-time save happens at that moment; the periodic save (every ~30 s when the bytes changed) and the `SIGTERM` / `SIGINT` save still cover it. ## Who owns what: app, toolkit, compositor There are two stacks, with two owners, as on Android: - The **navigation stack** — where the user has been *inside* one app — belongs to the app's model. Nothing outside the process sees it. - The **task stack** — which window was in front of which — belongs to the compositor. They meet in one standard request: when the navigation stack runs out, ltk asks to be minimised and the compositor shows the next entry of *its* stack. No custom protocol carries navigation state to the compositor, which keeps the scheme working for clients that are not ltk apps. The compositor has its own layers above every client and closes them before sending the key at all: its app switcher and panels, and the on-screen keyboard. That is why step 2 above is phrased as a fallback: under a compositor that retires the keyboard itself, the app only sees the *next* request. ## Checklist - [ ] Every piece of UI state is sorted into transient or navigation (the table above). - [ ] `on_back` derives its answer from the model; nothing is pushed or popped to "remember" what to undo. - [ ] `on_back` checks layers top-most first and ends in `BackResponse::Unhandled` at the root. - [ ] `save_state` persists the navigation state — the same object `on_back` reads — and nothing transient or secret. - [ ] Routes carry identifiers, not loaded data. - [ ] In-app back arrows send the same message `on_back` returns. - [ ] Dialogs have a `cancel`; the app has no `Escape` / back code for them. - [ ] `Escape` is not used to navigate. - [ ] `back_at_root` is a deliberate choice; the default `Minimize` keeps the app alive. - [ ] A host that forwards raw keys to an embedded view relies on `XF86Back` not arriving there. [`App::on_back`]: ../src/app.rs [`App::back_at_root`]: ../src/app.rs [`App::save_state`]: ../src/app.rs [`App::on_key_with_modifiers`]: ../src/app.rs [`App::on_close_requested`]: ../src/app.rs [`App::requested_exit`]: ../src/app.rs [`BackResponse::Consumed`]: ../src/app.rs [`ShellMode::Window`]: ../src/app.rs [`NavStack`]: ../src/nav_stack.rs