diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ecd65..9e64495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### 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`** — 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::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 )`** — 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 +- **`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. - **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`** 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. diff --git a/README.md b/README.md index beb4c5e..82de714 100644 --- a/README.md +++ b/README.md @@ -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/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/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`. | | `cargo doc --open` | Per-item rustdoc for the public API. | | [`CHANGELOG.md`](CHANGELOG.md) | What changed in each release, and what is pending unreleased. | diff --git a/docs/architecture.md b/docs/architecture.md index 461fdc1..d42f3e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,6 +99,12 @@ In practice, that model is easiest to adopt in three steps: - `on_key` / `on_key_with_modifiers` — global hotkeys. Characters, Backspace and Delete stay in a focused text input and reach the app otherwise, a focused button or pressable included; Return and Space go to the focused widget's submit or press message when it has one; arrows are offered to the app first and fall back to the text cursor or list navigation. - `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity. +**Implement when the app has more than one place to be** + +- `on_back()` → `BackResponse` — the system back button or gesture (`XF86Back`) and the mouse's back button. Return the message that undoes the top-most piece of *navigation state*, derived from the model; `Unhandled` at the root. The runtime closes its own transient UI and drops text focus before asking. `Escape` is a separate request that never navigates. +- `back_at_root()` → `BackAtRoot::Minimize` (default) / `Close` / `Ignore` — what back does once nothing is left. +- `NavStack` — the ready-made navigation state for an app made of screens; the same value answers `on_back` and is what `save_state` persists. See [`docs/navigation.md`](./navigation.md). + **Implement for animations and focus** - `is_animating()` — return `true` while a tween is running; the loop redraws on every compositor frame callback (~60 Hz on a typical display, capped at ~30 Hz on the software backend — see *Performance*). @@ -264,6 +270,8 @@ A four-button demo can keep all state in one struct and one flat `Msg` enum. Any **External state via channel + poll.** Anything that blocks (D-Bus, files, network, IPC) lives on a background thread. At startup save the `ChannelSender` 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`, an enum, a small struct) rather than booleans scattered across screens, derive the back answer from it instead of recording things to undo, and persist that same value. Transient UI — menus, popups, dialogs, text focus — stays out of both: the runtime closes it on back and on `Escape`, and nobody expects it after a relaunch. [`docs/navigation.md`](./navigation.md) is the full treatment. + **Stable widget ids only when you need to programmatically focus them.** `WidgetId` is an opt-in tag on a widget that pairs with `App::take_focus_request()`. Don't decorate every widget; tag the one input you want to autofocus on screen entry. Again, the simplest progression is: diff --git a/docs/cookbook.md b/docs/cookbook.md index 73c127a..af7ea75 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -738,11 +738,13 @@ opt-in needed beyond the widget being interactive. When the app has more than ~30 message variants it is time to split by screen. Each screen owns its sub-state and sub-message, and the -top-level enum wraps them. +top-level enum wraps them. Which screen is showing is the app's +*navigation state*: a [`NavStack`](./navigation.md#navstack) holds it, so +the system back button and a relaunch both work from the same value. ```rust,no_run -# use ltk::{ column, text, App, Element }; -# #[ derive( Clone, Copy ) ] enum Screen { Home, Settings, About } +# use ltk::{ column, text, App, BackResponse, Element, NavStack }; +# #[ derive( Clone, Copy, PartialEq ) ] enum Screen { Home, Settings, About } # #[ derive( Clone ) ] enum HomeMsg {} # #[ derive( Clone ) ] enum SettingsMsg {} # struct HomeState; @@ -756,45 +758,64 @@ top-level enum wraps them. #[derive(Clone)] enum AppMsg { - Nav( Screen ), + Open( Screen ), + Back, Home( HomeMsg ), Settings( SettingsMsg ), } struct AppState { - current: Screen, + nav: NavStack, home: HomeState, settings: SettingsState, } +impl Screen +{ + fn name( self ) -> &'static str + { + match self { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" } + } + + fn parse( name: &str ) -> Option + { + [ Screen::Home, Screen::Settings, Screen::About ].into_iter().find( |s| s.name() == name ) + } +} + impl App for AppState { type Message = AppMsg; fn app_id( &self ) -> &str { "net.example.MultiScreen" } - // Persist only the routing; each screen would add its own line. + // Persist the whole route stack, root first, one screen per line: + // after a restore, back retraces the same screens. With serde in the + // app, `serde_json::to_vec( &self.nav )` does the same in one call. fn save_state( &self ) -> Option> { - let s = match self.current { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" }; - Some( s.as_bytes().to_vec() ) + let lines: Vec<&str> = self.nav.iter().map( |s| s.name() ).collect(); + Some( lines.join( "\n" ).into_bytes() ) } fn restore_state( &mut self, state: Vec ) { - match state.as_slice() - { - b"home" => self.current = Screen::Home, - b"settings" => self.current = Screen::Settings, - b"about" => self.current = Screen::About, - _ => {} - } + let Ok( text ) = String::from_utf8( state ) else { return }; + let Some( screens ) = text.lines().map( Screen::parse ).collect::>>() else { return }; + let mut screens = screens.into_iter(); + let Some( root ) = screens.next() else { return }; + self.nav.reset( root ); + for screen in screens { self.nav.push( screen ); } } + // The same value answers the system back button. + fn on_back( &mut self ) -> BackResponse { self.nav.back( AppMsg::Back ) } + fn view( &self ) -> Element { - let body = match self.current + let current = *self.nav.current(); + let body = match current { Screen::Home => home_view( &self.home ).map( AppMsg::Home ), Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ), @@ -802,7 +823,7 @@ impl App for AppState }; column() - .push( nav_bar( self.current ) ) + .push( nav_bar( current ) ) .push( body ) .into() } @@ -811,7 +832,8 @@ impl App for AppState { match msg { - AppMsg::Nav( s ) => self.current = s, + AppMsg::Open( s ) => self.nav.push( s ), + AppMsg::Back => { self.nav.pop(); } AppMsg::Home( m ) => home_update( &mut self.home, m ), AppMsg::Settings( m ) => settings_update( &mut self.settings, m ), } @@ -834,7 +856,13 @@ In a real project each screen lives in its own file (`src/home.rs`, that `doctest-md` can typecheck the dispatch without needing one file per screen. -**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns). +An in-app back arrow sends `AppMsg::Back`, the same message `on_back` +returns, so the arrow and the system button cannot drift apart. A +sideways move that back should not retrace — switching between sibling +tabs — uses `self.nav.replace( s )` instead of `push`. + +**See also**: [`docs/navigation.md`](./navigation.md) for the model +behind `on_back`, and [`docs/architecture.md`](./architecture.md#larger-state-patterns). --- @@ -915,6 +943,11 @@ never exited cleanly. Shell components (layer-shell panels, lock screens) have no toplevel and are never persisted: return `None` and leave `restore_state` empty. +What to persist is the app's *navigation state* — the same state +`App::on_back` reads — so that back retraces the same places after a +restore. [`docs/navigation.md`](./navigation.md#back-and-session-state-are-the-same-state) +explains the rule. + **See also**: `App::save_state` / `App::restore_state` rustdoc for the full contract, and [`docs/architecture.md`](./architecture.md#known-gaps-and-non-goals) for the protocol status. diff --git a/docs/navigation.md b/docs/navigation.md new file mode 100644 index 0000000..a65cdde --- /dev/null +++ b/docs/navigation.md @@ -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> { None } +# fn restore_state( &mut self, _: Vec ) {} +# fn view( &self ) -> Element { text( "" ).into() } +# fn update( &mut self, _: Msg ) {} + fn on_back( &mut self ) -> BackResponse + { + if self.searching { return BackResponse::Message( Msg::CloseSearch ); } + match self.screen + { + Screen::Detail => BackResponse::Message( Msg::Show( Screen::List ) ), + Screen::List => BackResponse::Unhandled, + } + } +} +``` + +The alternative — pushing an "undo" entry whenever something opens and popping it on back — looks equivalent and is not. The user leaves screens in other ways: the panel's own close button, a link, a notification that jumps elsewhere. Every one of those paths has to remember to remove its entry, and the first that forgets leaves a back press that does nothing visible. A derived answer cannot disagree with the model, because it *is* the model. + +This is also why the runtime keeps no navigation stack of its own. Its entries would be messages, which are not serialisable (the `App` trait puts no serde bound on `Message`, by design), so it could not survive a relaunch; and it would duplicate what the app already knows. + +Keep `on_back` cheap and free of side effects beyond returning the message: do the work in `update`, where every other state change lives. + +## Back and session state are the same state + +[`App::save_state`] exists so an app can come back where the user left it (see *Session state* in [`docs/onboarding.md`](./onboarding.md#session-state) and the cookbook recipe [Surviving relaunch](./cookbook.md#surviving-relaunch-session-state)). What it should persist is the navigation state, the same state `on_back` reads: + +- persist **less** than back walks, and a restored app opens on the right screen but back jumps straight out of it, because the screens underneath were not saved; +- persist **more**, and the app restores a menu or a half-typed search the user had mentally closed. + +When both hooks operate on one object the rule holds by construction. That is the reason to model navigation as a value (a `NavStack`, an enum, a struct of a few fields) rather than as booleans scattered across screens. + +Transient UI is deliberately left out of `save_state`. So is anything secret, as the `save_state` contract already requires. + +## `NavStack` + +[`NavStack`] is the ready-made navigation state for the common case, an app made of screens: the routes the user went through, root first. It is a plain value in the app's model, never empty, and it serialises whenever the route type does. + +The example below is compiled as the doctest of `NavStack` itself; it is `ignore`d here only because the Markdown doctest harness links `ltk` alone, without `serde`. The cookbook recipe [Multi-screen app](./cookbook.md#multi-screen-app-via-sub-state-pattern) persists a `NavStack` without serde. + +```rust,ignore +use ltk::{ text, App, BackResponse, Element, NavStack }; +use serde::{ Deserialize, Serialize }; + +#[ derive( Clone, PartialEq, Serialize, Deserialize ) ] +enum Route { Inbox, Thread( u64 ), Settings } + +#[ derive( Clone ) ] +enum Msg { Open( Route ), Back } + +struct Mail { nav: NavStack } + +impl App for Mail +{ + type Message = Msg; + + fn app_id( &self ) -> &str { "net.example.Mail" } + + fn view( &self ) -> Element + { + let screen = match self.nav.current() + { + Route::Inbox => text( "inbox" ), + Route::Thread( id ) => text( format!( "thread {id}" ) ), + Route::Settings => text( "settings" ), + }; + screen.into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::Open( route ) => self.nav.push( route ), + Msg::Back => { self.nav.pop(); } + } + } + + // One object answers back… + fn on_back( &mut self ) -> BackResponse { self.nav.back( Msg::Back ) } + + // …and is what survives a relaunch. + fn save_state( &self ) -> Option> { serde_json::to_vec( &self.nav ).ok() } + + fn restore_state( &mut self, state: Vec ) + { + if let Ok( nav ) = serde_json::from_slice( &state ) { self.nav = nav; } + } +} +``` + +`push` goes somewhere new and keeps the current route underneath. `replace` is the sideways move — tab to tab, wizard step to step — that back should not retrace. `pop_to_root` and `reset` cover "home" and "log out". `back( msg )` is the glue for `on_back`: the message while there is a route to return to, `Unhandled` at the root. + +Put in the route whatever is needed to rebuild the screen (`Thread( id )`, not a loaded thread): the route is persisted, the data it points at is fetched again. + +An in-app back arrow sends the same message the hook returns (`Msg::Back` above), so the arrow and the system back button cannot drift apart. + +An app with layers above its screens checks them first and delegates the rest: + +```rust +# use ltk::{ BackResponse, NavStack }; +# #[ derive( Clone ) ] enum Msg { CloseDrawer, Back } +# struct Shop { drawer_open: bool, nav: NavStack } +# impl Shop { +fn on_back( &mut self ) -> BackResponse +{ + if self.drawer_open { return BackResponse::Message( Msg::CloseDrawer ); } + self.nav.back( Msg::Back ) +} +# } +``` + +## Apps that embed a web view + +A browser-like app has two histories: its own chrome (panels, menus, full screen) and the page's session history. The order is chrome first, then the page: + +```rust +# use ltk::BackResponse; +# #[ derive( Clone ) ] enum Msg { Dismiss, LeaveFullscreen, PageBack } +# struct Page; impl Page { fn can_go_back( &self ) -> bool { false } } +# struct Browser { menu_open: bool, panel_open: bool, fullscreen: bool, page: Page } +# impl Browser { +fn on_back( &mut self ) -> BackResponse +{ + if self.menu_open || self.panel_open { return BackResponse::Message( Msg::Dismiss ); } + if self.fullscreen { return BackResponse::Message( Msg::LeaveFullscreen ); } + if self.page.can_go_back() { return BackResponse::Message( Msg::PageBack ); } + BackResponse::Unhandled +} +# } +``` + +States *inside* a page need no extra work: a single-page application that uses the History API (`pushState`) creates session-history entries, so the engine's "can go back" already covers them. With ltk-webkit these are `WebView::can_go_back` and `WebView::go_back`. + +The page's session history is navigation state like any other, so the rule of the previous section applies to it: persisting only the current URL restores the right page with nothing behind it, and the first back after a relaunch leaves the app. Persist the engine's session state next to the URL — with ltk-webkit, `WebView::session_state` returns it as bytes and `WebView::restore_session_state` brings it back into a view created with an empty URL. + +Because `XF86Back` never reaches `on_raw_key`, a host that forwards raw keys into the view does not have to filter it out. + +## Apps whose back stack lives elsewhere + +Some hosts cannot answer ahead of time. An Android app running in an embedded runtime keeps its activities, fragments and back callbacks in its own code; whether back "does something" is only known by delivering it. Such a host hands the request over and returns [`BackResponse::Consumed`]: + +```rust +# use ltk::BackResponse; +# #[ derive( Clone ) ] enum Msg {} +# struct Guest; impl Guest { fn deliver_back_key( &mut self ) {} } +# struct Host { guest: Guest } +# impl Host { +fn on_back( &mut self ) -> BackResponse +{ + self.guest.deliver_back_key(); + BackResponse::Consumed +} +# } +``` + +The guest then decides everything, including leaving: when its last activity finishes, the host returns `true` from [`App::requested_exit`]. `Consumed` tells the runtime not to apply `back_at_root` on top of that. + +Steps 1 and 2 of the order still run first. For them to match the guest's expectations, project the guest's cancellable dialogs as overlays with an `on_dismiss`, and its non-cancellable ones without. + +## At the root + +When `on_back` answers `Unhandled` and no legacy key binding takes the request, [`App::back_at_root`] decides: + +- **`Minimize`** (default) — `xdg_toplevel.set_minimized`. The window leaves the screen, the process and its state stay, and the compositor shows whatever was behind. This is what a root activity does on Android since version 12, and it suits anything the user comes back to: a browser keeps its tabs, a player keeps playing. +- **`Close`** — close the window through [`App::on_close_requested`], which can still veto. For apps with nothing worth keeping alive. +- **`Ignore`** — do nothing. For kiosk-style apps that must not be left by accident. + +It only applies to [`ShellMode::Window`] surfaces. Layer-shell and session-lock surfaces never react to back at the root: a panel or a lock screen that minimised itself would be a bug. + +Since an app minimised from the root is not closed, no close-time save happens at that moment; the periodic save (every ~30 s when the bytes changed) and the `SIGTERM` / `SIGINT` save still cover it. + +## Who owns what: app, toolkit, compositor + +There are two stacks, with two owners, as on Android: + +- The **navigation stack** — where the user has been *inside* one app — belongs to the app's model. Nothing outside the process sees it. +- The **task stack** — which window was in front of which — belongs to the compositor. + +They meet in one standard request: when the navigation stack runs out, ltk asks to be minimised and the compositor shows the next entry of *its* stack. No custom protocol carries navigation state to the compositor, which keeps the scheme working for clients that are not ltk apps. + +The compositor has its own layers above every client and closes them before sending the key at all: its app switcher and panels, and the on-screen keyboard. That is why step 2 above is phrased as a fallback: under a compositor that retires the keyboard itself, the app only sees the *next* request. + +## Checklist + +- [ ] Every piece of UI state is sorted into transient or navigation (the table above). +- [ ] `on_back` derives its answer from the model; nothing is pushed or popped to "remember" what to undo. +- [ ] `on_back` checks layers top-most first and ends in `BackResponse::Unhandled` at the root. +- [ ] `save_state` persists the navigation state — the same object `on_back` reads — and nothing transient or secret. +- [ ] Routes carry identifiers, not loaded data. +- [ ] In-app back arrows send the same message `on_back` returns. +- [ ] Dialogs have a `cancel`; the app has no `Escape` / back code for them. +- [ ] `Escape` is not used to navigate. +- [ ] `back_at_root` is a deliberate choice; the default `Minimize` keeps the app alive. +- [ ] A host that forwards raw keys to an embedded view relies on `XF86Back` not arriving there. + +[`App::on_back`]: ../src/app.rs +[`App::back_at_root`]: ../src/app.rs +[`App::save_state`]: ../src/app.rs +[`App::on_key_with_modifiers`]: ../src/app.rs +[`App::on_close_requested`]: ../src/app.rs +[`App::requested_exit`]: ../src/app.rs +[`BackResponse::Consumed`]: ../src/app.rs +[`ShellMode::Window`]: ../src/app.rs +[`NavStack`]: ../src/nav_stack.rs diff --git a/docs/onboarding.md b/docs/onboarding.md index e425c51..8af95a7 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -451,6 +451,12 @@ because the compositor restores them through `xdg-session-management-v1`. Shell components (panels, lock screens) return `None` and no-op. See the cookbook recipe *Surviving relaunch: session state* for a worked example. +What those bytes should hold is the app's *navigation state* — which screen, +which panel, how far into a history — because that is also what the system +back button walks through `App::on_back`. Keeping the two the same makes back +retrace the same places after a restore. An app with more than one screen +should read [`docs/navigation.md`](./navigation.md) before growing its second. + ## Current assumptions and rough edges This repo is usable, but a few current behaviours are worth knowing up front: diff --git a/src/app.rs b/src/app.rs index e64ef23..53a4433 100644 --- a/src/app.rs +++ b/src/app.rs @@ -334,6 +334,35 @@ pub struct SubsurfaceSpec pub gpu: bool, } +/// What an app did with a back request. Returned by [`App::on_back`]. +#[ derive( Debug, Clone, PartialEq ) ] +pub enum BackResponse +{ + /// 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. pub trait App: 'static { @@ -597,6 +626,79 @@ pub trait App: 'static 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> { None } + /// # fn restore_state( &mut self, _: Vec ) {} + /// # fn view( &self ) -> Element { text( "" ).into() } + /// # fn update( &mut self, _: Msg ) {} + /// fn on_back( &mut self ) -> BackResponse + /// { + /// if self.searching { return BackResponse::Message( Msg::CloseSearch ); } + /// match self.screen + /// { + /// Screen::Detail => BackResponse::Message( Msg::Show( Screen::List ) ), + /// Screen::List => BackResponse::Unhandled, + /// } + /// } + /// } + /// ``` + fn on_back( &mut self ) -> BackResponse { 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. /// /// 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* /// 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 ) {} /// 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. /// - /// Default: every non-modifier key repeats *except* `Escape`, `Tab` - /// and `ISO_Left_Tab` — those drive one-shot UI semantics - /// (dismiss / focus cycle) where a held key would multiply the - /// effect in surprising ways. Override to widen or narrow the gate - /// (a chess clock app, for example, might want even Tab to - /// repeat). + /// Default: every non-modifier key repeats *except* `Escape`, + /// `XF86_Back`, `Tab` and `ISO_Left_Tab` — those drive one-shot UI + /// semantics (dismiss / go back / focus cycle) where a held key would + /// multiply the effect in surprising ways. Override to widen or + /// narrow the gate (a chess clock app, for example, might want even + /// Tab to repeat). fn key_repeats( &self, keysym: Keysym ) -> bool { !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 /// 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 /// xdg-shell fallback path used when `wlr-layer-shell` is missing. /// Ignored for layer-shell surfaces, which use [`Self::layer_size`] diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index eec788f..92bdb71 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -220,6 +220,10 @@ pub struct AppData /// First-configure latch: clears `max_size` after the compositor /// has honoured the pinned initial size. 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. pub main: SurfaceState, /// Auxiliary layer-shell surfaces keyed by their stable [`OverlayId`]. diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 103946d..f1bc36d 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -300,9 +300,17 @@ impl WindowHandler for AppData self.main.content_dirty = true; self.view_dirty = true; } - let ( hint_w, hint_h ) = self.app.window_size_hint().unwrap_or( ( 800, 600 ) ); - let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w ); - let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h ); + let fitted = self.fit_size_hint( window, configure.suggested_bounds ); + let ( hint_w, hint_h ) = self.fitted_size_hint.or( self.app.window_size_hint() ).unwrap_or( ( 800, 600 ) ); + 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 ); self.on_configure( w, h ); } diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 9b24e0d..dde68a1 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -17,6 +17,7 @@ pub( crate ) mod surface; pub( crate ) mod text_editing; pub( crate ) mod text_scale; pub( crate ) mod tooltip; +pub( crate ) mod window_fit; pub( crate ) mod error; pub( crate ) mod frame; diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index d312527..ca309f3 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -287,6 +287,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> 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_fit = !app.start_fullscreen() && app.window_size_hint().is_some(); let mut data = AppData { @@ -349,6 +350,8 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> pending_fullscreen, applied_fullscreen: Some( pending_fullscreen ), pending_size_hint_unpin, + pending_size_hint_fit, + fitted_size_hint: None, main: SurfaceState::::new( surface_kind, titlebar_height, titlebar_title ), overlays: std::collections::HashMap::new(), secondaries: Vec::new(), diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index 1e9b812..aabef54 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -35,10 +35,11 @@ impl AppData } ( None, _ ) => 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. - // flag a password field) without re-creating the object. + // A press on another text field, or on the focused one again: renew + // the enable so a compositor that folded its keyboard raises it. ( Some( _ ), Some( ti ) ) => { + ti.enable(); ti.set_content_type( hint, purpose ); ti.commit(); } diff --git a/src/event_loop/window_fit.rs b/src/event_loop/window_fit.rs new file mode 100644 index 0000000..9f9379a --- /dev/null +++ b/src/event_loop/window_fit.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 AppData +{ + /// 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 ) ); + } +} diff --git a/src/input/keyboard/back.rs b/src/input/keyboard/back.rs new file mode 100644 index 0000000..e6eba0b --- /dev/null +++ b/src/input/keyboard/back.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 AppData +{ + /// 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 ) + { + 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 => {} + } + } +} diff --git a/src/input/keyboard/dispatch.rs b/src/input/keyboard/dispatch.rs index 9c986fe..56ab698 100644 --- a/src/input/keyboard/dispatch.rs +++ b/src/input/keyboard/dispatch.rs @@ -32,7 +32,8 @@ impl AppData 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::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 ), _ => self.handle_key_default( focus, &event ), } diff --git a/src/input/keyboard/mod.rs b/src/input/keyboard/mod.rs index 99bdf59..8c03b82 100644 --- a/src/input/keyboard/mod.rs +++ b/src/input/keyboard/mod.rs @@ -26,6 +26,7 @@ use smithay_client_toolkit::reexports::client:: use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; +pub( super ) mod back; pub( super ) mod dispatch; pub( super ) mod text_keys; pub( super ) mod shortcuts; @@ -76,8 +77,12 @@ impl KeyboardHandler for AppData { let focus = self.keyboard_focus; // Raw observer hook (e.g. forwarding to an embedded WPE view). - // Fires before the focus-aware dispatch. - self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed ); + // 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 ); + } // A new press always cancels any in-flight repeat — the user // has either released the previous key or is pressing a // different one. Either way, the prior timer's keysym should @@ -96,7 +101,10 @@ impl KeyboardHandler for AppData event: KeyEvent, ) { - self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed ); + if event.keysym != Keysym::XF86_Back + { + 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 // repeating; releasing a non-repeating key (e.g. a shift that // snuck through, or any key we never armed) leaves the timer diff --git a/src/input/keyboard/shortcuts.rs b/src/input/keyboard/shortcuts.rs index 4a84475..95dba20 100644 --- a/src/input/keyboard/shortcuts.rs +++ b/src/input/keyboard/shortcuts.rs @@ -67,34 +67,13 @@ impl AppData pub( super ) fn handle_key_escape( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle ) { - // Esc peels off transient UI one layer at a time. - // Order: (1) open xdg-popup overlays → dismiss them; - // (2) context menu → close it; (3) active selection in - // 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() ) + // Esc peels off transient UI one layer at a time; with none + // left it drops focus and lets the app see Esc. + if self.dismiss_transient( focus ) { return; } + self.set_focus( focus, None, qh ); + if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) { self.pending_msgs.push( msg ); - } else { - self.set_focus( focus, None, qh ); - if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) - { - self.pending_msgs.push( msg ); - } } } } diff --git a/src/input/keyboard/text_keys.rs b/src/input/keyboard/text_keys.rs index c85b9ad..765910e 100644 --- a/src/input/keyboard/text_keys.rs +++ b/src/input/keyboard/text_keys.rs @@ -11,7 +11,7 @@ impl AppData { /// Whether the focused widget is an editable text input; any other /// 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 ); ss.focused_idx diff --git a/src/input/pointer/mod.rs b/src/input/pointer/mod.rs index cfaa850..ec52b72 100644 --- a/src/input/pointer/mod.rs +++ b/src/input/pointer/mod.rs @@ -61,6 +61,8 @@ impl PointerHandler for AppData 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: 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::Axis { .. } => self.on_pointer_axis( conn, qh, pointer, event ), _ => {} diff --git a/src/lib.rs b/src/lib.rs index 062f3b9..cf8671a 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -294,6 +294,7 @@ pub( crate ) mod app; pub mod theme; pub mod wallpaper; pub mod chassis; +pub mod nav_stack; pub( crate ) mod tree; pub( crate ) mod draw; @@ -308,7 +309,7 @@ pub mod core; pub use app:: { - Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, + Anchor, App, BackAtRoot, BackResponse, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, SubsurfaceId, SubsurfaceSpec, SubsurfaceParent, InvalidationScope, SurfaceTarget, ToplevelEvent, }; @@ -344,6 +345,7 @@ pub use gles_render::{ BorrowedGlesTexture, GlesVersion }; pub use render::is_software_render; pub use render::Canvas; 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 types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId }; pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX }; @@ -500,6 +502,7 @@ pub mod window pub use crate:: { App, ButtonVariant, Color, Element, Keysym, Point, Rect, Size, + BackAtRoot, BackResponse, NavStack, Column, Row, Stack, WrapGrid, Spacer, button, icon_button, text, text_edit, img_widget, container, checkbox, radio, toggle, separator, diff --git a/src/nav_stack.rs b/src/nav_stack.rs new file mode 100644 index 0000000..1f8a406 --- /dev/null +++ b/src/nav_stack.rs @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! [`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 } +/// +/// impl App for Mail +/// { +/// type Message = Msg; +/// +/// fn app_id( &self ) -> &str { "net.example.Mail" } +/// +/// fn view( &self ) -> Element +/// { +/// let screen = match self.nav.current() +/// { +/// Route::Inbox => text( "inbox" ), +/// Route::Thread( id ) => text( format!( "thread {id}" ) ), +/// Route::Settings => text( "settings" ), +/// }; +/// screen.into() +/// } +/// +/// fn update( &mut self, msg: Msg ) +/// { +/// match msg +/// { +/// Msg::Open( route ) => self.nav.push( route ), +/// Msg::Back => { self.nav.pop(); } +/// } +/// } +/// +/// fn on_back( &mut self ) -> BackResponse { self.nav.back( Msg::Back ) } +/// +/// fn save_state( &self ) -> Option> { serde_json::to_vec( &self.nav ).ok() } +/// +/// fn restore_state( &mut self, state: Vec ) +/// { +/// if let Ok( nav ) = serde_json::from_slice( &state ) { self.nav = nav; } +/// } +/// } +/// ``` +#[ derive( Debug, Clone, PartialEq, Eq, Serialize, Deserialize ) ] +pub struct NavStack +{ + root: R, + above: Vec, +} + +impl NavStack +{ + 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 + { + 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 + { + 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( &self, pop: M ) -> BackResponse + { + 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![ 0 ] ); + nav.push( 1 ); + nav.reset( 9 ); + assert_eq!( nav.iter().copied().collect::>(), 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 = serde_json::from_slice( &bytes ).unwrap(); + assert_eq!( restored, nav ); + assert!( restored.can_pop() ); + } +}