Files
ltk/docs/navigation.md
T
admin 96a9e88051
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
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
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`.
2026-09-20 23:23:26 +02:00

15 KiB

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 first for the view / update model this builds on.

Table of contents


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) 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.

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:

# 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 and the cookbook recipe Surviving relaunch). 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 ignored here only because the Markdown doctest harness links ltk alone, without serde. The cookbook recipe Multi-screen app persists a NavStack without serde.

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:

# 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:

# 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:

# 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.