Files
ltk/docs/cookbook.md
T
admin 02ab10b3ef
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
State restored on an ordinary launch for apps that ask, focus that follows its widget's id, buttons that do not take the focus when pressed, a focus ring only after the keyboard, and a browser app's surfaces copied rather than transferred, which WebKit got wrong
Restore on launch. `restore_state` follows Android's saved-instance-state contract: the saved bytes come back after a crash or a session restore, and a launch from the launcher starts fresh. Some apps keep state their users expect to find however the last run ended, as an Android browser keeps its tabs in its own storage even when closed from the app switcher. `App::restore_on_launch`, false by default, has the runtime restore on a plain launch too; `SessionRuntime::bootstrap` asks it before deciding. Saving, the file layout and the window geometry are the same either way. Its documentation explains when it is asked, that it is not when persistence is off, that `restore_state` must then merge what the launch itself asks for, such as a URL on the command line, and that what `save_state` returns now outlives every exit; it carries a compiled example. `restore_state`'s documentation, `docs/onboarding.md` and the cookbook's session state recipe mention it.
Focus on a press. `Button::focusable( false )` and `WindowButton` kept out of the Tab cycle but still took the focus when pressed, and Enter then pressed them again, which closed a browser's tab when the user pressed Enter in a page after closing another tab. `LaidOutWidget` gains `focus_on_press`, from `Element::focus_on_press`: a button's `focusable`, a `WindowButton`'s, a pressable's new `Pressable::focus_on_press`, true by default, and true for every other widget. `focus_pressed` replaces `set_focus` for pointer and touch presses and leaves the focus where it was when the widget does not take it. Enter's fallback to the widget under the pointer, which `hovered_idx` follows, skips such widgets too, and a rebuild that leaves the focus on one drops it (`drop_misplaced_focus`). The close buttons of `tab_strip`, on the desktop and on the phone's cards, are not focusable; `symbol_button` takes whether its button is.
Focus by id. The focus was held as the index of a widget in the laid-out tree, so a rebuild that inserted or removed widgets before it moved it to whatever widget took that index. `WidgetId` becomes a name and a key, `WidgetId::keyed( name, key )` telling apart widgets of the same kind, such as the tabs of a strip; `WidgetId( "name" )` still builds one, through a `const fn` of that name, so the calls in every app compile unchanged, and only code that read `.0`, the toasts, the combo and the tooltip here, moves to the derived `Hash` or `name()`. After each layout `follow_focused_id` finds the focused id and moves the focus to its new index with the field's pending text, cursor, selection and scroll; it drops the focus when the widget is gone, keeps it when a scroll only clipped it, and leaves it when the widget at the old index still has the id. `draw_surface` returns whether the focus moved, and its callers draw the surface again rather than marking it done, since the frame just drawn showed the focus in the old place. Widgets without an id keep the index. Each tab and each card of `tab_strip` carries `WidgetId::keyed` with the tab's key, so a chosen tab keeps the focus while others close.
Focus ring. A press that focused a button, a toggle or a list item left its ring drawn, which browsers have not done since `:focus-visible`. `SurfaceState::focus_visible` is set by Tab and Shift+Tab and cleared by a press that moves the focus, `show_focus_ring` redrawing the whole surface when it changes, since the partial redraw only sees indices. The draw is given the focused index only while the ring shows or the widget is a text field, which draws its cursor however it was focused; the focus itself is unchanged, so Enter and Space still press the widget.
Key repeat. A key repeated by the platform rather than by ltk's own timer reached the focused widget even while `App::claims_keyboard` was true; `key_repeated` checks it as `key_press` does.
Browser surfaces. Every surface is painted through the app's one WebGL context on one `OffscreenCanvas`, and was handed to its own canvas, a `bitmaprenderer`, with `transferToImageBitmap`. WebKit reuses the buffer of a bitmap it was handed, so in a WebKit browser each surface showed another's frame, or none, and only the last one painted was right: forge/web's background, dock and windows came and went in a WPE browser while Chromium showed them all. A test page with two canvases fed from one offscreen canvas showed their colours swapped in WPE and right with a copy. Each surface's canvas is now a `2d` one, and `present_copy` copies the offscreen canvas with `drawImage` in `copy` composition, set before every copy since resizing a canvas resets its context, so a transparent frame replaces the last rather than leaving trails behind an animation. Subsurfaces go the same way. `WebSurfaceState` loses `bitmap` and `new` its `gpu` argument, and web-sys loses the `ImageBitmap` and `ImageBitmapRenderingContext` features.
CHANGELOG records each of these.
Not done. The tests that build `LaidOutWidget` by hand set the new field, but no test covers a press on a non-focusable button, the focus following an id or the ring. For one frame after a rebuild moves the focused widget, its ring and its cursor may show in the old place.
2026-11-01 09:19:23 +01:00

1132 lines
37 KiB
Markdown

# ltk cookbook
Concrete recipes for patterns that come up often when building real
applications and shells with `ltk`. Each recipe pairs a sketch of the
problem with a copy-pasteable shape of the solution and pointers to the
relevant APIs.
If you are looking for the mental model, read
[`docs/onboarding.md`](./onboarding.md) and
[`docs/architecture.md`](./architecture.md) first. For per-widget
reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
[`docs/theming.md`](./theming.md).
## Table of contents
- [Responsive sizing across mobile / tablet / desktop](#responsive-sizing-across-mobile--tablet--desktop)
- [Slide-in panel](#slide-in-panel)
- [Password field with PAM submit](#password-field-with-pam-submit)
- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay)
- [Runtime light / dark theme toggle](#runtime-light--dark-theme-toggle)
- [Icon launcher with WrapGrid](#icon-launcher-with-wrapgrid)
- [Channel-driven external state](#channel-driven-external-state)
- [Toast / OSD with auto-expiry](#toast--osd-with-auto-expiry)
- [Tab navigation between widgets](#tab-navigation-between-widgets)
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern)
- [Surviving relaunch: session state](#surviving-relaunch-session-state)
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
- [Custom CPU drawing and path clipping](#custom-cpu-drawing-and-path-clipping)
- [Projecting an externally-laid-out view tree](#projecting-an-externally-laid-out-view-tree)
---
## Responsive sizing across mobile / tablet / desktop
Size everything as a fraction of the surface so one view reads
coherently from a portrait phone to a landscape desktop window,
without per-target forks. The rule is *fluid but clamped*: a `vmin`
percentage tracks the surface's smaller side, and a px `clamp` keeps
it from collapsing on a tiny screen or ballooning on a 4K monitor.
Use `Length::orient( portrait, landscape )` when the *proportion*
itself should differ between orientations, and `Image::short_side` to
size a logo along the screen's short side while preserving its aspect
ratio.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ column, img_widget, text, Length, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( logo: Arc<Vec<u8>>, lw: u32, lh: u32 ) -> Element<Msg> {
column::<Msg>()
.padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) )
.spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) )
// Logo: 40 % of the width in portrait, 5 % of the height in landscape.
.push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) )
// Heading: fluid, but never below 20 px nor above 44 px.
.push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) )
.into()
# }
```
Fluid units scale with the screen's pixels, not real-world
millimetres. For sizes that must stay physically constant across an
open-ended device set — touch targets, text that honours the user's
font-size preference — use `Length::dp` or `Length::em`. For text
that should stay fluid, reach for the
[`typography`](../src/theme/typography.rs) scale (`h0`…`body_xs`)
instead of hand-rolling raw `vmin`: its ramp is clamped-`vmin`,
tuned for running text. Reserve
`view()`-level branching on `surface_width` / `surface_height` for
genuine layout restructuring (sidebar → bottom tabs), not for sizing.
---
## Slide-in panel
A quick-settings or notification panel that slides down from the top of
the screen and dissolves into transparency at its leading edge so the
edge does not knife-cut against the layer below.
```rust,no_run
# use std::time::Instant;
# use ltk::{ container, text, viewport, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_QS: OverlayId = OverlayId( 1 );
# const SLIDE_DURATION: f32 = 0.25;
# #[ derive( Clone ) ] enum Msg { CloseQs }
# struct App { qs_started: Option<Instant>, surface_width: u32, surface_height: u32 }
# impl App {
# fn quick_settings_view( &self ) -> Element<Msg> { text( "qs" ).into() }
fn build_quick_settings_overlay( &self ) -> OverlaySpec<Msg>
{
// Compute the slide progress based on a stored start instant. While
// animating, `is_animating()` returns `true` so the runtime redraws
// every compositor frame (~60 Hz on GLES; capped at ~30 Hz on the
// software backend, see `cap_software_animation`) and reads the new
// progress each time.
let progress = match self.qs_started
{
Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ),
None => 1.0,
};
let panel_height = self.surface_height as f32 * 0.85;
let visible_h = panel_height * progress;
// Feather the bottom edge during the slide; drop the fade once the
// panel is fully open so the bottom of a settled panel is hard.
let fade_px = if progress < 1.0 { 16.0 } else { 0.0 };
let panel: Element<Msg> = container( self.quick_settings_view() )
.surface( "surface-card" )
.padding( 24.0 )
.into();
OverlaySpec
{
id: OVERLAY_QS,
layer: Layer::Overlay,
anchor: Anchor::TOP,
size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: viewport( panel )
.height( panel_height )
.fade_bottom( fade_px )
.into(),
on_dismiss: Some( Msg::CloseQs ),
anchor_widget_id: None,
}
}
fn is_animating( &self ) -> bool
{
self.qs_started
.map( |t| t.elapsed().as_secs_f32() < SLIDE_DURATION )
.unwrap_or( false )
}
# }
```
Set `LTK_PERF_WARN=1` during development to be warned when `is_animating()`
sticks at `true` after an animation should have settled.
The `fade_bottom( px )` builder is a GLES-only effect; the software
backend renders a hard edge. If your shell must look identical on both
backends, branch on `ltk::is_software_render()` and skip the fade
when the software path is active.
If the panel is a fixed-size pill on a much larger surface (a phone-shaped quick-settings card pinned to a corner of a desktop monitor), add `.local_viewport()` to the `viewport` so `vw` / `vmin` and fluid sizes inside resolve against the pill rect instead of the whole surface — without it the panel's content scales with the monitor and overflows the pill.
**See also**: [`Viewport`](./widgets.md#viewport),
[`OverlaySpec`](../src/app.rs).
---
## Password field with PAM submit
A login screen where the user types a password, presses Enter, and the
app forwards the submission to a background thread that runs PAM.
```rust,no_run
# use ltk::{ button, column, text, text_edit, App, ChannelSender, Element };
# #[ derive( Clone ) ] enum Msg {
# UsernameChanged( String ), PasswordChanged( String ),
# Submit, AuthResult( bool ),
# }
# fn pam_authenticate( _user: &str, _pass: &str ) -> bool { true }
struct LoginApp
{
username: String,
password: String,
sender: Option<ChannelSender<Msg>>,
}
impl App for LoginApp
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Login" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
column()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "Sign in" ).size( 28.0 ) )
.push(
text_edit( "Username", &self.username )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.secure( true ) // mask glyphs + zeroize on drop
.on_change( |s| Msg::PasswordChanged( s ) )
.on_submit( Msg::Submit ), // Enter fires this
)
.push( button( "Log in" ).on_press( Msg::Submit ) )
.into()
}
fn set_channel_sender( &mut self, s: ChannelSender<Msg> )
{
// Saved once at startup; cloned into worker threads so they can
// wake the loop without polling.
self.sender = Some( s );
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::UsernameChanged( s ) => self.username = s,
Msg::PasswordChanged( s ) => self.password = s,
Msg::Submit =>
{
let username = self.username.clone();
let password = self.password.clone();
let sender = self.sender.clone().unwrap();
std::thread::spawn( move ||
{
let result = pam_authenticate( &username, &password );
let _ = sender.send( Msg::AuthResult( result ) );
} );
// Clear the visible field so the user has feedback;
// `secure( true )` zeroizes the buffer when the next
// view() rebuild drops the old TextEdit.
self.password.clear();
}
Msg::AuthResult( true ) => std::process::exit( 0 ),
Msg::AuthResult( false ) => { /* show error */ }
}
}
}
```
`secure( true )` does two things:
1. Renders bullets instead of the actual characters.
2. Wipes the underlying byte buffer with zeroes when the
[`TextEdit`](./widgets.md#text_edit) (and the per-frame handler
snapshot) is dropped — the heap allocation that used to hold the
credential is overwritten before it returns to the allocator.
`text_edit::on_submit` fires when the user presses Enter inside the
field; it is wired to the same `Msg::Submit` the explicit button uses
so both keyboard and pointer paths converge on the same `update` arm.
The cleanup of `self.password` is the application's responsibility once
authentication completes. `ltk` cannot guarantee a clean memory image
on its own — for the strongest guarantees clear long-lived state in
your `Drop` impl too.
**See also**: [`SECURITY.md`](../SECURITY.md), the
[`text_edit`](./widgets.md#text_edit) widget, and
[`crate::ChannelSender`](../src/channel.rs).
---
## Swipe-to-dismiss overlay
A modal panel that closes when the user swipes down past a threshold or
taps outside the panel.
```rust,no_run
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_MODAL: OverlayId = OverlayId( 2 );
# #[ derive( Clone ) ] enum Msg { CloseModal }
# struct App { modal_open: bool, modal_drag_progress: f32 }
# impl App {
# fn modal_body( &self ) -> Element<Msg> { text( "modal" ).into() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
if !self.modal_open { return vec![]; }
// The modal body sits inside a column capped at 400 px so it stays
// legible on wide displays; the outer column with two spacers
// centres it vertically.
let modal: Element<Msg> = column()
.max_width( 400.0 )
.push(
container( self.modal_body() )
.surface( "surface-card" )
.padding( 24.0 ),
)
.into();
vec![
OverlaySpec
{
id: OVERLAY_MODAL,
layer: Layer::Overlay,
anchor: Anchor::ALL,
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None, // accept input
view: column()
.center_y( true )
.push( spacer() )
.push( modal )
.push( spacer() )
.into(),
on_dismiss: Some( Msg::CloseModal ), // tap outside dismisses
anchor_widget_id: None,
},
]
}
// Swipe-down gesture (only fires inside the overlay because the main
// surface does not declare a down-swipe target).
fn on_swipe_down( &mut self ) -> Option<Msg>
{
Some( Msg::CloseModal )
}
fn on_swipe_down_progress( &mut self, progress: f32 )
{
// Optional follow-the-finger feedback: store the in-progress value
// and use it in view() to translate or fade the modal contents.
self.modal_drag_progress = progress;
}
# }
```
`on_dismiss` is the runtime's "tap outside the panel" hook — it fires
for any release on the overlay surface that did not land on an
interactive widget. The runtime also fires it on a press on the main
surface that does not hit the trigger (relevant for xdg-popup overlays
under compositors that do not break the popup grab on parent-surface
clicks) and on Escape with an xdg-popup open. Pair with `on_swipe_down`
for a follow-the-finger gesture, and `on_swipe_down_progress` if you
want the modal to track the finger before commit.
**See also**: [`OverlaySpec`](../src/app.rs),
[`docs/architecture.md`](./architecture.md#surface-composition).
---
## Runtime light / dark theme toggle
A "switch theme" affordance that flips the active mode without a
restart.
```rust,no_run
# use ltk::{ button, Element, ThemeMode };
# #[ derive( Clone ) ] enum Msg { ToggleTheme }
# struct App;
# impl App {
fn view( &self ) -> Element<Msg>
{
let label = match ltk::active_mode()
{
ThemeMode::Light => "Switch to dark",
ThemeMode::Dark => "Switch to light",
};
button( label ).on_press( Msg::ToggleTheme ).into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::ToggleTheme =>
{
let new = match ltk::active_mode()
{
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::Dark => ThemeMode::Light,
};
ltk::set_active_mode( new );
// No further action — the next render reads the new mode
// through the slot helpers and recomposes the surface.
}
}
}
# }
```
`set_active_mode` mutates a process-global cell; the next `view()`
rebuild reads the new mode through the per-slot helpers
(`theme_palette`, `theme_surface`, `theme_paint`, etc.) and
renders against the new colours. There is no manual invalidation step.
For a full theme swap, load a different `ThemeDocument` and apply it:
```rust,no_run
# fn _ex() {
let doc = ltk::ThemeDocument::find( "midnight" )
.expect( "midnight theme not installed" );
ltk::set_active_document( doc );
# }
```
**See also**: [`docs/theming.md`](./theming.md#using-the-theme-from-app-code).
---
## Icon launcher with WrapGrid
An app drawer that displays an N-column grid of icon buttons, scrolls
when content overflows, and caches decoded icons across frames.
```rust,no_run
# use std::cell::RefCell;
# use std::collections::HashMap;
# use std::sync::Arc;
# use ltk::{ grid, icon_button, scroll, App, Element };
# #[ derive( Clone ) ] enum Msg { Launch( String ) }
# struct DesktopEntry { id: String, icon_path: String }
# fn decode_icon( _path: &str ) -> ( Arc<Vec<u8>>, u32, u32 ) {
# ( Arc::new( vec![ 0; 4 ] ), 1, 1 )
# }
struct LauncherApp
{
apps: Vec<DesktopEntry>,
icon_cache: RefCell<HashMap<String, ( Arc<Vec<u8>>, u32, u32 )>>,
}
impl App for LauncherApp
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Launcher" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
let mut grid = grid::<Msg>( 4 )
.padding( 16.0 )
.spacing( 12.0 );
for app in &self.apps
{
// Decode icons once on first reference; reuse the Arc on
// every subsequent frame for a pointer copy.
let ( bytes, w, h ) = self
.icon_cache
.borrow_mut()
.entry( app.id.clone() )
.or_insert_with( || decode_icon( &app.icon_path ) )
.clone();
grid = grid.push(
icon_button( bytes, w, h )
.on_press( Msg::Launch( app.id.clone() ) ),
);
}
scroll( grid ).into()
}
fn update( &mut self, _msg: Msg ) { /* ... */ }
}
```
The `RefCell<HashMap<…>>` cache is single-threaded — the runtime is
single-threaded, so `Mutex` would only add lock overhead. Decoded
icons are kept as `Arc<Vec<u8>>` so building the widget is a pointer
clone instead of a full byte clone.
**See also**: [`grid`](./widgets.md#grid),
[`scroll`](./widgets.md#scroll),
[`docs/architecture.md`](./architecture.md#larger-state-patterns).
---
## Channel-driven external state
Surface external events (D-Bus signals, file watches, timers) into the
event loop without busy-polling.
```rust,no_run
# use std::time::{ Duration, Instant };
# use ltk::ChannelSender;
# #[ derive( Clone ) ] struct BatteryEvent;
# fn wait_for_battery_event() -> BatteryEvent { BatteryEvent }
# struct OsdToast { expires_at: Instant }
# #[ derive( Clone ) ] enum Msg { BatteryChanged( BatteryEvent ), HideToast }
# struct App { sender: Option<ChannelSender<Msg>>, toast: Option<OsdToast> }
# impl App {
fn set_channel_sender( &mut self, sender: ChannelSender<Msg> )
{
// Saved once and never changed.
self.sender = Some( sender.clone() );
// Spawn the worker that watches for external events and forwards
// them as messages. Substitute `wait_for_battery_event` with whatever
// blocks for your real source — a D-Bus signal, a file watch, a
// socket read, a timer, etc.
std::thread::spawn( move ||
{
loop
{
// Block until the external source produces an event. When it
// arrives, post a message into the loop.
let event = wait_for_battery_event();
let _ = sender.send( Msg::BatteryChanged( event ) );
}
} );
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// For state that doesn't need a dedicated thread (file mtime checks,
// expiry sweeps), drain it here. Called after every Wayland event
// and every poll_interval tick.
let mut msgs = vec![];
if let Some( osd ) = self.toast.as_ref()
{
if osd.expires_at <= Instant::now()
{
msgs.push( Msg::HideToast );
}
}
msgs
}
fn poll_interval( &self ) -> Option<Duration>
{
// Wake every minute to re-check the clock display. Keep this `None`
// unless you actually need a wall-clock tick — it costs battery
// life on mobile targets.
Some( Duration::from_secs( 60 ) )
}
# }
```
The `set_channel_sender` hook gives you a clone-able sender that wakes
the event loop from another thread without busy-waiting. `poll_external`
runs in the loop's own thread after each tick, so anything that costs
CPU but does not block (TTL checks, RefCell snapshot diffs) is fine
there.
**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns).
---
## Toast / OSD with auto-expiry
A short-lived overlay that fades out after a fixed duration without
blocking other UI.
```rust,no_run
# use std::time::Instant;
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
# #[ derive( Clone ) ] enum Msg {}
struct AppState
{
toast: Option<Toast>,
// ...
}
struct Toast
{
text: String,
started: Instant,
}
const TOAST_DURATION: f32 = 2.0;
const TOAST_FADE: f32 = 0.25;
impl AppState
{
# fn main_view( &self ) -> Element<Msg> { text( "main" ).into() }
// ...
}
impl App for AppState
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Toast" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> { self.main_view() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
let toast = match &self.toast
{
Some( t ) => t,
None => return vec![],
};
let elapsed = toast.started.elapsed().as_secs_f32();
let alpha = if elapsed >= TOAST_DURATION
{
// Fade-out window: 0.25 s after expiry the alpha hits 0.
( 1.0 - ( elapsed - TOAST_DURATION ) / TOAST_FADE ).clamp( 0.0, 1.0 )
} else { 1.0 };
vec![
OverlaySpec
{
id: OVERLAY_TOAST,
layer: Layer::Overlay,
anchor: Anchor::BOTTOM,
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: Some( vec![] ), // pass-through
view: container( text( &toast.text ).color( Color::WHITE ) )
.surface( "surface-panel" )
.padding( 12.0 )
.opacity( alpha )
.into(),
on_dismiss: None,
anchor_widget_id: None,
},
]
}
fn update( &mut self, _msg: Msg ) {}
fn is_animating( &self ) -> bool
{
// Redraw every compositor frame (capped at ~30 Hz on the
// software backend) while the toast is visible or fading.
self.toast.is_some()
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// Drop the toast once the fade window completes.
if let Some( t ) = &self.toast
{
if t.started.elapsed().as_secs_f32() >= TOAST_DURATION + TOAST_FADE
{
self.toast = None;
}
}
vec![]
}
}
```
Two ideas worth noting:
- `input_region: Some( vec![] )` makes the overlay pass-through: pointer
events fall through to whatever surface is below. The toast does not
steal taps from the main UI.
- The fade-out cleanup belongs in `poll_external`, not `view()`. `view`
must stay pure — read state, build a tree, no mutation.
**See also**: [`docs/architecture.md`](./architecture.md#animations).
---
## Tab navigation between widgets
The runtime ships Tab / Shift+Tab traversal automatically; you only need
to set up programmatic focus when an external event should land focus on
a specific widget.
```rust,no_run
# use ltk::{ column, text_edit, App, Element, WidgetId };
# #[ derive( Clone ) ] enum Msg {
# UsernameChanged( String ), PasswordChanged( String ), AuthFailed,
# }
const FIELD_USERNAME: WidgetId = WidgetId( "username" );
const FIELD_PASSWORD: WidgetId = WidgetId( "password" );
struct LoginApp
{
username: String,
password: String,
pending_focus: Option<WidgetId>,
// ...
}
impl App for LoginApp
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Login" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
column()
.push(
text_edit( "Username", &self.username )
.id( FIELD_USERNAME )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.id( FIELD_PASSWORD )
.secure( true )
.on_change( |s| Msg::PasswordChanged( s ) ),
)
.into()
}
fn take_focus_request( &mut self ) -> Option<WidgetId>
{
// Returned once; the runtime focuses that widget on the next
// frame. Subsequent calls return None.
self.pending_focus.take()
}
fn update( &mut self, msg: Msg )
{
if matches!( msg, Msg::AuthFailed )
{
// Clear the password and put focus back on the field so the
// user can retype without a click.
self.password.clear();
self.pending_focus = Some( FIELD_PASSWORD );
}
}
}
```
`take_focus_request` is consumed once: the runtime dispatches focus to
the returned id and the next call returns `None` because the slot was
drained. The application owns "when should focus move". The target may
be added to the view by the same `update` that requests it, and may sit
inside a `scroll` below the fold: the runtime waits a few frames for it
and moves the viewport so the widget is visible when it takes focus.
Requesting focus for the text field that already has it puts its cursor at the end of the value, without firing its `on_focus` again. That is how an application moves the cursor after replacing the value itself, for instance after completing a path on Tab: set the new value in `update` and return the field's id from `take_focus_request`. A field built with `select_on_focus( true )` selects its whole value instead.
Tab and Shift+Tab traverse focusable widgets in declaration order — no
opt-in needed beyond the widget being interactive. While a text field has the focus, Tab and the vertical arrows still reach `App::on_key_with_modifiers` first, so an application can claim them, for completion say: return a message for the key and ltk neither moves the focus nor the cursor. Escape reaches it too, after ltk has closed any transient UI and taken the focus off the field.
**See also**: [`WidgetId`](../src/types.rs),
[`tests/tab_navigation.rs`](../tests/tab_navigation.rs).
---
## Multi-screen app via sub-state pattern
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. 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, BackResponse, Element, NavStack };
# #[ derive( Clone, Copy, PartialEq ) ] enum Screen { Home, Settings, About }
# #[ derive( Clone ) ] enum HomeMsg {}
# #[ derive( Clone ) ] enum SettingsMsg {}
# struct HomeState;
# struct SettingsState;
# fn home_view( _: &HomeState ) -> Element<HomeMsg> { text( "home" ).into() }
# fn home_update( _: &mut HomeState, _: HomeMsg ) {}
# fn settings_view( _: &SettingsState ) -> Element<SettingsMsg> { text( "settings" ).into() }
# fn settings_update( _: &mut SettingsState, _: SettingsMsg ) {}
# fn about_view() -> Element<AppMsg> { text( "about" ).into() }
# fn nav_bar( _: Screen ) -> Element<AppMsg> { text( "nav" ).into() }
#[derive(Clone)]
enum AppMsg
{
Open( Screen ),
Back,
Home( HomeMsg ),
Settings( SettingsMsg ),
}
struct AppState
{
nav: NavStack<Screen>,
home: HomeState,
settings: SettingsState,
}
impl Screen
{
fn name( self ) -> &'static str
{
match self { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" }
}
fn parse( name: &str ) -> Option<Self>
{
[ Screen::Home, Screen::Settings, Screen::About ].into_iter().find( |s| s.name() == name )
}
}
impl App for AppState
{
type Message = AppMsg;
fn app_id( &self ) -> &str { "net.example.MultiScreen" }
// Persist the whole route stack, root first, one screen per line:
// after a restore, back retraces the same screens. With serde in the
// app, `serde_json::to_vec( &self.nav )` does the same in one call.
fn save_state( &self ) -> Option<Vec<u8>>
{
let lines: Vec<&str> = self.nav.iter().map( |s| s.name() ).collect();
Some( lines.join( "\n" ).into_bytes() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
let Ok( text ) = String::from_utf8( state ) else { return };
let Some( screens ) = text.lines().map( Screen::parse ).collect::<Option<Vec<_>>>() else { return };
let mut screens = screens.into_iter();
let Some( root ) = screens.next() else { return };
self.nav.reset( root );
for screen in screens { self.nav.push( screen ); }
}
// The same value answers the system back button.
fn on_back( &mut self ) -> BackResponse<AppMsg> { self.nav.back( AppMsg::Back ) }
fn view( &self ) -> Element<AppMsg>
{
let 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 ),
Screen::About => about_view(),
};
column()
.push( nav_bar( current ) )
.push( body )
.into()
}
fn update( &mut self, msg: AppMsg )
{
match msg
{
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 ),
}
}
}
```
Each screen owns its `view( state ) -> Element<SubMsg>` and
`update( state, msg )` functions; the parent wraps the sub-message in
the outer enum on the way out and unwraps it on the way in. There is no
runtime-level "screen" abstraction in `ltk` because the widget tree is
already cheap to rebuild every frame — the dispatch above is just three
function calls.
In a real project each screen lives in its own file (`src/home.rs`,
`src/settings.rs`, `src/about.rs`) declared as `pub mod home;` etc. in
`lib.rs`, and the call sites become `home::view( &self.home )` and
`home::update( &mut self.home, m )` instead of the flat
`home_view` / `home_update` shape used above. The snippet is flat so
that `doctest-md` can typecheck the dispatch without needing one file
per screen.
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).
---
## Surviving relaunch: session state
The runtime persists whatever `App::save_state` returns and hands it
back through `App::restore_state` before the first frame — but only
when the shell relaunches the app as part of a session restore
(`LTK_SESSION_RESTORE=1` in the environment) or when the previous run
did not exit cleanly. A plain launch starts fresh, Android-style;
window size and position still come back on every launch through
`xdg-session-management-v1`.
An app whose state the user expects back on every launch, however the last run ended, as a browser's tabs, returns `true` from `App::restore_on_launch`; the runtime then restores on a plain launch too. `restore_state` should then merge what the launch itself asked for, a URL or file on the command line, into the restored state instead of letting the restore replace it.
The bytes are yours: version them, and treat a parse failure as "keep
the defaults". No serde needed for small state.
```rust,no_run
# use ltk::{ column, text, text_edit, App, Element };
#[derive(Clone)]
enum Msg { Tab( usize ), Draft( String ) }
struct Editor { tab: usize, draft: String }
impl App for Editor
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Editor" }
fn save_state( &self ) -> Option<Vec<u8>>
{
// Line 1 is the format version; the draft goes last so it may
// contain newlines.
Some( format!( "1\n{}\n{}", self.tab, self.draft ).into_bytes() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
let Ok( text ) = String::from_utf8( state ) else { return };
let mut parts = text.splitn( 3, '\n' );
if parts.next() != Some( "1" ) { return; }
if let Some( tab ) = parts.next().and_then( |t| t.parse().ok() ) { self.tab = tab; }
if let Some( draft ) = parts.next() { self.draft = draft.to_string(); }
}
fn view( &self ) -> Element<Msg>
{
column()
.push( text( format!( "tab {}", self.tab ) ) )
.push( text_edit( "Draft", &self.draft ).on_change( Msg::Draft ) )
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Tab( t ) => self.tab = t,
Msg::Draft( d ) => self.draft = d,
}
}
}
```
The runtime saves in three situations: every ~30 s while the bytes
differ from the last save, when the window closes, and on `SIGTERM` /
`SIGINT` (which ltk turns into a clean exit of the event loop). Files
land in `$XDG_STATE_HOME/<app_id>/` — `state.bin` for your bytes,
`session.json` for the compositor session id and the clean-exit marker.
To try it: run the app, change something, `kill -TERM $(pidof my-app)`,
then start it again with `LTK_SESSION_RESTORE=1 my-app` — the state is
back. Start it without the variable and it opens fresh (the window
still lands where you left it). `kill -KILL` instead of `-TERM` and the
next plain launch restores too, because the marker says the last run
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.
---
## Embedding ltk without `ltk::run`
A compositor or embedder that already owns the Wayland connection and
just wants ltk's layout, rendering and hit-testing.
```rust,no_run
# use ltk::{ button, Color, Element, Rect };
# use ltk::core::{ Canvas, RenderOptions, UiSurface };
# #[ derive( Clone ) ] enum Msg { Tick }
# struct App;
# impl App { fn view( &self ) -> Element<Msg> { button( "x" ).into() }
# fn update( &mut self, _: Msg ) {} }
# struct Event;
# impl Event { fn into_msg( self ) -> Msg { Msg::Tick } }
# struct Queue( Vec<Event> );
# impl Queue { fn drain( &mut self ) -> std::vec::Drain<'_, Event> { self.0.drain( .. ) } }
# fn present_argb8888( _: &[u8] ) {}
# fn wl_damage( _: &Rect ) {}
# fn _ex( width: u32, height: u32, mut app: App, mut input_queue: Queue, pos_x: f32, pos_y: f32 ) {
let mut surface = UiSurface::<Msg>::new( width, height );
loop
{
// 1. Drain pending app events from your own input source.
for ev in input_queue.drain() { app.update( ev.into_msg() ); }
// 2. Build the tree and render.
let view = app.view();
let out = surface.render(
&view,
RenderOptions::full_canvas( width, height )
.background( Color::TRANSPARENT ),
);
// 3. Pull pixels (software backend) or present the FBO (GLES).
match surface.canvas()
{
Canvas::Software( _ ) =>
{
let mut buf = vec![ 0u8; ( width * height * 4 ) as usize ];
surface.canvas().write_to_wayland_buf( &mut buf, false );
present_argb8888( &buf );
}
Canvas::Gles( _ ) =>
{
// Already drawn into the FBO the embedder owns; commit
// through your own EGL context.
}
}
// 4. Use damage rects to feed wl_surface.damage_buffer if you are
// on the software path.
for rect in &out.damage_rects { wl_damage( rect ); }
// Pointer dispatch: turn a screen-space point into the widget under it.
let hit = surface.hit_test( ltk::Point { x: pos_x, y: pos_y } );
if let Some( idx ) = hit
{
if let Some( msg ) = surface.handlers( idx ).and_then( |h| h.press_msg() )
{
app.update( msg );
}
}
# break;
}
# }
```
`UiSurface` keeps the focus / hover / pressed state, the cursor map,
the scroll-offset table, and the per-frame widget rects. Set
`set_focused( Some( idx ) )` etc. from your own input handler and the
next render automatically uses the partial-damage path when only
interaction state changed.
**See also**: [`tests/core_surface.rs`](../tests/core_surface.rs) for
the full set of supported operations,
[`docs/onboarding.md`](./onboarding.md#when-to-use-coreuisurface).
---
## Custom CPU drawing and path clipping
Painting something the widget set does not cover — a gauge, a
`VectorDrawable`, a Lottie frame, a shaped avatar — straight onto the
canvas, identically on the GLES and software backends. `External::cpu`
gives you a closure invoked once per frame with the `Canvas` and the
widget's laid-out rect; `set_clip_path` then clips arbitrary draws to a
vector path with an anti-aliased edge.
```rust,no_run
# use ltk::{ External, Element, Color, PathCmd };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
External::cpu( 96.0, 96.0, |canvas, rect|
{
// Background, unclipped.
canvas.fill_rect( rect, Color::rgb( 0.10, 0.10, 0.12 ), 0.0 );
// Snapshot the outer clip so the path clip does not leak into the
// rest of the frame, then clip the foreground to a circle.
let saved = canvas.clip_bounds();
let r = rect.width.min( rect.height ) / 2.0;
let ( cx, cy ) = ( rect.x + rect.width / 2.0, rect.y + rect.height / 2.0 );
let k = r * 0.5523;
canvas.set_clip_path( &[
PathCmd::MoveTo( cx, cy - r ),
PathCmd::CubicTo( cx + k, cy - r, cx + r, cy - k, cx + r, cy ),
PathCmd::CubicTo( cx + r, cy + k, cx + k, cy + r, cx, cy + r ),
PathCmd::CubicTo( cx - k, cy + r, cx - r, cy + k, cx - r, cy ),
PathCmd::CubicTo( cx - r, cy - k, cx - k, cy - r, cx, cy - r ),
PathCmd::Close,
] );
canvas.fill_rect( rect, Color::rgb( 0.20, 0.50, 0.95 ), 0.0 );
canvas.set_clip_rects( &saved );
} ).into()
# }
```
The path clip is bracketed: `set_clip_path` installs it and
`set_clip_rects( &saved )` flushes it (on GLES this is when the offscreen
layer is composited). `clip_bounds` snapshots the prior clip first so an
outer clip is restored rather than dropped. `fill_path` / `stroke_path`
take the same `PathCmd` list to paint a path instead of clipping to it.
**See also**: [`examples/clip_path.rs`](../examples/clip_path.rs) (rounded
rect, circle, triangle — same smooth result on both backends).
---
## Projecting an externally-laid-out view tree
Hosting a widget tree whose geometry is computed elsewhere — an Android
measure/layout pass, say, which yields one absolute rect per view —
projected onto an ltk `stack` in paint order. `measure_text` gives the
external layout the same metrics the renderer will use, without a live
`Canvas`; `push_placed` drops each child at its exact rect; and
`push_placed_clipped` clips a child's overflow like Android's
`clipChildren`.
```rust,no_run
# use ltk::{ measure_text, stack, text, Element, Rect };
# #[ derive( Clone ) ] enum Msg {}
# struct View { label: String, rect: Rect, clip: Option<Rect> }
# fn external_layout_pass() -> Vec<View> { Vec::new() }
# fn _ex() -> Element<Msg> {
// The external layout pass measures text with the renderer's own metrics.
let ( w, line_h ) = measure_text( "Inbox", 16.0 );
let _ = ( w, line_h );
let views = external_layout_pass();
let mut s = stack();
for v in views
{
let child = text( v.label );
s = match v.clip
{
Some( clip ) => s.push_placed_clipped( child, v.rect, clip ),
None => s.push_placed( child, v.rect ),
};
}
s.into()
# }
```
To rasterise the result into a caller-owned buffer instead of presenting
it, render through a [`core::UiSurface`](#embedding-ltk-without-ltkrun)
and call `Canvas::read_rgba_pixels( &mut buf )` — it fills the buffer
with tightly packed straight-alpha RGBA8 (top-left row first) and
returns `Result<(), String>`, on either backend
(the software path un-premultiplies for you). Branch on
`Canvas::is_software()` when a draw must honour a real path clip on
software but only a bounding rect on GLES.