Back as a navigation request: App::on_back, BackAtRoot, NavStack and docs/navigation.md; renew the text-input enable on a press; fit an oversized window_size_hint to the screen
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

Three changes that came out of making the shell's back button work across Eydos the way it does on Android. The first is the feature; the other two are gaps it exposed.
Back is no longer Escape. forge turns its back gesture and button into the `XF86Back` key for the focused client, and ltk treated that key as an alias of `Escape`: close transient UI, else drop the focus and reach `on_key_with_modifiers`. That is "cancel", and it is all an app could get: nothing navigated, and nothing ever left the app. `XF86Back` is now a back request with its own order, one layer per request, top-most first: transient UI the runtime owns (open xdg-popup overlays, the built-in context menu, a text selection, the top-most pressable carrying an `on_escape`, i.e. a dialog's cancel), then text-input focus, then the new `App::on_back`, then `on_key_with_modifiers( XF86_Back )` so an app that bound the key itself keeps receiving it (crustace does, for its launcher), then `App::back_at_root`. The first step is what Escape already did; it moves out of `handle_key_escape` into `AppData::dismiss_transient` in the new `input/keyboard/back.rs`, which both paths call, so Escape behaves exactly as before. Text focus comes before the app on purpose: with a keyboard covering half the screen, back means "put the keyboard away", and navigating instead would discard what the user was typing. A compositor that folds its keyboard itself, as forge now does, only lets the next request through, which makes this step the fallback for the ones that do not.
`App::on_back` returns a `BackResponse`: `Message( msg )` with the message that undoes the top-most piece of navigation state, `Unhandled` at the root, or `Consumed` for a host whose back stack lives elsewhere and cannot be inspected ahead of time. `Consumed` exists for rustdroid: an Android app keeps its activities, fragments and back callbacks in its own code, so the host can only hand the request over. `App::back_at_root` then picks `Minimize` (the default), `Close` (through `on_close_requested`, which can still veto) or `Ignore`. Minimising is what a root activity does on Android since version 12 and what suits an app the user comes back to: a browser keeps its tabs, a call goes on. It is also the single point where the two stacks meet: the navigation stack belongs to the app's model, the task stack to the compositor, and `xdg_toplevel.set_minimized` is the one standard request between them, so no custom protocol carries navigation state out of the process and clients that are not ltk apps keep working. Only `ShellMode::Window` surfaces react at the root; a panel or a lock screen that minimised itself would be a bug.
Three consequences of back being a navigation request rather than input: the key leaves the default `App::key_repeats` set, since a held button would empty the whole stack; it is no longer reported through `App::on_raw_key`, so a host forwarding raw keys into an embedded web view or guest toolkit stops leaking it as a keystroke; and the mouse's back button (`BTN_SIDE` / `BTN_BACK`) enters through the same `handle_back`. `focused_text_input` becomes `pub( crate )` for the new module.
The answer is derived, not recorded, and that is the design decision worth writing down. The runtime keeps no navigation stack of its own. Its entries would have to be messages, which are not serialisable (the trait puts no serde bound on `Message`, by design), so it could not survive a relaunch; and a list of "things to undo" drifts the first time the user leaves a screen some other way, through its own close button or a link, leaving a back press that does nothing visible. An answer computed from the model cannot disagree with it. This is also what ties back to the session persistence added earlier: navigation state is both what `on_back` reads and what `save_state` should persist, and keeping the two the same is what makes back retrace the same places after a session restore or a crash, with nothing extra to serialise. Persist less and a restored app opens on the right screen with nothing behind it; persist more and it restores a menu the user had closed.
`NavStack<R>` packages that rule for the common case, an app made of screens. It is a plain value the app keeps in its model, never seen by the runtime: `push`, `pop`, `replace` (the sideways move back should not retrace), `pop_to_root`, `reset`, `current`, `current_mut`, `iter`, `depth`, `can_pop`, and `back( msg )`, which answers `on_back` directly. It is stored as a root plus the routes above it rather than as a `Vec`, so "never empty" holds by construction, including after deserialising an old or damaged state file, and it derives serde whenever `R` does. Six unit tests cover it, a serde round trip among them, and its rustdoc example is a complete `App` where one object answers `on_back`, `save_state` and `restore_state`.
Documentation, since the point is for app authors to take navigation state into account. The new `docs/navigation.md` is the reference: transient UI versus navigation state and the test that separates them, the back order and the reason for each step, why the answer is derived, how it lines up with session state, `NavStack`, apps that embed a web view (whose page history is navigation state too, to be persisted with ltk-webkit's `session_state`), hosts with a foreign back stack, the root, who owns which stack, and an author's checklist. `architecture.md` gains a group in the trait surface and a paragraph in the state patterns, `onboarding.md` and the README index link to it, and in the cookbook the multi-screen recipe now uses `NavStack` with `on_back` and persists the stack without serde, which keeps it compiled by the Markdown doctest harness (that harness links `ltk` alone, so the serde example in `navigation.md` is marked `ignore` and compiled as `NavStack`'s own doctest instead). `BackResponse`, `BackAtRoot` and `NavStack` are exported at the crate root and in the `ltk::window` facade.
Behaviour change for existing apps, also recorded under Changed: a window app that implements neither `on_back` nor the key is now minimised on back at its root, where nothing happened before; `BackAtRoot::Ignore` restores that. With a text input focused, back only drops the focus and the app sees the next request, where it used to get the key in the same press.
Renew the text-input enable on a press. Moving the focus between two text fields, or pressing the one already focused, only committed the refreshed content type on the existing `zwp_text_input_v3`. `activate_text_input` now sends `enable` first, as GTK does when a focused entry is clicked. A compositor that folded its on-screen keyboard while the field kept the focus, which is what back now does, has no other way to learn that the user wants it again: the text-input never stopped being active, so there was nothing for it to observe. ltk sends neither surrounding text nor a cursor rectangle, so the only state `enable` resets is the content type set right after it.
Fit an oversized `window_size_hint` to the screen. The hint is pinned as `min_size == max_size` before the first commit, when no output is known yet, so an app asking for a phone-shaped 720x1440 window on a 1080p desktop got a toplevel taller than the screen; rustdroid carried a dead branch that tried to avoid this from a screen size nothing could give it. The first configure is the earliest point the hint can be checked, and the protocol has the right input for it: `xdg_toplevel.configure_bounds`, exposed by sctk as `WindowConfigure::suggested_bounds` and ignored until now. The new `event_loop/window_fit.rs` holds `fit_within`, which scales a size down keeping its aspect ratio until it fits and leaves one that fits untouched (four unit tests), and `AppData::fit_size_hint`, run once behind the new `pending_size_hint_fit` latch: it uses the compositor's bounds when sent and otherwise 90 % of the first output's logical size, since panels and decorations are unknown from the client side. When the hint has to shrink, `min_size` (and `max_size` for a non-resizable window) is re-pinned to the fitted size, which is kept in `fitted_size_hint` and replaces the app's hint as the fallback for later configures that carry no size; without that the window would grow back on the next one. On that one configure the fitted size wins over the compositor's `new_size`, which was derived from the oversized pin. `App::window_size_hint` documents the rule with the worked example (720x1440 opens as 486x972 on a 1080p output).
The back order needs a live Wayland connection to exercise and has no test; the unit tests cover `NavStack` and `fit_within`.
This commit is contained in:
2026-09-20 23:23:26 +02:00
parent 2252a63f16
commit 96a9e88051
21 changed files with 916 additions and 63 deletions
+8
View File
@@ -99,6 +99,12 @@ In practice, that model is easiest to adopt in three steps:
- `on_key` / `on_key_with_modifiers` — global hotkeys. Characters, Backspace and Delete stay in a focused text input and reach the app otherwise, a focused button or pressable included; Return and Space go to the focused widget's submit or press message when it has one; arrows are offered to the app first and fall back to the text cursor or list navigation.
- `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity.
**Implement when the app has more than one place to be**
- `on_back()` → `BackResponse` — the system back button or gesture (`XF86Back`) and the mouse's back button. Return the message that undoes the top-most piece of *navigation state*, derived from the model; `Unhandled` at the root. The runtime closes its own transient UI and drops text focus before asking. `Escape` is a separate request that never navigates.
- `back_at_root()` → `BackAtRoot::Minimize` (default) / `Close` / `Ignore` — what back does once nothing is left.
- `NavStack<R>` — the ready-made navigation state for an app made of screens; the same value answers `on_back` and is what `save_state` persists. See [`docs/navigation.md`](./navigation.md).
**Implement for animations and focus**
- `is_animating()` — return `true` while a tween is running; the loop redraws on every compositor frame callback (~60 Hz on a typical display, capped at ~30 Hz on the software backend — see *Performance*).
@@ -264,6 +270,8 @@ A four-button demo can keep all state in one struct and one flat `Msg` enum. Any
**External state via channel + poll.** Anything that blocks (D-Bus, files, network, IPC) lives on a background thread. At startup save the `ChannelSender<Msg>` from `set_channel_sender`, hand a clone to the worker, and have the worker push messages back. `poll_external()` is the place for non-blocking `try_recv()` against in-process receivers (e.g. `mpsc`/`crossbeam` channels) or for expiry checks like "is this notification past its TTL".
**Navigation state is one value.** The part of the model that says where the user is — current screen, open panel, position in a history — is what the back button walks (`App::on_back`) and what a relaunch restores (`App::save_state`). Keep it as one value (a `NavStack<Route>`, an enum, a small struct) rather than booleans scattered across screens, derive the back answer from it instead of recording things to undo, and persist that same value. Transient UI — menus, popups, dialogs, text focus — stays out of both: the runtime closes it on back and on `Escape`, and nobody expects it after a relaunch. [`docs/navigation.md`](./navigation.md) is the full treatment.
**Stable widget ids only when you need to programmatically focus them.** `WidgetId` is an opt-in tag on a widget that pairs with `App::take_focus_request()`. Don't decorate every widget; tag the one input you want to autofocus on screen entry.
Again, the simplest progression is:
+52 -19
View File
@@ -738,11 +738,13 @@ opt-in needed beyond the widget being interactive.
When the app has more than ~30 message variants it is time to split by
screen. Each screen owns its sub-state and sub-message, and the
top-level enum wraps them.
top-level enum wraps them. Which screen is showing is the app's
*navigation state*: a [`NavStack`](./navigation.md#navstack) holds it, so
the system back button and a relaunch both work from the same value.
```rust,no_run
# use ltk::{ column, text, App, Element };
# #[ derive( Clone, Copy ) ] enum Screen { Home, Settings, About }
# use ltk::{ column, text, App, BackResponse, Element, NavStack };
# #[ derive( Clone, Copy, PartialEq ) ] enum Screen { Home, Settings, About }
# #[ derive( Clone ) ] enum HomeMsg {}
# #[ derive( Clone ) ] enum SettingsMsg {}
# struct HomeState;
@@ -756,45 +758,64 @@ top-level enum wraps them.
#[derive(Clone)]
enum AppMsg
{
Nav( Screen ),
Open( Screen ),
Back,
Home( HomeMsg ),
Settings( SettingsMsg ),
}
struct AppState
{
current: Screen,
nav: NavStack<Screen>,
home: HomeState,
settings: SettingsState,
}
impl Screen
{
fn name( self ) -> &'static str
{
match self { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" }
}
fn parse( name: &str ) -> Option<Self>
{
[ Screen::Home, Screen::Settings, Screen::About ].into_iter().find( |s| s.name() == name )
}
}
impl App for AppState
{
type Message = AppMsg;
fn app_id( &self ) -> &str { "net.example.MultiScreen" }
// Persist only the routing; each screen would add its own line.
// Persist the whole route stack, root first, one screen per line:
// after a restore, back retraces the same screens. With serde in the
// app, `serde_json::to_vec( &self.nav )` does the same in one call.
fn save_state( &self ) -> Option<Vec<u8>>
{
let s = match self.current { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" };
Some( s.as_bytes().to_vec() )
let lines: Vec<&str> = self.nav.iter().map( |s| s.name() ).collect();
Some( lines.join( "\n" ).into_bytes() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
match state.as_slice()
{
b"home" => self.current = Screen::Home,
b"settings" => self.current = Screen::Settings,
b"about" => self.current = Screen::About,
_ => {}
}
let Ok( text ) = String::from_utf8( state ) else { return };
let Some( screens ) = text.lines().map( Screen::parse ).collect::<Option<Vec<_>>>() else { return };
let mut screens = screens.into_iter();
let Some( root ) = screens.next() else { return };
self.nav.reset( root );
for screen in screens { self.nav.push( screen ); }
}
// The same value answers the system back button.
fn on_back( &mut self ) -> BackResponse<AppMsg> { self.nav.back( AppMsg::Back ) }
fn view( &self ) -> Element<AppMsg>
{
let body = match self.current
let current = *self.nav.current();
let body = match current
{
Screen::Home => home_view( &self.home ).map( AppMsg::Home ),
Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ),
@@ -802,7 +823,7 @@ impl App for AppState
};
column()
.push( nav_bar( self.current ) )
.push( nav_bar( current ) )
.push( body )
.into()
}
@@ -811,7 +832,8 @@ impl App for AppState
{
match msg
{
AppMsg::Nav( s ) => self.current = s,
AppMsg::Open( s ) => self.nav.push( s ),
AppMsg::Back => { self.nav.pop(); }
AppMsg::Home( m ) => home_update( &mut self.home, m ),
AppMsg::Settings( m ) => settings_update( &mut self.settings, m ),
}
@@ -834,7 +856,13 @@ In a real project each screen lives in its own file (`src/home.rs`,
that `doctest-md` can typecheck the dispatch without needing one file
per screen.
**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns).
An in-app back arrow sends `AppMsg::Back`, the same message `on_back`
returns, so the arrow and the system button cannot drift apart. A
sideways move that back should not retrace — switching between sibling
tabs — uses `self.nav.replace( s )` instead of `push`.
**See also**: [`docs/navigation.md`](./navigation.md) for the model
behind `on_back`, and [`docs/architecture.md`](./architecture.md#larger-state-patterns).
---
@@ -915,6 +943,11 @@ never exited cleanly.
Shell components (layer-shell panels, lock screens) have no toplevel and
are never persisted: return `None` and leave `restore_state` empty.
What to persist is the app's *navigation state* — the same state
`App::on_back` reads — so that back retraces the same places after a
restore. [`docs/navigation.md`](./navigation.md#back-and-session-state-are-the-same-state)
explains the rule.
**See also**: `App::save_state` / `App::restore_state` rustdoc for the
full contract, and [`docs/architecture.md`](./architecture.md#known-gaps-and-non-goals) for the protocol status.
+274
View File
@@ -0,0 +1,274 @@
# 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<Vec<u8>> { None }
# fn restore_state( &mut self, _: Vec<u8> ) {}
# fn view( &self ) -> Element<Msg> { text( "" ).into() }
# fn update( &mut self, _: Msg ) {}
fn on_back( &mut self ) -> BackResponse<Msg>
{
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<R>`] 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<Route> }
impl App for Mail
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Mail" }
fn view( &self ) -> Element<Msg>
{
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<Msg> { self.nav.back( Msg::Back ) }
// …and is what survives a relaunch.
fn save_state( &self ) -> Option<Vec<u8>> { serde_json::to_vec( &self.nav ).ok() }
fn restore_state( &mut self, state: Vec<u8> )
{
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<u8> }
# impl Shop {
fn on_back( &mut self ) -> BackResponse<Msg>
{
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<Msg>
{
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<Msg>
{
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<R>`]: ../src/nav_stack.rs
+6
View File
@@ -451,6 +451,12 @@ because the compositor restores them through `xdg-session-management-v1`.
Shell components (panels, lock screens) return `None` and no-op. See the
cookbook recipe *Surviving relaunch: session state* for a worked example.
What those bytes should hold is the app's *navigation state* — which screen,
which panel, how far into a history — because that is also what the system
back button walks through `App::on_back`. Keeping the two the same makes back
retrace the same places after a restore. An app with more than one screen
should read [`docs/navigation.md`](./navigation.md) before growing its second.
## Current assumptions and rough edges
This repo is usable, but a few current behaviours are worth knowing up front: