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
+7
View File
@@ -6,6 +6,10 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Added ### Added
- **`App::on_back()` / `BackResponse`** — the app's answer to a back request (the shell's back button or gesture, delivered as `XF86Back`, or a mouse's `BTN_SIDE` / `BTN_BACK`). Back peels one layer per request: transient UI the runtime owns (xdg-popups, context menu, text selection, a dialog's `on_escape`), then text-input focus, then `on_back`, then `on_key_with_modifiers( XF86_Back )` for apps that bound the key themselves, then `back_at_root`. `BackResponse::Message` carries the message that undoes the top-most piece of navigation state, `Consumed` is for hosts whose back stack lives elsewhere (an embedded Android runtime), `Unhandled` means the root. Default `Unhandled`.
- **`App::back_at_root()` / `BackAtRoot`** — what back does with nothing left to go back to: `Minimize` (default; `xdg_toplevel.set_minimized`, the app and its state stay alive), `Close` (through `on_close_requested`) or `Ignore`. Only `ShellMode::Window` surfaces react; layer-shell and session-lock surfaces never do.
- **`NavStack<R>`** — navigation state for an app made of screens, kept in the app's model: `push` / `pop` / `replace` / `pop_to_root` / `reset` / `current` / `iter`, never empty, serialisable whenever `R` is. `back( msg )` answers `on_back`, and persisting the same value from `save_state` makes back retrace the same screens after a session restore.
- **`docs/navigation.md`** — the navigation-state model: transient UI versus navigation state, the back order and its rationale, why the answer is derived instead of recorded, how it lines up with `save_state`, embedded web views, foreign back stacks, and an author's checklist. `architecture.md`, `onboarding.md` and the cookbook's multi-screen and session-state recipes point to it; the multi-screen recipe now uses `NavStack`.
- **`App::claims_raw_touch_at( x, y )`** — per-press variant of `claims_raw_touch`: a finger landing where the app returns `true` rides the raw `on_touch_*` stream through the auxiliary-slot path and clears the widget focus, while fingers landing elsewhere keep driving the gesture machine. Lets an app embed a self-contained input consumer (a WebView) next to regular widgets. Only consulted for the main surface; default `false`. - **`App::claims_raw_touch_at( x, y )`** — per-press variant of `claims_raw_touch`: a finger landing where the app returns `true` rides the raw `on_touch_*` stream through the auxiliary-slot path and clears the widget focus, while fingers landing elsewhere keep driving the gesture machine. Lets an app embed a self-contained input consumer (a WebView) next to regular widgets. Only consulted for the main surface; default `false`.
- **`App::fullscreen()`** — desired fullscreen state of the toplevel at runtime, polled after every batch of `update`s; a change is forwarded as `xdg_toplevel.set_fullscreen` / `unset_fullscreen`. The built-in titlebar is not painted while the compositor reports the window as fullscreen. Default `None` (window state left alone). - **`App::fullscreen()`** — desired fullscreen state of the toplevel at runtime, polled after every batch of `update`s; a change is forwarded as `xdg_toplevel.set_fullscreen` / `unset_fullscreen`. The built-in titlebar is not painted while the compositor reports the window as fullscreen. Default `None` (window state left alone).
- **`PatternGrid::size( impl Into<Length> )`** — explicit side for the square grid, resolved through `Canvas::resolve_geom` like the other geometry overrides; without it the theme's fluid default still applies. A lock screen can now size the grid from the surface (a share of the width in portrait, of the free height in landscape) instead of being capped at the default. - **`PatternGrid::size( impl Into<Length> )`** — explicit side for the square grid, resolved through `Canvas::resolve_geom` like the other geometry overrides; without it the theme's fluid default still applies. A lock screen can now size the grid from the surface (a share of the width in portrait, of the free height in landscape) instead of being capped at the default.
@@ -40,6 +44,9 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Changed ### Changed
- **`XF86Back` is no longer an alias of `Escape`.** It used to run the Escape path: close transient UI, else drop the focus and reach `on_key_with_modifiers`. It is now a back request with its own order (see `App::on_back` under Added). Visible differences for an existing app: 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; the key is no longer reported through `App::on_raw_key`, so hosts forwarding raw keys into an embedded view stop leaking it to the page; it no longer auto-repeats by default (`App::key_repeats`); and a `ShellMode::Window` app that handles neither `on_back` nor the key is **minimised** on back at its root, where nothing happened before — return `BackAtRoot::Ignore` from `back_at_root` to keep the old behaviour. An app that binds `XF86_Back` in `on_key` / `on_key_with_modifiers` keeps receiving it. `Escape` itself is unchanged; its transient-UI step is now shared with back as `AppData::dismiss_transient`.
- **A `window_size_hint` larger than the screen is shrunk to fit.** 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. On the first configure the runtime now fits the hint, keeping its aspect ratio, inside the compositor's `xdg_toplevel.configure_bounds` (`WindowConfigure::suggested_bounds`) or, when it sends none, inside 90 % of the first output's logical size; `min_size` (and `max_size` for a non-resizable window) is re-pinned to the fitted size, which also replaces the hint as the fallback when later configures carry no size. A hint that fits is untouched. `event_loop/window_fit.rs`, with unit tests for the fitting rule.
- **A press on a text field renews the text-input `enable`.** Moving the focus between two text fields, or pressing the one already focused, used to commit only the refreshed content type on the existing `zwp_text_input_v3`. It 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 (the back button does) has no other way to learn that the user wants it again, since the text-input never stopped being active. ltk sends neither surrounding text nor a cursor rectangle, so the state `enable` resets is only the content type set right after it.
- **The software backend's ARGB8888 conversion works on whole pixels.** `SoftwareCanvas::write_to_wayland_buf` swapped red and blue with a byte-by-byte loop over the frame; it now reads each pixel as a `u32`, exchanges the two channels with masks and shifts, and lets the compiler vectorise it. Compositors that offer `ABGR8888` were never affected. - **The software backend's ARGB8888 conversion works on whole pixels.** `SoftwareCanvas::write_to_wayland_buf` swapped red and blue with a byte-by-byte loop over the frame; it now reads each pixel as a `u32`, exchanges the two channels with masks and shifts, and lets the compiler vectorise it. Compositors that offer `ABGR8888` were never affected.
- **Breaking: `App::app_id`, `App::save_state` and `App::restore_state` are new mandatory trait methods** — every implementor must add them (shell components: constant id, `None`, no-op). `xdg_toplevel.set_app_id` and the AccessKit application name now come from `app_id()` (previously `"ltk"` / `"ltk-app"`); the `app_id` element of the deprecated `window_config` tuple is ignored. Crate version bumped to 0.3.0. - **Breaking: `App::app_id`, `App::save_state` and `App::restore_state` are new mandatory trait methods** — every implementor must add them (shell components: constant id, `None`, no-op). `xdg_toplevel.set_app_id` and the AccessKit application name now come from `app_id()` (previously `"ltk"` / `"ltk-app"`); the `app_id` element of the deprecated `window_config` tuple is ignored. Crate version bumped to 0.3.0.
- **`Button::icon_size` takes `impl Into<Length>`** and resolves through `Canvas::resolve_geom`, joining `font_size` / `height` / `width`. A bare `f32` still means `Length::px` — every existing call keeps its exact size — but a caller can now pass `Length::widget( n )` to have an icon button follow the widget-scaling mode the way stock icons do. Without it an explicit `icon_size` was the one geometry setter that ignored the mode, so a 21 px back arrow sat next to a `list_item` chevron that fluid sizing had grown well past 21 and looked visibly smaller. - **`Button::icon_size` takes `impl Into<Length>`** and resolves through `Canvas::resolve_geom`, joining `font_size` / `height` / `width`. A bare `f32` still means `Length::px` — every existing call keeps its exact size — but a caller can now pass `Length::widget( n )` to have an icon button follow the widget-scaling mode the way stock icons do. Without it an explicit `icon_size` was the one geometry setter that ignored the mode, so a 21 px back arrow sat next to a `list_item` chevron that fluid sizing had grown well past 21 and looked visibly smaller.
+1
View File
@@ -340,6 +340,7 @@ on the roadmap.
| [`docs/widgets.md`](docs/widgets.md) | Per-widget catalogue: what each one is, when to use it, minimal example, see-also. | | [`docs/widgets.md`](docs/widgets.md) | Per-widget catalogue: what each one is, when to use it, minimal example, see-also. |
| [`docs/theming.md`](docs/theming.md) | JSON theme schema, slot conventions, runtime APIs. | | [`docs/theming.md`](docs/theming.md) | JSON theme schema, slot conventions, runtime APIs. |
| [`docs/backends.md`](docs/backends.md) | Software/GLES capability matrix — what renders identically, what degrades, what is GPU-only. | | [`docs/backends.md`](docs/backends.md) | Software/GLES capability matrix — what renders identically, what degrades, what is GPU-only. |
| [`docs/navigation.md`](docs/navigation.md) | Navigation state and the back button: what back closes and in which order, why the answer is derived from the model, how it lines up with session state, `NavStack`. |
| [`docs/cookbook.md`](docs/cookbook.md) | Concrete recipes — slide-in panels, password fields, runtime theme toggle, channel-driven state, embedding without `ltk::run`. | | [`docs/cookbook.md`](docs/cookbook.md) | Concrete recipes — slide-in panels, password fields, runtime theme toggle, channel-driven state, embedding without `ltk::run`. |
| `cargo doc --open` | Per-item rustdoc for the public API. | | `cargo doc --open` | Per-item rustdoc for the public API. |
| [`CHANGELOG.md`](CHANGELOG.md) | What changed in each release, and what is pending unreleased. | | [`CHANGELOG.md`](CHANGELOG.md) | What changed in each release, and what is pending unreleased. |
+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. - `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. - `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** **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*). - `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". **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. **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: 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 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 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 ```rust,no_run
# use ltk::{ column, text, App, Element }; # use ltk::{ column, text, App, BackResponse, Element, NavStack };
# #[ derive( Clone, Copy ) ] enum Screen { Home, Settings, About } # #[ derive( Clone, Copy, PartialEq ) ] enum Screen { Home, Settings, About }
# #[ derive( Clone ) ] enum HomeMsg {} # #[ derive( Clone ) ] enum HomeMsg {}
# #[ derive( Clone ) ] enum SettingsMsg {} # #[ derive( Clone ) ] enum SettingsMsg {}
# struct HomeState; # struct HomeState;
@@ -756,45 +758,64 @@ top-level enum wraps them.
#[derive(Clone)] #[derive(Clone)]
enum AppMsg enum AppMsg
{ {
Nav( Screen ), Open( Screen ),
Back,
Home( HomeMsg ), Home( HomeMsg ),
Settings( SettingsMsg ), Settings( SettingsMsg ),
} }
struct AppState struct AppState
{ {
current: Screen, nav: NavStack<Screen>,
home: HomeState, home: HomeState,
settings: SettingsState, 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 impl App for AppState
{ {
type Message = AppMsg; type Message = AppMsg;
fn app_id( &self ) -> &str { "net.example.MultiScreen" } 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>> fn save_state( &self ) -> Option<Vec<u8>>
{ {
let s = match self.current { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" }; let lines: Vec<&str> = self.nav.iter().map( |s| s.name() ).collect();
Some( s.as_bytes().to_vec() ) Some( lines.join( "\n" ).into_bytes() )
} }
fn restore_state( &mut self, state: Vec<u8> ) fn restore_state( &mut self, state: Vec<u8> )
{ {
match state.as_slice() let Ok( text ) = String::from_utf8( state ) else { return };
{ let Some( screens ) = text.lines().map( Screen::parse ).collect::<Option<Vec<_>>>() else { return };
b"home" => self.current = Screen::Home, let mut screens = screens.into_iter();
b"settings" => self.current = Screen::Settings, let Some( root ) = screens.next() else { return };
b"about" => self.current = Screen::About, 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> 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::Home => home_view( &self.home ).map( AppMsg::Home ),
Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ), Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ),
@@ -802,7 +823,7 @@ impl App for AppState
}; };
column() column()
.push( nav_bar( self.current ) ) .push( nav_bar( current ) )
.push( body ) .push( body )
.into() .into()
} }
@@ -811,7 +832,8 @@ impl App for AppState
{ {
match msg 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::Home( m ) => home_update( &mut self.home, m ),
AppMsg::Settings( m ) => settings_update( &mut self.settings, 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 that `doctest-md` can typecheck the dispatch without needing one file
per screen. 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 Shell components (layer-shell panels, lock screens) have no toplevel and
are never persisted: return `None` and leave `restore_state` empty. 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 **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. 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 Shell components (panels, lock screens) return `None` and no-op. See the
cookbook recipe *Surviving relaunch: session state* for a worked example. 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 ## Current assumptions and rough edges
This repo is usable, but a few current behaviours are worth knowing up front: This repo is usable, but a few current behaviours are worth knowing up front:
+118 -7
View File
@@ -334,6 +334,35 @@ pub struct SubsurfaceSpec<Message: Clone>
pub gpu: bool, pub gpu: bool,
} }
/// What an app did with a back request. Returned by [`App::on_back`].
#[ derive( Debug, Clone, PartialEq ) ]
pub enum BackResponse<Message>
{
/// There was something to go back from: deliver this message, which
/// undoes the top-most piece of navigation state.
Message( Message ),
/// The request was handled outside the message loop — for hosts whose
/// back stack lives elsewhere (an embedded Android runtime, a guest
/// toolkit) and cannot be inspected ahead of time.
Consumed,
/// Nothing to go back to. The runtime applies [`App::back_at_root`].
Unhandled,
}
/// What a back request does once the app has nothing left to go back to.
/// Returned by [`App::back_at_root`].
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum BackAtRoot
{
/// Minimise the window (`xdg_toplevel.set_minimized`); the app keeps
/// running and the compositor shows whatever was behind it.
Minimize,
/// Close the window, through [`App::on_close_requested`].
Close,
/// Do nothing.
Ignore,
}
/// Trait that application types must implement to integrate with ltk. /// Trait that application types must implement to integrate with ltk.
pub trait App: 'static pub trait App: 'static
{ {
@@ -597,6 +626,79 @@ pub trait App: 'static
self.on_key( keysym ) self.on_key( keysym )
} }
/// Called on a back request — the shell's back button or gesture
/// (delivered as the `XF86Back` key) or a mouse's back button — once
/// the runtime has nothing of its own left to close.
///
/// Back peels one layer per request, top-most first:
///
/// 1. transient UI the runtime owns — the built-in context menu, open
/// xdg-popup overlays, a text selection, the top-most pressable
/// carrying an `on_escape` (a [`dialog`](crate::dialog)'s cancel);
/// 2. text-input focus — dropping it also retires the on-screen
/// keyboard on compositors that did not close it themselves;
/// 3. **this hook** — the app's own navigation state;
/// 4. [`Self::on_key_with_modifiers`] with `Keysym::XF86_Back`, for
/// apps that predate this hook;
/// 5. [`Self::back_at_root`].
///
/// Return [`BackResponse::Message`] with the message that undoes the
/// top-most piece of *navigation state* — the part of the model that
/// says where the user is: the open panel, the current screen, the
/// position in a history. Derive the answer from the model every time
/// instead of keeping a separate list of things to undo: a list drifts
/// the first time the user leaves a screen some other way (its own
/// close button, a link), while the model cannot disagree with itself.
///
/// Navigation state is also what [`Self::save_state`] should persist.
/// Keeping the two the same is what makes back behave identically
/// after a session restore or a crash: the restored model yields the
/// same answers here, with nothing extra to serialise.
/// [`NavStack`](crate::NavStack) packages that pattern for the common
/// multi-screen app. `docs/navigation.md` covers the model at length.
///
/// `Escape` is a different request: it only runs step 1, drops the
/// focus and reaches [`Self::on_key_with_modifiers`]. It never
/// navigates and never leaves the app.
///
/// ```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,
/// }
/// }
/// }
/// ```
fn on_back( &mut self ) -> BackResponse<Self::Message> { BackResponse::Unhandled }
/// What a back request does when [`Self::on_back`] had nothing to go
/// back to. Default: [`BackAtRoot::Minimize`] — the window leaves the
/// screen and the app keeps its state, the way a root activity behaves
/// on Android.
///
/// Only applies to [`ShellMode::Window`] surfaces; layer-shell and
/// session-lock surfaces never react to back at the root.
fn back_at_root( &self ) -> BackAtRoot { BackAtRoot::Minimize }
/// Called when a sufficient upward swipe gesture is detected. /// Called when a sufficient upward swipe gesture is detected.
/// ///
/// Return a message to handle the swipe (e.g., opening an overview). /// Return a message to handle the swipe (e.g., opening an overview).
@@ -856,6 +958,9 @@ pub trait App: 'static
/// ///
/// This bypass exists for those embeddings: it fires *in addition* /// This bypass exists for those embeddings: it fires *in addition*
/// to the normal callbacks; consume or ignore at will. /// to the normal callbacks; consume or ignore at will.
///
/// `XF86_Back` is not reported here: it is a navigation request, not
/// input for the embedded system, and goes through [`Self::on_back`].
fn on_raw_key( &mut self, _keysym: Keysym, _keycode: u32, _pressed: bool, _ctrl: bool, _shift: bool ) {} fn on_raw_key( &mut self, _keysym: Keysym, _keycode: u32, _pressed: bool, _ctrl: bool, _shift: bool ) {}
/// Called when the surface is (re-)configured with a new size. /// Called when the surface is (re-)configured with a new size.
@@ -921,16 +1026,16 @@ pub trait App: 'static
/// Decide whether a given key participates in held-key repeat. /// Decide whether a given key participates in held-key repeat.
/// ///
/// Default: every non-modifier key repeats *except* `Escape`, `Tab` /// Default: every non-modifier key repeats *except* `Escape`,
/// and `ISO_Left_Tab` — those drive one-shot UI semantics /// `XF86_Back`, `Tab` and `ISO_Left_Tab` — those drive one-shot UI
/// (dismiss / focus cycle) where a held key would multiply the /// semantics (dismiss / go back / focus cycle) where a held key would
/// effect in surprising ways. Override to widen or narrow the gate /// multiply the effect in surprising ways. Override to widen or
/// (a chess clock app, for example, might want even Tab to /// narrow the gate (a chess clock app, for example, might want even
/// repeat). /// Tab to repeat).
fn key_repeats( &self, keysym: Keysym ) -> bool fn key_repeats( &self, keysym: Keysym ) -> bool
{ {
!matches!( keysym, !matches!( keysym,
Keysym::Escape | Keysym::Tab | Keysym::ISO_Left_Tab, Keysym::Escape | Keysym::XF86_Back | Keysym::Tab | Keysym::ISO_Left_Tab,
) )
} }
@@ -1027,6 +1132,12 @@ pub trait App: 'static
/// point of view — if the compositor sends a different configure, the /// point of view — if the compositor sends a different configure, the
/// runtime adopts that size on the next frame. /// runtime adopts that size on the next frame.
/// ///
/// A hint larger than the screen is shrunk on the first configure,
/// keeping its aspect ratio: to the compositor's
/// `xdg_toplevel.configure_bounds` when it sends them, otherwise to
/// 90 % of the output. A phone-shaped 720x1440 hint therefore opens as
/// 486x972 on a 1080p desktop instead of running off its bottom edge.
///
/// Only applies to [`ShellMode::Window`] surfaces and to the /// Only applies to [`ShellMode::Window`] surfaces and to the
/// xdg-shell fallback path used when `wlr-layer-shell` is missing. /// xdg-shell fallback path used when `wlr-layer-shell` is missing.
/// Ignored for layer-shell surfaces, which use [`Self::layer_size`] /// Ignored for layer-shell surfaces, which use [`Self::layer_size`]
+4
View File
@@ -220,6 +220,10 @@ pub struct AppData<A: App>
/// First-configure latch: clears `max_size` after the compositor /// First-configure latch: clears `max_size` after the compositor
/// has honoured the pinned initial size. /// has honoured the pinned initial size.
pub pending_size_hint_unpin: bool, pub pending_size_hint_unpin: bool,
/// First-configure latch: check the size hint against the screen.
pub pending_size_hint_fit: bool,
/// The size hint once shrunk to fit the screen; replaces the app's.
pub fitted_size_hint: Option<( u32, u32 )>,
/// Main application surface. /// Main application surface.
pub main: SurfaceState<A::Message>, pub main: SurfaceState<A::Message>,
/// Auxiliary layer-shell surfaces keyed by their stable [`OverlayId`]. /// Auxiliary layer-shell surfaces keyed by their stable [`OverlayId`].
+11 -3
View File
@@ -300,9 +300,17 @@ impl<A: App> WindowHandler for AppData<A>
self.main.content_dirty = true; self.main.content_dirty = true;
self.view_dirty = true; self.view_dirty = true;
} }
let ( hint_w, hint_h ) = self.app.window_size_hint().unwrap_or( ( 800, 600 ) ); let fitted = self.fit_size_hint( window, configure.suggested_bounds );
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w ); let ( hint_w, hint_h ) = self.fitted_size_hint.or( self.app.window_size_hint() ).unwrap_or( ( 800, 600 ) );
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h ); let ( w, h ) = if fitted
{
( hint_w, hint_h )
} else {
(
configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w ),
configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h ),
)
};
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 ); window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
self.on_configure( w, h ); self.on_configure( w, h );
} }
+1
View File
@@ -17,6 +17,7 @@ pub( crate ) mod surface;
pub( crate ) mod text_editing; pub( crate ) mod text_editing;
pub( crate ) mod text_scale; pub( crate ) mod text_scale;
pub( crate ) mod tooltip; pub( crate ) mod tooltip;
pub( crate ) mod window_fit;
pub( crate ) mod error; pub( crate ) mod error;
pub( crate ) mod frame; pub( crate ) mod frame;
+3
View File
@@ -287,6 +287,7 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
let pending_fullscreen = app.start_fullscreen(); let pending_fullscreen = app.start_fullscreen();
let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some() && app.window_resizable(); let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some() && app.window_resizable();
let pending_size_hint_fit = !app.start_fullscreen() && app.window_size_hint().is_some();
let mut data = AppData let mut data = AppData
{ {
@@ -349,6 +350,8 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
pending_fullscreen, pending_fullscreen,
applied_fullscreen: Some( pending_fullscreen ), applied_fullscreen: Some( pending_fullscreen ),
pending_size_hint_unpin, pending_size_hint_unpin,
pending_size_hint_fit,
fitted_size_hint: None,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ), main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(), overlays: std::collections::HashMap::new(),
secondaries: Vec::new(), secondaries: Vec::new(),
+3 -2
View File
@@ -35,10 +35,11 @@ impl<A: App> AppData<A>
} }
( None, _ ) => ( None, _ ) =>
eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ), eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ),
// Focus moved between text fields: refresh the content type (e.g. // A press on another text field, or on the focused one again: renew
// flag a password field) without re-creating the object. // the enable so a compositor that folded its keyboard raises it.
( Some( _ ), Some( ti ) ) => ( Some( _ ), Some( ti ) ) =>
{ {
ti.enable();
ti.set_content_type( hint, purpose ); ti.set_content_type( hint, purpose );
ti.commit(); ti.commit();
} }
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Keeps [`App::window_size_hint`](crate::App::window_size_hint) inside the screen.
use smithay_client_toolkit::shell::xdg::window::Window;
use crate::app::App;
use super::AppData;
/// Share of an output a window may take when the compositor sent no `configure_bounds`.
pub( crate ) const OUTPUT_SHARE: f32 = 0.9;
/// `size` scaled down, keeping its aspect ratio, until it fits in `bounds`.
pub( crate ) fn fit_within( size: ( u32, u32 ), bounds: ( u32, u32 ) ) -> ( u32, u32 )
{
let ( w, h ) = size;
let ( bw, bh ) = bounds;
if w == 0 || h == 0 || bw == 0 || bh == 0 { return size; }
if w <= bw && h <= bh { return size; }
let scale = ( bw as f32 / w as f32 ).min( bh as f32 / h as f32 );
( ( ( w as f32 * scale ) as u32 ).max( 1 ), ( ( h as f32 * scale ) as u32 ).max( 1 ) )
}
impl<A: App> AppData<A>
{
/// On the first configure, shrink a size hint larger than the screen. Returns whether it did.
pub( crate ) fn fit_size_hint( &mut self, window: &Window, suggested: Option<( u32, u32 )> ) -> bool
{
if !std::mem::take( &mut self.pending_size_hint_fit ) { return false; }
let Some( hint ) = self.app.window_size_hint() else { return false };
let bounds = suggested.filter( |b| b.0 > 0 && b.1 > 0 ).or_else( || self.output_bounds() );
let Some( bounds ) = bounds else { return false };
let fitted = fit_within( hint, bounds );
if fitted == hint { return false; }
window.set_min_size( Some( fitted ) );
if !self.app.window_resizable() { window.set_max_size( Some( fitted ) ); }
self.fitted_size_hint = Some( fitted );
true
}
fn output_bounds( &self ) -> Option<( u32, u32 )>
{
let info = self.output_state.outputs().find_map( |o| self.output_state.info( &o ) )?;
let ( w, h ) = info.logical_size.or_else( ||
{
let mode = info.modes.iter().find( |m| m.current )?;
let scale = info.scale_factor.max( 1 );
Some( ( mode.dimensions.0 / scale, mode.dimensions.1 / scale ) )
} )?;
if w <= 0 || h <= 0 { return None; }
Some( ( ( w as f32 * OUTPUT_SHARE ) as u32, ( h as f32 * OUTPUT_SHARE ) as u32 ) )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn a_size_that_fits_is_kept()
{
assert_eq!( fit_within( ( 800, 600 ), ( 1920, 1080 ) ), ( 800, 600 ) );
assert_eq!( fit_within( ( 1920, 1080 ), ( 1920, 1080 ) ), ( 1920, 1080 ) );
}
#[ test ]
fn a_tall_window_shrinks_to_the_height_and_keeps_its_shape()
{
assert_eq!( fit_within( ( 720, 1440 ), ( 1920, 1080 ) ), ( 540, 1080 ) );
}
#[ test ]
fn a_wide_window_shrinks_to_the_width()
{
assert_eq!( fit_within( ( 1600, 900 ), ( 800, 1280 ) ), ( 800, 450 ) );
}
#[ test ]
fn degenerate_input_is_left_alone()
{
assert_eq!( fit_within( ( 720, 1440 ), ( 0, 1080 ) ), ( 720, 1440 ) );
assert_eq!( fit_within( ( 0, 1440 ), ( 1920, 1080 ) ), ( 0, 1440 ) );
}
}
+92
View File
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The back request: the shell's back button or gesture (`XF86Back`) and
//! the mouse's back button.
//!
//! Back peels one layer per request, top-most first — see [`App::on_back`]
//! for the order as the app sees it. `Escape` shares only the first step
//! ([`AppData::dismiss_transient`]): it cancels transient UI, never
//! navigates and never leaves the app.
use smithay_client_toolkit::seat::keyboard::Keysym;
use smithay_client_toolkit::reexports::client::QueueHandle;
use crate::app::{ App, BackAtRoot, BackResponse };
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceKind };
impl<A: App> AppData<A>
{
/// Close the top-most piece of transient UI the runtime owns. Order:
/// 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 (typically a `dialog`'s cancel). Returns whether
/// there was one.
pub( crate ) fn dismiss_transient( &mut self, focus: SurfaceFocus ) -> bool
{
if !self.overlays.is_empty() && self.app.overlays().iter().any( | s | s.anchor_widget_id.is_some() )
{
self.dismiss_all_popups();
return true;
}
if self.surface( focus ).context_menu.is_some()
{
self.hide_context_menu( focus );
return true;
}
if self.collapse_selection_if_any( focus ) { return true; }
let escape_msg = self.surface( focus ).frame.widget_rects.iter()
.rev()
.find_map( |w| w.handlers.escape_msg() );
if let Some( msg ) = escape_msg
{
self.pending_msgs.push( msg );
return true;
}
false
}
pub( crate ) fn handle_back( &mut self, focus: SurfaceFocus, qh: &QueueHandle<Self> )
{
if self.dismiss_transient( focus ) { return; }
if self.focused_text_input( focus )
{
self.set_focus( focus, None, qh );
return;
}
self.back_into_app();
}
fn back_into_app( &mut self )
{
match self.app.on_back()
{
BackResponse::Message( msg ) => self.pending_msgs.push( msg ),
BackResponse::Consumed => {}
BackResponse::Unhandled =>
{
// Apps that predate `on_back` bind the key themselves.
let legacy = self.app.on_key_with_modifiers( Keysym::XF86_Back, self.ctrl_pressed, self.shift_pressed );
match legacy
{
Some( msg ) => self.pending_msgs.push( msg ),
None => self.back_at_root(),
}
}
}
}
fn back_at_root( &mut self )
{
let SurfaceKind::Window( ref window ) = self.main.surface else { return };
match self.app.back_at_root()
{
BackAtRoot::Minimize => window.set_minimized(),
BackAtRoot::Close =>
{
if self.app.on_close_requested() { self.exit_requested = true; }
}
BackAtRoot::Ignore => {}
}
}
}
+2 -1
View File
@@ -32,7 +32,8 @@ impl<A: App> AppData<A>
Keysym::x | Keysym::X if self.ctrl_pressed => self.handle_key_ctrl_x( focus ), Keysym::x | Keysym::X if self.ctrl_pressed => self.handle_key_ctrl_x( focus ),
Keysym::v | Keysym::V if self.ctrl_pressed => self.handle_key_ctrl_v( focus ), Keysym::v | Keysym::V if self.ctrl_pressed => self.handle_key_ctrl_v( focus ),
Keysym::Tab | Keysym::ISO_Left_Tab => self.handle_key_tab( focus, &event, qh ), Keysym::Tab | Keysym::ISO_Left_Tab => self.handle_key_tab( focus, &event, qh ),
Keysym::Escape | Keysym::XF86_Back => self.handle_key_escape( focus, &event, qh ), Keysym::Escape => self.handle_key_escape( focus, &event, qh ),
Keysym::XF86_Back => self.handle_back( focus, qh ),
Keysym::space => self.handle_key_space( focus, &event ), Keysym::space => self.handle_key_space( focus, &event ),
_ => self.handle_key_default( focus, &event ), _ => self.handle_key_default( focus, &event ),
} }
+9 -1
View File
@@ -26,6 +26,7 @@ use smithay_client_toolkit::reexports::client::
use crate::app::App; use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus }; use crate::event_loop::{ AppData, SurfaceFocus };
pub( super ) mod back;
pub( super ) mod dispatch; pub( super ) mod dispatch;
pub( super ) mod text_keys; pub( super ) mod text_keys;
pub( super ) mod shortcuts; pub( super ) mod shortcuts;
@@ -76,8 +77,12 @@ impl<A: App> KeyboardHandler for AppData<A>
{ {
let focus = self.keyboard_focus; let focus = self.keyboard_focus;
// Raw observer hook (e.g. forwarding to an embedded WPE view). // Raw observer hook (e.g. forwarding to an embedded WPE view).
// Fires before the focus-aware dispatch. // Fires before the focus-aware dispatch. Back is a navigation
// request, not input for the embedded system.
if event.keysym != Keysym::XF86_Back
{
self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed ); self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed );
}
// A new press always cancels any in-flight repeat — the user // A new press always cancels any in-flight repeat — the user
// has either released the previous key or is pressing a // has either released the previous key or is pressing a
// different one. Either way, the prior timer's keysym should // different one. Either way, the prior timer's keysym should
@@ -95,8 +100,11 @@ impl<A: App> KeyboardHandler for AppData<A>
_serial: u32, _serial: u32,
event: KeyEvent, event: KeyEvent,
) )
{
if event.keysym != Keysym::XF86_Back
{ {
self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed ); self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed );
}
// Only stop if the released key matches the one currently // Only stop if the released key matches the one currently
// repeating; releasing a non-repeating key (e.g. a shift that // repeating; releasing a non-repeating key (e.g. a shift that
// snuck through, or any key we never armed) leaves the timer // snuck through, or any key we never armed) leaves the timer
+3 -24
View File
@@ -67,34 +67,13 @@ impl<A: App> AppData<A>
pub( super ) fn handle_key_escape( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle<Self> ) pub( super ) fn handle_key_escape( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle<Self> )
{ {
// Esc peels off transient UI one layer at a time. // Esc peels off transient UI one layer at a time; with none
// Order: (1) open xdg-popup overlays → dismiss them; // left it drops focus and lets the app see Esc.
// (2) context menu → close it; (3) active selection in if self.dismiss_transient( focus ) { return; }
// a focused text input → collapse to cursor; (4) any
// laid-out pressable carrying an `on_escape` message
// (typically the topmost `dialog`'s cancel) → fire
// that; (5) otherwise → drop focus and let the app see
// Esc.
if !self.overlays.is_empty() && self.app.overlays().iter().any( | s | s.anchor_widget_id.is_some() )
{
self.dismiss_all_popups();
} else if self.surface( focus ).context_menu.is_some()
{
self.hide_context_menu( focus );
} else if self.collapse_selection_if_any( focus )
{
// Selection collapsed — keep focus, no app msg.
} else if let Some( msg ) = self.surface( focus ).frame.widget_rects.iter()
.rev()
.find_map( |w| w.handlers.escape_msg() )
{
self.pending_msgs.push( msg );
} else {
self.set_focus( focus, None, qh ); self.set_focus( focus, None, qh );
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{ {
self.pending_msgs.push( msg ); self.pending_msgs.push( msg );
} }
} }
}
} }
+1 -1
View File
@@ -11,7 +11,7 @@ impl<A: App> AppData<A>
{ {
/// Whether the focused widget is an editable text input; any other /// Whether the focused widget is an editable text input; any other
/// focused widget lets text keys bubble to the app. /// focused widget lets text keys bubble to the app.
fn focused_text_input( &self, focus: SurfaceFocus ) -> bool pub( crate ) fn focused_text_input( &self, focus: SurfaceFocus ) -> bool
{ {
let ss = self.surface( focus ); let ss = self.surface( focus );
ss.focused_idx ss.focused_idx
+2
View File
@@ -61,6 +61,8 @@ impl<A: App> PointerHandler for AppData<A>
PointerEventKind::Motion { .. } => self.on_pointer_motion( conn, qh, pointer, event ), PointerEventKind::Motion { .. } => self.on_pointer_motion( conn, qh, pointer, event ),
PointerEventKind::Press { button: 0x110, .. } => self.on_pointer_left_press( conn, qh, pointer, event ), PointerEventKind::Press { button: 0x110, .. } => self.on_pointer_left_press( conn, qh, pointer, event ),
PointerEventKind::Press { button: 0x111, .. } => self.on_pointer_right_click( conn, qh, pointer, event ), PointerEventKind::Press { button: 0x111, .. } => self.on_pointer_right_click( conn, qh, pointer, event ),
// BTN_SIDE / BTN_BACK: the mouse's back button.
PointerEventKind::Press { button: 0x113 | 0x116, .. } => self.handle_back( focus, qh ),
PointerEventKind::Release { button: 0x110, .. } => self.on_pointer_release( conn, qh, pointer, event ), PointerEventKind::Release { button: 0x110, .. } => self.on_pointer_release( conn, qh, pointer, event ),
PointerEventKind::Axis { .. } => self.on_pointer_axis( conn, qh, pointer, event ), PointerEventKind::Axis { .. } => self.on_pointer_axis( conn, qh, pointer, event ),
_ => {} _ => {}
+4 -1
View File
@@ -294,6 +294,7 @@ pub( crate ) mod app;
pub mod theme; pub mod theme;
pub mod wallpaper; pub mod wallpaper;
pub mod chassis; pub mod chassis;
pub mod nav_stack;
pub( crate ) mod tree; pub( crate ) mod tree;
pub( crate ) mod draw; pub( crate ) mod draw;
@@ -308,7 +309,7 @@ pub mod core;
pub use app:: pub use app::
{ {
Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, Anchor, App, BackAtRoot, BackResponse, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
SubsurfaceId, SubsurfaceSpec, SubsurfaceParent, SubsurfaceId, SubsurfaceSpec, SubsurfaceParent,
InvalidationScope, SurfaceTarget, ToplevelEvent, InvalidationScope, SurfaceTarget, ToplevelEvent,
}; };
@@ -344,6 +345,7 @@ pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use render::is_software_render; pub use render::is_software_render;
pub use render::Canvas; pub use render::Canvas;
pub use wallpaper::{ WallpaperBundle, ImageData }; pub use wallpaper::{ WallpaperBundle, ImageData };
pub use nav_stack::NavStack;
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop }; pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId }; pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId };
pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX }; pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX };
@@ -500,6 +502,7 @@ pub mod window
pub use crate:: pub use crate::
{ {
App, ButtonVariant, Color, Element, Keysym, Point, Rect, Size, App, ButtonVariant, Color, Element, Keysym, Point, Rect, Size,
BackAtRoot, BackResponse, NavStack,
Column, Row, Stack, WrapGrid, Spacer, Column, Row, Stack, WrapGrid, Spacer,
button, icon_button, text, text_edit, img_widget, button, icon_button, text, text_edit, img_widget,
container, checkbox, radio, toggle, separator, container, checkbox, radio, toggle, separator,
+224
View File
@@ -0,0 +1,224 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! [`NavStack`]: the navigation state of a multi-screen app.
use serde::{ Deserialize, Serialize };
use crate::app::BackResponse;
/// The screens the user went through to get where they are, root first.
///
/// A plain value the app keeps in its model — the runtime never sees it.
/// It exists so the two things that must agree are one object: what
/// [`App::on_back`](crate::App::on_back) walks and what
/// [`App::save_state`](crate::App::save_state) persists. It serialises
/// whenever the route type does, and it is never empty: there is always a
/// root to fall back to, including after deserialising.
///
/// ```rust
/// use ltk::{ App, BackResponse, Element, NavStack, text };
/// 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(); }
/// }
/// }
///
/// fn on_back( &mut self ) -> BackResponse<Msg> { self.nav.back( Msg::Back ) }
///
/// 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; }
/// }
/// }
/// ```
#[ derive( Debug, Clone, PartialEq, Eq, Serialize, Deserialize ) ]
pub struct NavStack<R>
{
root: R,
above: Vec<R>,
}
impl<R> NavStack<R>
{
pub fn new( root: R ) -> Self
{
Self { root, above: Vec::new() }
}
/// The route on screen.
pub fn current( &self ) -> &R
{
self.above.last().unwrap_or( &self.root )
}
pub fn current_mut( &mut self ) -> &mut R
{
self.above.last_mut().unwrap_or( &mut self.root )
}
/// Go to `route`, keeping the current one underneath.
pub fn push( &mut self, route: R )
{
self.above.push( route );
}
/// Go back one route. `None` at the root, which stays.
pub fn pop( &mut self ) -> Option<R>
{
self.above.pop()
}
/// Swap the current route for `route` without growing the stack — a
/// sideways move (tab to tab, step to step) back should not retrace.
/// Returns the route it replaced.
pub fn replace( &mut self, route: R ) -> R
{
std::mem::replace( self.current_mut(), route )
}
pub fn pop_to_root( &mut self )
{
self.above.clear();
}
/// Start over from a new root.
pub fn reset( &mut self, root: R )
{
self.root = root;
self.above.clear();
}
pub fn can_pop( &self ) -> bool
{
!self.above.is_empty()
}
/// Number of routes, the root included.
pub fn depth( &self ) -> usize
{
self.above.len() + 1
}
/// Routes from the root up to the current one.
pub fn iter( &self ) -> impl Iterator<Item = &R>
{
std::iter::once( &self.root ).chain( self.above.iter() )
}
/// The answer for [`App::on_back`](crate::App::on_back): `pop` when
/// there is a route to go back to, [`BackResponse::Unhandled`] at the
/// root. `pop` is the app's message that ends up calling [`Self::pop`].
pub fn back<M>( &self, pop: M ) -> BackResponse<M>
{
if self.can_pop() { BackResponse::Message( pop ) } else { BackResponse::Unhandled }
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn push_and_pop_walk_the_routes()
{
let mut nav = NavStack::new( "inbox" );
nav.push( "thread" );
nav.push( "attachment" );
assert_eq!( *nav.current(), "attachment" );
assert_eq!( nav.depth(), 3 );
assert_eq!( nav.pop(), Some( "attachment" ) );
assert_eq!( *nav.current(), "thread" );
}
#[ test ]
fn the_root_cannot_be_popped()
{
let mut nav = NavStack::new( "inbox" );
assert!( !nav.can_pop() );
assert_eq!( nav.pop(), None );
assert_eq!( *nav.current(), "inbox" );
}
#[ test ]
fn replace_is_a_sideways_move()
{
let mut nav = NavStack::new( "inbox" );
nav.push( "general" );
assert_eq!( nav.replace( "network" ), "general" );
assert_eq!( nav.depth(), 2 );
nav.pop();
assert_eq!( *nav.current(), "inbox" );
let mut root_only = NavStack::new( "a" );
root_only.replace( "b" );
assert_eq!( *root_only.current(), "b" );
assert!( !root_only.can_pop() );
}
#[ test ]
fn back_is_unhandled_only_at_the_root()
{
let mut nav = NavStack::new( 0 );
assert_eq!( nav.back( "pop" ), BackResponse::Unhandled );
nav.push( 1 );
assert_eq!( nav.back( "pop" ), BackResponse::Message( "pop" ) );
}
#[ test ]
fn reset_and_pop_to_root()
{
let mut nav = NavStack::new( 0 );
nav.push( 1 );
nav.push( 2 );
nav.pop_to_root();
assert_eq!( nav.iter().copied().collect::<Vec<_>>(), vec![ 0 ] );
nav.push( 1 );
nav.reset( 9 );
assert_eq!( nav.iter().copied().collect::<Vec<_>>(), vec![ 9 ] );
}
#[ test ]
fn survives_a_serde_round_trip()
{
let mut nav = NavStack::new( "inbox".to_string() );
nav.push( "thread".to_string() );
let bytes = serde_json::to_vec( &nav ).unwrap();
let restored: NavStack<String> = serde_json::from_slice( &bytes ).unwrap();
assert_eq!( restored, nav );
assert!( restored.can_pop() );
}
}