Files
ltk/docs/cookbook.md
T
admin 96a9e88051
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Back as a navigation request: App::on_back, BackAtRoot, NavStack and docs/navigation.md; renew the text-input enable on a press; fit an oversized window_size_hint to the screen
Three changes that came out of making the shell's back button work across Eydos the way it does on Android. The first is the feature; the other two are gaps it exposed.
Back is no longer Escape. forge turns its back gesture and button into the `XF86Back` key for the focused client, and ltk treated that key as an alias of `Escape`: close transient UI, else drop the focus and reach `on_key_with_modifiers`. That is "cancel", and it is all an app could get: nothing navigated, and nothing ever left the app. `XF86Back` is now a back request with its own order, one layer per request, top-most first: transient UI the runtime owns (open xdg-popup overlays, the built-in context menu, a text selection, the top-most pressable carrying an `on_escape`, i.e. a dialog's cancel), then text-input focus, then the new `App::on_back`, then `on_key_with_modifiers( XF86_Back )` so an app that bound the key itself keeps receiving it (crustace does, for its launcher), then `App::back_at_root`. The first step is what Escape already did; it moves out of `handle_key_escape` into `AppData::dismiss_transient` in the new `input/keyboard/back.rs`, which both paths call, so Escape behaves exactly as before. Text focus comes before the app on purpose: with a keyboard covering half the screen, back means "put the keyboard away", and navigating instead would discard what the user was typing. A compositor that folds its keyboard itself, as forge now does, only lets the next request through, which makes this step the fallback for the ones that do not.
`App::on_back` returns a `BackResponse`: `Message( msg )` with the message that undoes the top-most piece of navigation state, `Unhandled` at the root, or `Consumed` for a host whose back stack lives elsewhere and cannot be inspected ahead of time. `Consumed` exists for rustdroid: an Android app keeps its activities, fragments and back callbacks in its own code, so the host can only hand the request over. `App::back_at_root` then picks `Minimize` (the default), `Close` (through `on_close_requested`, which can still veto) or `Ignore`. Minimising is what a root activity does on Android since version 12 and what suits an app the user comes back to: a browser keeps its tabs, a call goes on. It is also the single point where the two stacks meet: the navigation stack belongs to the app's model, the task stack to the compositor, and `xdg_toplevel.set_minimized` is the one standard request between them, so no custom protocol carries navigation state out of the process and clients that are not ltk apps keep working. Only `ShellMode::Window` surfaces react at the root; a panel or a lock screen that minimised itself would be a bug.
Three consequences of back being a navigation request rather than input: the key leaves the default `App::key_repeats` set, since a held button would empty the whole stack; it is no longer reported through `App::on_raw_key`, so a host forwarding raw keys into an embedded web view or guest toolkit stops leaking it as a keystroke; and the mouse's back button (`BTN_SIDE` / `BTN_BACK`) enters through the same `handle_back`. `focused_text_input` becomes `pub( crate )` for the new module.
The answer is derived, not recorded, and that is the design decision worth writing down. The runtime keeps no navigation stack of its own. Its entries would have to be messages, which are not serialisable (the trait puts no serde bound on `Message`, by design), so it could not survive a relaunch; and a list of "things to undo" drifts the first time the user leaves a screen some other way, through its own close button or a link, leaving a back press that does nothing visible. An answer computed from the model cannot disagree with it. This is also what ties back to the session persistence added earlier: navigation state is both what `on_back` reads and what `save_state` should persist, and keeping the two the same is what makes back retrace the same places after a session restore or a crash, with nothing extra to serialise. Persist less and a restored app opens on the right screen with nothing behind it; persist more and it restores a menu the user had closed.
`NavStack<R>` packages that rule for the common case, an app made of screens. It is a plain value the app keeps in its model, never seen by the runtime: `push`, `pop`, `replace` (the sideways move back should not retrace), `pop_to_root`, `reset`, `current`, `current_mut`, `iter`, `depth`, `can_pop`, and `back( msg )`, which answers `on_back` directly. It is stored as a root plus the routes above it rather than as a `Vec`, so "never empty" holds by construction, including after deserialising an old or damaged state file, and it derives serde whenever `R` does. Six unit tests cover it, a serde round trip among them, and its rustdoc example is a complete `App` where one object answers `on_back`, `save_state` and `restore_state`.
Documentation, since the point is for app authors to take navigation state into account. The new `docs/navigation.md` is the reference: transient UI versus navigation state and the test that separates them, the back order and the reason for each step, why the answer is derived, how it lines up with session state, `NavStack`, apps that embed a web view (whose page history is navigation state too, to be persisted with ltk-webkit's `session_state`), hosts with a foreign back stack, the root, who owns which stack, and an author's checklist. `architecture.md` gains a group in the trait surface and a paragraph in the state patterns, `onboarding.md` and the README index link to it, and in the cookbook the multi-screen recipe now uses `NavStack` with `on_back` and persists the stack without serde, which keeps it compiled by the Markdown doctest harness (that harness links `ltk` alone, so the serde example in `navigation.md` is marked `ignore` and compiled as `NavStack`'s own doctest instead). `BackResponse`, `BackAtRoot` and `NavStack` are exported at the crate root and in the `ltk::window` facade.
Behaviour change for existing apps, also recorded under Changed: a window app that implements neither `on_back` nor the key is now minimised on back at its root, where nothing happened before; `BackAtRoot::Ignore` restores that. With a text input focused, back only drops the focus and the app sees the next request, where it used to get the key in the same press.
Renew the text-input enable on a press. Moving the focus between two text fields, or pressing the one already focused, only committed the refreshed content type on the existing `zwp_text_input_v3`. `activate_text_input` now sends `enable` first, as GTK does when a focused entry is clicked. A compositor that folded its on-screen keyboard while the field kept the focus, which is what back now does, has no other way to learn that the user wants it again: the text-input never stopped being active, so there was nothing for it to observe. ltk sends neither surrounding text nor a cursor rectangle, so the only state `enable` resets is the content type set right after it.
Fit an oversized `window_size_hint` to the screen. The hint is pinned as `min_size == max_size` before the first commit, when no output is known yet, so an app asking for a phone-shaped 720x1440 window on a 1080p desktop got a toplevel taller than the screen; rustdroid carried a dead branch that tried to avoid this from a screen size nothing could give it. The first configure is the earliest point the hint can be checked, and the protocol has the right input for it: `xdg_toplevel.configure_bounds`, exposed by sctk as `WindowConfigure::suggested_bounds` and ignored until now. The new `event_loop/window_fit.rs` holds `fit_within`, which scales a size down keeping its aspect ratio until it fits and leaves one that fits untouched (four unit tests), and `AppData::fit_size_hint`, run once behind the new `pending_size_hint_fit` latch: it uses the compositor's bounds when sent and otherwise 90 % of the first output's logical size, since panels and decorations are unknown from the client side. When the hint has to shrink, `min_size` (and `max_size` for a non-resizable window) is re-pinned to the fitted size, which is kept in `fitted_size_hint` and replaces the app's hint as the fallback for later configures that carry no size; without that the window would grow back on the next one. On that one configure the fitted size wins over the compositor's `new_size`, which was derived from the oversized pin. `App::window_size_hint` documents the rule with the worked example (720x1440 opens as 486x972 on a 1080p output).
The back order needs a live Wayland connection to exercise and has no test; the unit tests cover `NavStack` and `fit_within`.
2026-09-20 23:23:26 +02:00

36 KiB

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 and docs/architecture.md first. For per-widget reference, see docs/widgets.md. For theme JSON, see docs/theming.md.

Table of contents


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.

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

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


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.

# 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 (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, the text_edit widget, and crate::ChannelSender.


Swipe-to-dismiss overlay

A modal panel that closes when the user swipes down past a threshold or taps outside the panel.

# 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, docs/architecture.md.


Runtime light / dark theme toggle

A "switch theme" affordance that flips the active mode without a restart.

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

# fn _ex() {
let doc = ltk::ThemeDocument::find( "midnight" )
	.expect( "midnight theme not installed" );
ltk::set_active_document( doc );
# }

See also: docs/theming.md.


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.

# 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, scroll, docs/architecture.md.


Channel-driven external state

Surface external events (D-Bus signals, file watches, timers) into the event loop without busy-polling.

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


Toast / OSD with auto-expiry

A short-lived overlay that fades out after a fixed duration without blocking other UI.

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


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.

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

Tab and Shift+Tab traverse focusable widgets in declaration order — no opt-in needed beyond the widget being interactive.

See also: WidgetId, 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 holds it, so the system back button and a relaunch both work from the same value.

# 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 for the model behind on_back, and docs/architecture.md.


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.

The bytes are yours: version them, and treat a parse failure as "keep the defaults". No serde needed for small state.

# 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 explains the rule.

See also: App::save_state / App::restore_state rustdoc for the full contract, and docs/architecture.md 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.

# 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 for the full set of supported operations, docs/onboarding.md.


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.

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

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