Files
ltk/docs/cookbook.md
Pedro M. de Echanove Pasquin 1fd697aa6d
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
docs overhaul, orientation API, fluid-sizing fixes, examples made honest
Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved.
New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md.
Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event.
Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
2026-07-30 19:28:26 +02:00

30 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 (h0body_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.

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

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.

# use ltk::{ column, text, App, Element };
# #[ derive( Clone, Copy ) ] 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
{
	Nav( Screen ),
	Home( HomeMsg ),
	Settings( SettingsMsg ),
}

struct AppState
{
	current:  Screen,
	home:     HomeState,
	settings: SettingsState,
}

impl App for AppState
{
	type Message = AppMsg;

	fn view( &self ) -> Element<AppMsg>
	{
		let body = match self.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( self.current ) )
			.push( body )
			.into()
	}

	fn update( &mut self, msg: AppMsg )
	{
		match msg
		{
			AppMsg::Nav( s )       => self.current = s,
			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.

See also: docs/architecture.md.


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.