First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

247
docs/architecture.md Normal file
View File

@@ -0,0 +1,247 @@
# ltk architecture
If you are new to the library, start with [`docs/onboarding.md`](./onboarding.md)
first. This document assumes you already know how to run an example and what
kind of application surface you are trying to build.
This document covers the patterns that the small `examples/` files cannot show: how a real application is structured on top of the [`App`] trait, how multiple surfaces coordinate, how theming is consumed, how to build animations, and where the cost of a frame actually lives.
For copy-pasteable patterns the canonical references are the two downstream consumers in the Eydos workspace:
- **crustace** (`crustace/src/`) — the Eydos shell. Layer-shell background surface + 8 overlays, system polling, MPRIS, notifications, animated OSD.
- **loginmanager** (`loginmanager/src/`) — greeter. `keyboard_exclusive`, single overlay, focus management, async PAM via `set_channel_sender`.
The rest of this document explains *why* those repos look the way they do.
If you are coming from `cargo doc`, keep the public API split in mind:
- `ltk::window` — normal application windows
- `ltk::shell` — layer-shell and overlays
- `ltk::runtime` — advanced runtime hooks and runtime-free embedding
This document mostly lives in the overlap between `ltk::shell` and
`ltk::runtime`. If you only want to build a plain app window, stay with
`docs/onboarding.md` and the `ltk::window` surface first.
## Mental model
ltk is Elm-shaped. The application is a value implementing [`App`]; ltk drives the loop and the application reacts.
Every frame: ltk calls `view()` and `overlays()`, lays out the returned tree(s), draws them, and dispatches input events back as `Message` values which are fed to `update()`. There are no retained widgets. `Element<Msg>` is rebuilt from scratch every frame from the application's own state.
This sounds expensive and is actually fine. The widget tree is plain enums, the layout pass is a single recursive walk that already has to happen anyway, and as of the `WidgetHandlers` snapshot work the input dispatch path no longer rebuilds the tree per event. The only thing the app must avoid in `view()` is *I/O* (reading files, scanning directories, walking icon caches) — keep those in `poll_external` or behind a `RefCell` cache.
In practice, that model is easiest to adopt in three steps:
1. Start with the `ltk::window` mental model: one app state, one `view()`, one `update()`, one normal window.
2. Add `ltk::shell` concepts only if you need layer-shell or overlays.
3. Reach for `ltk::runtime` hooks only when you need async wakeups, invalidation narrowing, or embedding outside `ltk::run()`.
## The trait surface, by purpose
`App` looks intimidating — most of it is opt-in. Group the methods by what you actually need:
**Always implement**
- `type Message` — your message enum.
- `view(&self) -> Element<Msg>` — main surface contents.
- `update(&mut self, msg: Msg)` — state transitions.
**Implement when your app is multi-surface**
- `overlays(&self) -> Vec<OverlaySpec<Msg>>` — see *Surface composition* below.
**Implement when your app is a shell component, not a window**
- `shell_mode()``ShellMode::Layer( Layer::Background | Bottom | Top | Overlay )`.
- `layer_anchor()`, `layer_size()`, `exclusive_zone()`, `keyboard_exclusive()` — the layer-shell knobs.
- `background_color()``Color::rgba( 0, 0, 0, 0 )` for transparent surfaces (panels, OSDs).
**Implement when external state matters**
- `set_channel_sender(sender)` — saved once at startup; clone into background threads to push messages into the loop without polling.
- `poll_external() -> Vec<Msg>` — called after every Wayland event *and* every `poll_interval()` tick. Drain receivers here.
- `poll_interval()``None` (event-driven only) or `Some( Duration )` (timer wakeups for clocks, expiry, etc.).
**Implement when input gestures matter**
- `on_swipe_up`, `on_swipe_down`, `on_swipe_progress`, `on_swipe_down_progress` (follow-the-finger).
- `on_tap` — taps that miss every widget.
- `on_key` / `on_key_with_modifiers` — global hotkeys.
- `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity.
**Implement for animations and focus**
- `is_animating()` — return `true` while a tween is running; the loop redraws at ~60 Hz.
- `take_focus_request()``Option<WidgetId>` — pull-once focus retargeting.
- `on_text_input_focused(active)` — surface IME state.
The defaults for everything else are sensible enough that a minimal app overrides only the four methods in the first group.
Another way to read the trait is by API layer:
- `ltk::window`: `view`, `update`, plus the widgets/layouts you use to build the tree.
- `ltk::shell`: `shell_mode`, `layer_anchor`, `layer_size`, `exclusive_zone`, `keyboard_exclusive`, `overlays`.
- `ltk::runtime`: `set_channel_sender`, `poll_external`, `poll_interval`, `invalidate_after`, `take_focus_request`, `is_animating`, and `core::UiSurface`.
That is the intended order of adoption for third-party users.
## Surface composition
The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpec<Msg>>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using [`OverlayId`]:
- Same id present last frame and this frame → keep the surface alive, only re-render its `view`.
- New id → create a new layer-shell surface.
- Id missing → destroy the surface.
This is why crustace declares stable `const OVERLAY_LAUNCHER: OverlayId = OverlayId(1)` etc. at the top of `app.rs`. Don't allocate ids dynamically — diffing relies on stability.
Each overlay carries its own `view`, `anchor`, `size`, `layer`, `keyboard_exclusive`, `input_region`, and `on_dismiss`. The `Message` type is shared with the main app: a button inside an overlay produces the same `Msg` that a button on the main surface would, and `update()` handles both. There is no per-overlay state machine — overlays are pure projections of `App` state.
`on_dismiss` is fired by three independent paths: a `popup_done` event from the compositor (xdg-popup mode); a pointer / touch press on the main surface that does not land on the trigger pointed at by `anchor_widget_id` while the overlay is mapped (covers compositors that route the button to the parent surface instead of breaking the popup grab); and Escape pressed while at least one xdg-popup overlay is open. The application only has to flip its `is_open` flag to `false` in `update()`; the runtime tolerates the message arriving more than once for the same open / close cycle.
Common patterns:
- *Modal panel*: `layer: Overlay`, `anchor: ALL`, `keyboard_exclusive: false`, `on_dismiss: Some( CloseMsg )`. Tap-outside dismisses; the panel itself centers via `column().push(spacer()).push(panel).push(spacer())`.
- *Pass-through OSD*: same as above but `input_region: Some(Vec::new())` so pointer events fall through to whatever is below.
- *Top bar / dock*: `layer: Top` or `Bottom`, `anchor: TOP`/`BOTTOM`, fixed `size`, non-zero `exclusive_zone` so app windows reflow around it. Usually returned from `view()` (single-purpose shell), not from `overlays()`.
- *Greeter / lock screen*: `shell_mode: Layer(Overlay)`, `keyboard_exclusive: true`. Loginmanager is the reference.
Overlays do not nest. A "submenu inside the quick settings panel" is just a second overlay with a different id whose `view()` builds the submenu. Crustace uses this for the WiFi and Bluetooth pickers.
If your application does not need overlays or layer-shell, you can ignore this
entire section and stay in the `ltk::window` subset.
## Theming
`ltk::theme` exposes a process-wide active theme. Three layers:
1. **Document** — a [`ThemeDocument`] loaded from disk (`/usr/share/ltk/themes/<id>/theme.json`). Each document carries a `light` and `dark` [`Mode`] with a typed [`SlotStore`] (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically.
2. **Mode** — [`ThemeMode::Light`] or `Dark`; flips which mode of the document is active.
3. **Active state**`ltk::active_document()` / `ltk::active_mode()` return the current pair. Per-slot shorthands (`ltk::theme_color`, `theme_paint`, `theme_shadows`, `theme_surface`, `theme_text_style`, `theme_palette`, `theme_window_controls`, `theme_wallpaper`, `theme_lockscreen`) cover the common patterns.
Inside a widget tree, read the palette through the per-slot helper:
```rust,no_run
# fn _ex() {
let _label = ltk::text( "Hello" )
.color( ltk::theme_palette().text_primary );
# }
```
To switch theme at runtime, dispatch a message that calls `ltk::set_active_mode( ThemeMode::Dark )` from `update()` and let the next frame re-resolve. There is no manual invalidation step.
Loading a different document:
```rust
let doc = ltk::ThemeDocument::find( "default" )
.expect( "default theme not installed (ltk-theme-default)" );
ltk::set_active_document( doc );
```
For dev iteration set `LTK_THEMES_DIR=/path/to/ltk/themes` so the lookup picks files in the working tree before the system path. The full search order is:
1. `LTK_THEMES_DIR/<id>/` when the env var is set
2. `$XDG_DATA_HOME/ltk/themes/<id>/` (defaults to `~/.local/share/ltk/themes/<id>/`)
3. `/usr/share/ltk/themes/<id>/`
Wallpapers ship as a single landscape PNG per variant. `ltk::WallpaperBundle::from_path_or_bytes( path, bundled_fallback )` handles the disk-or-builtin fallback, and `bundle.for_size( sw, sh )` returns the right crop for landscape *or* portrait surfaces — no need to ship two PNGs.
For many third-party apps, theming is optional at first. It is reasonable to
start with the default theme and come back to the runtime theme APIs later as
part of the `ltk::runtime` layer.
## Animations
The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`:
```rust,no_run
# struct App { toast: Option<()>, nav_progress: f32 }
# impl App {
fn is_animating( &self ) -> bool
{
self.toast.is_some() // an OSD is fading
|| self.nav_progress < 1.0 // a screen is sliding
}
# }
```
While `is_animating()` returns `true`, ltk redraws at ~60 Hz. Do *not* mutate state in `view()`; instead read `Instant::now()` against a stored start time and compute the tween value:
```rust,no_run
# use std::time::Instant;
# use ltk::Element;
# const TOAST_DURATION: f32 = 3.0;
# #[ derive( Clone ) ] enum Msg {}
# struct App { toast_started: Option<Instant> }
# impl App {
fn view( &self ) -> Element<Msg>
{
let progress = match self.toast_started
{
Some( t ) => ( t.elapsed().as_secs_f32() / TOAST_DURATION ).min( 1.0 ),
None => 0.0,
};
// … fade alpha = 1.0 - progress
# ltk::text( "" ).into()
}
# }
```
The end-of-animation cleanup belongs in `poll_external()`: when `progress >= 1.0` clear `self.toast_started` so `is_animating()` returns `false` and the loop sleeps again.
For follow-the-finger gestures use `on_swipe_progress(progress)` / `on_swipe_down_progress(progress)`. Those fire continuously during the drag with a `0.0..=1.0` value and don't require `is_animating` — the gesture itself drives the redraw.
For a basic application window, defer this whole area until the rest of the UI
is already working. Animation is part of the advanced runtime surface, not the
core onboarding path.
## Larger state patterns
A four-button demo can keep all state in one struct and one flat `Msg` enum. Anything bigger needs structure. Conventions used by crustace and loginmanager:
**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element<AppMsg>` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen.rs`, `launcher/`, `notifications.rs`, `powermenu.rs`.
**Wrap sub-messages in the top-level enum.** `enum AppMsg { Home(HomeMsg), Settings(SettingsMsg), Nav(Route), Tick }`. `update()` matches the outer variant, then forwards to the right sub-module. This avoids one-giant-message-enum bloat once the app passes ~30 variants.
**Ephemeral caches behind `RefCell` (single-threaded).** `view(&self)` is `&self`; if you need a mutable icon cache, scaled-image cache, layout cache, etc., wrap it in `RefCell<...>` on the app struct and `borrow_mut()` inside `view()`. Crustace's `IconCache` does exactly this. Don't reach for `Mutex` — the event loop is single-threaded.
**External state via channel + poll.** Anything that blocks (D-Bus, files, network, IPC) lives on a background thread. At startup save the `ChannelSender<Msg>` from `set_channel_sender`, hand a clone to the worker, and have the worker push messages back. `poll_external()` is the place for non-blocking `try_recv()` against in-process receivers (e.g. `mpsc`/`crossbeam` channels) or for expiry checks like "is this notification past its TTL".
**Stable widget ids only when you need to programmatically focus them.** `WidgetId` is an opt-in tag on a widget that pairs with `App::take_focus_request()`. Don't decorate every widget; tag the one input you want to autofocus on screen entry.
Again, the simplest progression is:
1. one flat app state in `ltk::window`
2. sub-state and overlays once the app becomes shell-like
3. caches, channels, focus retargeting, and cross-surface invalidation only when scale requires them
## Performance
The cheap things and the expensive things, in rough order:
- *Cheap*: building the `Element<Msg>` tree. It's plain enums and `Vec`s. crustace rebuilds the entire shell every frame and stays idle when nothing changes.
- *Cheap*: input dispatch. Per-leaf handler snapshots are captured during the layout pass; pointer/key events are O(N_focusable_leaves) lookups, not tree walks.
- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc<ThemeDocument>` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the eight canonical palette fields.
- *Avoid in `view()`*: filesystem walks, image decoding, `serde` parsing, regex compilation. Cache the result on the app struct (behind `RefCell` if needed) and look it up.
- *Avoid in `view()`*: cloning large `Vec<u8>` image buffers. `img_widget` takes an `Arc<Vec<u8>>`; build the `Arc` once at load time and clone *the Arc*, not the bytes.
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target.
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`.
- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper.
- *GPU vs software*: the GLES path is selected automatically when EGL is available; both render the same pixels (see the recent commits for the alpha/SDF parity work). There is no API-level difference for the application.
When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished.
## Where to look in the consumer repos
| Pattern | File |
| --- | --- |
| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, lines ~250380) |
| Background poller + channel sender | `crustace/src/app.rs` (`set_channel_sender`, `poll_external`) |
| Sub-module per screen | `crustace/src/{homescreen.rs, notifications.rs, powermenu.rs, launcher/}` |
| Cached icon loading via `RefCell` | `crustace/src/launcher/icon_cache.rs` and use sites in `app.rs` |
| OSD overlay with auto-expiry | `crustace/src/app.rs` (`show_osd`, `build_osd`, `OSD_TIMEOUT_SECS`) |
| `keyboard_exclusive` + `take_focus_request` | `loginmanager/src/main.rs` |
| Theme on disk (slot-typed JSON) | `ltk/themes/default/theme.json`, `ltk::ThemeDocument::find` |
For a self-contained example that exercises overlays, theme switching, and animation in one ~300-line file, see `examples/mini_shell.rs`.

825
docs/cookbook.md Normal file
View File

@@ -0,0 +1,825 @@
# 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
- [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)
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
---
## 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, 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
// at ~60 Hz and reads the new progress every frame.
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: ( self.surface_width, visible_h as u32 ),
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 )
}
# }
```
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`](./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 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/app.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, 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: ( 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 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, 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: ( 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 at 60 Hz 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 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`](../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.
```rust,no_run
# 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`](./architecture.md#larger-state-patterns).
---
## 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).

385
docs/onboarding.md Normal file
View File

@@ -0,0 +1,385 @@
# ltk onboarding
This guide is for the first hour with `ltk`: what environment you need, how to
run the examples, how to build a minimal app, when to use layer-shell vs a
regular window, and what theme/font assumptions the toolkit currently makes.
If you already know the basics and want the deeper rationale, read
[`docs/architecture.md`](./architecture.md) next.
## What `ltk` is
`ltk` is a Rust UI toolkit for Wayland. It is aimed first at the Eydos shell
stack, but it can also be used to build normal client applications and
runtime-free UI surfaces.
At a high level:
- Implement the [`App`] trait.
- Return an [`Element<Msg>`] tree from `view()`.
- React to user input by handling messages in `update()`.
- Start the event loop with `ltk::run(app)`.
The model is declarative and Elm-shaped: the widget tree is rebuilt from your
state, then `ltk` handles layout, drawing and input dispatch.
If you are browsing the crate through `cargo doc`, the public API is also
grouped conceptually into three entry points:
- `ltk::window` — basic application windows
- `ltk::shell` — layer-shell and overlays
- `ltk::runtime` — advanced runtime hooks and runtime-free embedding
Most users should start with `ltk::window` and ignore the other two until they
have a normal app window running.
## Before you start
`ltk` is not a browser toolkit and not a cross-platform desktop toolkit. Today
it assumes:
- a running **Wayland** session
- Wayland client libraries available through Rust dependencies
- a usable system font such as `google-sora-fonts`, `liberation-fonts` or
`dejavu-fonts`
- an installed `default` theme, or a development theme directory exposed
through `LTK_THEMES_DIR`
The rendering backend is selected automatically:
- **GLES** when EGL/GLES is available
- **software** fallback otherwise, or when `LTK_FORCE_SOFTWARE=1`
## Fastest way to see it working
From the repo root:
```bash
cargo run --example showcase
```
Other useful examples:
- `cargo run --example widgets` — broad widget survey
- `cargo run --example inputs` — text entry
- `cargo run --example scroll` — scroll viewport patterns
- `cargo run --example mini_shell` — overlays, animation and theme switching
All examples require a running Wayland compositor.
## Theme and font setup
`ltk` currently expects a theme named `default`. Lookup order is:
1. `LTK_THEMES_DIR/<id>/`
2. `$XDG_DATA_HOME/ltk/themes/<id>/`
3. `/usr/share/ltk/themes/<id>/`
For development inside this repository, the simplest setup is:
```bash
export LTK_THEMES_DIR="$PWD/themes"
```
That makes `ThemeDocument::find("default")` resolve to
`$PWD/themes/default/theme.json`.
Font loading is separate from theme lookup. `Canvas` walks a chain of
common system font paths (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`,
`fonts-freefont`, …) and uses the first one it finds. If nothing matches,
it falls back to an embedded Sora Regular (~50 KB, SIL OFL 1.1) shipped
inside the crate, so canvas construction never panics on a system without
the expected fonts. Installing one of the listed packages is still
recommended for richer glyph coverage.
## Your first app
The smallest useful `ltk` app implements `App`, returns a tree from `view()`,
updates its state in `update()`, and calls `ltk::run(...)`.
```rust,no_run
use ltk::{ App, Element, Keysym, button, column, spacer, text };
#[derive(Clone)]
enum Msg
{
Increment,
}
struct CounterApp
{
value: u32,
}
impl App for CounterApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column::<Msg>()
.padding( 32.0 )
.spacing( 16.0 )
.center_y( true )
.push( text( "Hello from ltk" ).size( 28.0 ) )
.push( text( format!( "Count: {}", self.value ) ).size( 18.0 ) )
.push( spacer() )
.push( button( "Increment" ).on_press( Msg::Increment ) )
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Increment => self.value += 1,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
}
fn main()
{
ltk::run( CounterApp { value: 0 } );
}
```
### Minimal `Cargo.toml`
```toml
[package]
name = "my-ltk-app"
version = "0.1.0"
edition = "2021"
[dependencies]
ltk = { path = "../ltk" }
```
If you vend `ltk` from crates.io later, replace the `path` dependency with a
versioned one.
## Public API Layers
`ltk` exposes most items at the crate root, but for documentation and discovery
it is useful to think of the library in three layers.
### 1. `ltk::window`
This is the default entry point for third-party applications.
Use it for:
- normal application windows
- tools and prototypes
- most widget/layout work
The APIs you will usually touch first live here conceptually:
- `App`
- `Element<Msg>`
- `button`, `text`, `text_edit`, `image`
- `column`, `row`, `stack`, `grid`, `spacer`
- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio`
- `Color`
- `run`
### 2. `ltk::shell`
This layer groups the APIs that matter when your surface is part of the shell
rather than a normal app window.
Use it for:
- bars and docks
- homescreens
- notifications
- greeters and lock screens
- transient overlays
The most important APIs in this layer are:
- `ShellMode`
- `Layer`
- `Anchor`
- `OverlaySpec`
- `OverlayId`
- `overlays()`
### 3. `ltk::runtime`
This layer is for advanced integration points.
Use it when you need:
- external wakeups via `set_channel_sender()`
- timer-driven or async state via `poll_external()` / `poll_interval()`
- redraw narrowing via `invalidate_after()`
- runtime theme state access
- runtime-free embedding through `core::UiSurface`
Most applications do not need to start here.
## Regular app window vs shell surface
Most consumers should start with a **regular window**.
Default behaviour:
- `shell_mode()` defaults to `ShellMode::Window`
- `ltk::run(app)` creates an xdg-shell toplevel
Use this for:
- normal applications
- internal tools
- prototypes while learning the toolkit
Switch to **layer-shell** only when you are building a shell component:
- top bar
- dock
- homescreen
- notification surface
- lock screen / greeter
The knobs you will usually override are:
- `shell_mode()`
- `layer_anchor()`
- `layer_size()`
- `exclusive_zone()`
- `keyboard_exclusive()`
- `background_color()`
For a non-trivial layer-shell example, use `examples/mini_shell.rs` as the
reference entry point.
## The APIs you will touch first
In practice, most first apps only need a small subset of the surface area.
Start here:
- `App`
- `Element<Msg>`
- `button`, `text`, `text_edit`, `image`
- `column`, `row`, `stack`, `grid`, `spacer`
- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio`
- `Color`
- `run`
Do not start with these unless you need them:
- `ltk::shell`
- `ltk::runtime`
- `overlays()`
- gesture hooks such as `on_swipe_*`
- `set_channel_sender()` / `poll_external()`
- `core::UiSurface`
- custom theming APIs
## Message flow and state
The expected shape is:
1. user interaction emits a `Message`
2. `update()` mutates app state
3. `view()` rebuilds the UI from that state
Example:
```rust
#[derive(Clone)]
enum Msg
{
NameChanged( String ),
Submit,
}
```
For small apps, one top-level `enum Msg` is enough. Once the app grows, split
state by screen/panel and wrap sub-messages in the top-level enum:
```rust,no_run
# #[ derive( Clone ) ] pub enum HomeMsg {}
# #[ derive( Clone ) ] pub enum SettingsMsg {}
enum AppMsg
{
Home( HomeMsg ),
Settings( SettingsMsg ),
Quit,
}
```
This is the pattern used by `examples/mini_shell.rs`.
## Recommended learning order
If you are new to the library, this order minimizes confusion:
1. Run `examples/showcase.rs`.
2. Read the crate-level docs in `src/lib.rs`, especially `ltk::window`.
3. Build a plain xdg-shell window with `button`, `text`, `column`.
4. Add input handling with `text_edit` or `slider`.
5. Only then look at `ltk::shell` for overlays and layer-shell.
6. Move to `ltk::runtime` only when you need advanced hooks or embedding.
## Performance rules of thumb
`ltk` is designed to sleep when idle and redraw only on real changes, but the
application can still make bad choices. Keep these rules in mind:
- keep `view()` pure and cheap
- do not do filesystem I/O, parsing or image decoding inside `view()`
- cache expensive derived data on your app struct
- leave `poll_interval()` as `None` unless you genuinely need periodic wakeups
- only return `true` from `is_animating()` while something is actually moving
On mobile targets, the last two matter directly for battery life.
## When to use `core::UiSurface`
Most apps should ignore `core` at first.
Use `core::UiSurface` when you want `ltk`'s layout/drawing/hit-testing without
`ltk::run()`. Typical cases:
- compositor-side decorations
- embedding `ltk` widgets in another render loop
- offscreen rendering or previews
There is coverage for that path in `tests/core_surface.rs`.
## Current assumptions and rough edges
This repo is usable, but a few current behaviours are worth knowing up front:
- examples and docs assume Wayland, not X11
- theming is process-global
- theme discovery currently expects a `default` theme on disk (a B/W
fallback document kicks in when missing, with a red banner on every
frame so the gap is impossible to miss)
- the architecture docs mention downstream consumer repos that are not part of
this repository
None of that blocks learning the toolkit, but it matters when you evaluate
`ltk` as a third-party dependency.
## What to read next
- [`docs/architecture.md`](./architecture.md) — multi-surface patterns,
theming, animation and performance
- [`examples/showcase.rs`](../examples/showcase.rs) — smallest visual tour
- [`examples/widgets.rs`](../examples/widgets.rs) — broader widget coverage
- [`examples/mini_shell.rs`](../examples/mini_shell.rs) — overlays and shell
patterns
- [`tests/core_surface.rs`](../tests/core_surface.rs) — runtime-free rendering

737
docs/theming.md Normal file
View File

@@ -0,0 +1,737 @@
# ltk theming
`ltk` reads a JSON theme document at startup and exposes a process-wide
active state for widgets and applications to query. This document
describes the on-disk format, the runtime APIs, and the slot conventions
that built-in widgets expect.
For background on *why* theming is process-global and how it interacts
with the runtime, see [`docs/architecture.md`](./architecture.md). This
file is the schema reference.
## File layout
A theme is a directory under one of:
1. `LTK_THEMES_DIR/<id>/` — only when the env var is set; intended
for development.
2. `$XDG_DATA_HOME/ltk/themes/<id>/` (defaults to
`~/.local/share/ltk/themes/<id>/`).
3. `/usr/share/ltk/themes/<id>/` — system-wide install path
(`ltk-theme-default` Debian package).
The directory tree:
```text
<id>/
├── theme.json required — the schema below
├── branding/ mode-aware branded assets
│ ├── light/
│ │ ├── launcher.svg launcher logo
│ │ ├── wallpaper.svg homescreen wallpaper
│ │ ├── lockscreen.svg greeter / lockscreen image
│ │ └── logo/ brand wordmark / icon variants
│ │ ├── logo.svg primary logo (about / splash)
│ │ ├── square.svg 1:1 variant (avatars, app icons)
│ │ └── horizontal.svg wordmark (header / sign-in bars)
│ └── dark/
│ └── (same set, dark variants)
└── icons/ symbolic + app icons
├── app-default.svg fallback icon for unknown app ids
├── apps/ per-application icons (firefox.svg, …)
└── catalogue/ symbolic glyph catalogue
├── filled/ solid silhouettes — preferred by default
│ └── <category>/ general, system, window, …
└── line/ outlined variants (same names)
```
Branded assets in `branding/` and icons in `icons/` are picked up by
convention — see [Branding assets](#branding-assets) and
[Icons](#icons) below. Asset paths declared inside `theme.json` (e.g. a
custom wallpaper override) resolve relative to the theme directory; a
bare `"path": "custom.png"` is portable across install prefixes,
absolute paths work for system fonts but break relocatable installs.
## Top-level structure
```json
{
"theme": { "id": "default", "name": "Default" },
"fonts": { ... },
"colors": { ... },
"gradients": { ... },
"inset_stacks": { ... },
"modes": {
"light": { ... },
"dark": { ... }
}
}
```
`theme.id` must match the directory name. `theme.name` is shown in any
theme-picker UI a shell builds on top of `ltk`.
The other six sections are described below in order. The parser is strict
(`deny_unknown_fields`): unknown keys at any level are an error so typos
surface immediately.
## `fonts`
```json
"fonts": {
"sora": {
"name": "Sora",
"fallbacks": ["system-ui", "sans-serif"],
"sources": [
{ "weight": 300, "path": "/usr/share/fonts/opentype/sora/Sora-Light.otf" },
{ "weight": 400, "path": "/usr/share/fonts/opentype/sora/Sora-Regular.otf" },
{ "weight": 600, "path": "/usr/share/fonts/opentype/sora/Sora-SemiBold.otf" },
{ "weight": 700, "path": "/usr/share/fonts/opentype/sora/Sora-Bold.otf" }
]
}
}
```
Each entry defines a font *family* with one source per OpenType / TrueType
weight. The map key (`"sora"`) is the family alias used inside the theme
(`"font_family": "sora"`); `name` is the human-readable label.
`fallbacks` is consulted when a glyph is missing from the primary font
files at render time. The chain is walked in order; the first family that
can rasterise the codepoint wins.
If none of the listed `sources` exist on disk, `ltk` falls back to its
embedded Sora Regular (~50 KB, OFL 1.1) and stamps a red banner on every
frame pointing at the missing-theme problem.
## `colors`
A flat dictionary of named hex literals, used as building blocks
elsewhere in the document via the [`@name` reference syntax](#references):
```json
"colors": {
"navy": "#0A032E",
"white": "#FFFFFF",
"cyan": "#04D9FE",
"danger": "#E5484D",
"glass-hi": "#555555",
"ink": "#000000"
}
```
Values are 6- or 8-digit hex (`#RRGGBB` or `#RRGGBBAA`). Names use
kebab-case. There is no distinction between "palette" colours (used in
many places) and "raw" colours (single-use) — any hex can live here, and
single-use literals are equally valid inline.
## `gradients`
Named paints. Two variants: `linear` and `radial`. Used both as fills for
surfaces and as values referenced from a slot. Stops carry a `pos` (in
`[0, 1]`, but stop positions outside that range are accepted and used for
extrapolation) and a `color` (literal hex or `@reference`).
```json
"gradients": {
"fill-cyan": {
"type": "linear",
"angle_deg": 270,
"space": "linear-rgb",
"stops": [
{ "pos": 0, "color": "@cyan" },
{ "pos": 1, "color": "@cyan-soft" }
]
}
}
```
`space` is either `"srgb"` (perceptually quick, the default) or
`"linear-rgb"` (interpolate in linear-light space, more uniform mid-tones
for high-saturation gradients). `angle_deg` is the conventional CSS
gradient angle (`0` = up, `90` = right, `180` = down, `270` = left).
Radial gradients use `center: [x, y]` (relative to the painted rect, both
in `[0, 1]`) and `radius: r` (also relative).
Soft cap: a single gradient may carry **64 stops**. Beyond that the
parser truncates the tail with a stderr warning, so a hostile or
mistakenly-large gradient cannot blow up CPU + memory at parse time.
Realistic designs use 2 6 stops.
## `inset_stacks`
Named lists of inset shadows reused across surfaces. Convenient for the
"glass" stack used by every translucent slot in the default theme:
```json
"inset_stacks": {
"glass-insets": [
{ "offset": [0, 1], "blur": 4, "color": "@ink/0F", "blend": "normal" }
]
}
```
Each entry has `offset: [x, y]` (logical pixels), `blur` (Gaussian sigma
× 2, matching the CSS convention), optional `spread` (default `0`),
`color` (literal or `@ref`), and `blend` (`normal`, `plus-lighter`, or
`overlay`).
Reference an entire stack from a slot with `"inset_shadows":
"@glass-insets"`, or inline the array if you only use it once.
## `modes`
Two required entries: `light` and `dark`. Each carries the look the
theme applies in that lighting mode plus its own `slots` table.
### `wallpaper` / `lockscreen`
Both are optional. The runtime resolves them in two steps:
1. **Convention** — looks for `branding/{mode}/wallpaper.svg` and
`branding/{mode}/lockscreen.svg`, with the standard mode →
opposite-mode → no-mode fallback (see
[Branding assets](#branding-assets)).
2. **Override** — if `theme.json` declares an explicit block, that path
wins over the convention. Useful for raster wallpapers or
non-conventional locations:
```json
"wallpaper": { "path": "custom/path.png", "fit": "cover" }
```
`path` resolves against the theme directory. `fit` is one of `"cover"`,
`"contain"`, `"stretch"`, `"center"` (default `"cover"`). The wallpaper
bundle helper ([`ltk::WallpaperBundle::for_size`]) returns the right
crop for landscape or portrait surfaces, so a single landscape SVG / PNG
covers both.
### `launcher`
```json
"launcher": { "background": "@white/E6", "border_radius": 24.0 }
```
`background` is any color reference (`@name[/AA]`). `border_radius` is
the corner radius for the launcher panel, in logical pixels.
### `window_controls`
Per-mode tokens for the title-bar control buttons:
```json
"window_controls": {
"icon": "#5F5F68",
"hover_bg": "@navy/14",
"pressed_bg": "@navy/24",
"close_hover_bg": "@danger",
"close_icon": "@white",
"focus_ring": "@teal"
}
```
Consumed by the [`window_button`](../src/widget/window_button.rs) widget
through [`theme_window_controls()`].
The actual SVG glyphs (`close`, `maximize`, `minimize`, `restore`) live
in `icons/catalogue/filled/window/` and are tinted at runtime with the
`icon` colour from this block (and `close_icon` on close-hover). Themes
don't need to ship per-mode variants of these glyphs — the symbolic
tinting handles light vs dark colouring. They also don't need a
`line/window/` variant; chrome controls should look the same regardless
of the theme's overall icon style preference, and the existing `filled`
fallback in [`icon_path`](#icons) already covers the case.
### `slots`
The mode's slot table. Each entry is keyed by a stable id; widgets look
their slot up by id and the slot's `meta.semantic` field supplies a
human-readable hint that's useful in theme inspectors.
Three slot variants:
#### `color`
```json
"text-primary": {
"type": "color",
"value": "@navy",
"meta": { "semantic": "palette/text_primary" }
}
```
Plain colour values. `value` is a literal hex or `@reference`. `meta` is
optional but conventional: `palette/<role>` for the palette layer and
`effect/<group>/<name>` for everything else.
#### `shadows`
```json
"shadows-glass": {
"type": "shadows",
"shadows": [
{ "offset": [0, 0], "blur": 9, "color": "@glass-elev/1F" }
]
}
```
Outer drop shadows applied via [`theme_shadows(id)`]. Same field shape as
`inset_stacks` entries.
#### `surface`
```json
"surface-card": {
"type": "surface",
"fill": "@surface-glass-dark",
"shadows": "shadows-glass",
"inset_shadows": "@glass-insets",
"backdrop": { "blur_px": 22.5 },
"meta": { "semantic": "effect/glass/card" }
}
```
The most expressive slot kind. Composes:
- `fill` — a paint reference (`@gradient-name`) or inline gradient /
solid colour. Required.
- `shadows` — id of a `shadows` slot or an inline list. Optional.
- `inset_shadows` — id of an `inset_stacks` entry (`@glass-insets`) or
inline list. Optional.
- `backdrop``{ "blur_px": <σ × 2> }` for backdrop blur. Optional;
GLES backend renders it, software backend ignores it (documented
parity gap).
## References
The `@name` syntax substitutes a palette / gradient / inset-stack value
in place of an inline literal:
- `@cyan` — looks up `colors.cyan`, `gradients.cyan` or
`inset_stacks.cyan` (collisions across sections are an error). When the
resolved value is a colour, the alpha channel comes from the original.
- `@cyan/80` — the `/AA` suffix is a colour-only alpha override (two hex
digits). Lets a single base `@navy` serve `@navy/14`, `@navy/24`,
`@navy/99`, `@navy/D9` etc. without a separate entry per alpha.
References inside gradient stops or inset shadows are resolved at parse
time, so each downstream substitution at a slot call site is a flat
clone with no recursion at runtime.
Unknown references fail loud: parsing aborts with `ThemeError::
UnknownColorRef("foo")`.
## Canonical slot ids
The default widgets look up these slot ids; a custom theme that omits any
of them falls back to embedded defaults.
**Palette (every mode must define):**
| id | role | type |
| --- | --- | --- |
| `bg-page` | window background | color |
| `surface` | card / panel surface | color |
| `surface-alt` | text-input field background | color |
| `text-primary` | regular text colour | color |
| `text-secondary` | muted / placeholder text | color |
| `accent` | toggle on, slider fill, focus ring | color |
| `divider` | separator, toggle off, list item border | color |
| `icon` | icon-button glyph colour | color |
**Effects (optional but used by built-in widgets):**
| id | consumer | type |
| --- | --- | --- |
| `shadows-glass` | every surface that opts into elevation | shadows |
| `surface-card` | `Container::surface("surface-card")` | surface |
| `surface-card-flat` | flat variant for software backend | surface |
| `surface-panel` | overlay panels | surface |
| `surface-slider-track` | `Slider` track background | surface |
| `surface-slider-fill` | `Slider` filled portion | surface |
| `surface-slider-track-flat` | software-backend slider track | surface |
| `surface-slider-fill-flat` | software-backend slider fill | surface |
| `surface-toggle-active` | `Toggle` on-state surface | surface |
The `-flat` variants are used by the software backend, which lacks
backdrop blur; the GLES backend uses the non-flat ones.
## Using the theme from app code
The active theme is process-global mutable state: a `(ThemeDocument,
ThemeMode)` pair guarded by a `RwLock` behind the `ltk::theme` API.
Widgets and apps read it through cheap accessors that clone an `Arc`
or project a small struct out of the slot table — designed so it's
fine to call them dozens of times per frame from inside `view()`.
### The canonical pattern: read in `view()`, never cache
Read the theme at the top of every `view()` and let the next frame
re-resolve automatically. Storing a `Color` in your app state freezes
it at the moment you captured it: a later `set_active_mode( Dark )`
will repaint everyone who reads palette per-frame and skip the widgets
that read a stale field.
```rust,no_run
# use ltk::{ column, container, text, Element };
# #[ derive( Clone ) ] enum Msg {}
# struct MyApp;
# impl MyApp {
fn view( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
column::<Msg>()
.push( text( "Hola" ).color( palette.text_primary ) )
.push( text( "subtítulo" ).color( palette.text_secondary ) )
.push(
container( text( "tarjeta" ).color( palette.text_primary ) )
.background( palette.surface )
.radius( 12.0 ),
)
.into()
}
# }
```
### Helpers reference
| Helper | Returns | When to use |
| --- | --- | --- |
| `theme_palette()` | `Palette` | Common case — named colour fields (`bg`, `surface`, `surface_alt`, `text_primary`, `text_secondary`, `accent`, `divider`, `icon`, `danger`, `danger_bg`). Cheap projection, ideal at the top of `view()`. |
| `theme_color( id )` | `Option<Color>` | Pull a single colour slot by id when it's not in the palette (`"surface-card-border"`, custom theme tokens). |
| `theme_color_or( id, fallback )` | `Color` | Same, with a baked-in default — ergonomic in widget defaults so missing slots don't return `None`. |
| `theme_paint( id )` | `Option<Paint>` | Slot may be a colour or a gradient — promotes a colour to `Paint::Solid` automatically. |
| `theme_surface( id )` | `Option<Surface>` | Surface slot (fill + shadows + insets + backdrop). |
| `theme_resolve_surface( id )` | `Option<( Surface, Vec<Shadow> )>` | Same, but pre-resolves a `ShadowsRef::Named` reference to a flat `Vec`. Use this when you call `canvas.fill_surface` directly. |
| `theme_shadows( id )` | `Option<Vec<Shadow>>` | Outer shadow stack. |
| `theme_text_style( id )` | `Option<TextStyle>` | Typography slot (size, weight, line-height). |
| `theme_window_controls()` | `WindowControlsSpec` | Per-mode chrome tokens for the title-bar buttons. |
| `theme_wallpaper()` / `theme_lockscreen()` | `Option<WallpaperSpec>` | Full-screen branded images (SVG), with the convention fallback chain. |
| `theme_branding_image( name, sw, sh )` | `Option<PathBuf>` | Sized branded image: smallest covering raster (WebP / PNG / JPEG) under `branding/{mode}/{name}/`, or the largest available raster if none cover, falling back to the SVG only when no rasters exist. Pass `(0, 0)` for the smallest available (startup before surface-configure). |
| `theme_branding_raster( name, sw, sh )` | `Option<PathBuf>` | Raster-only variant of the above; returns `None` only when no rasters exist at all. |
| `theme_branding_asset( name, ext )` | `Option<PathBuf>` | Generic branded asset lookup (any extension). Powers `theme_launcher_icon`, `theme_wallpaper`, `theme_lockscreen`. |
| `theme_launcher_icon()` | `Option<PathBuf>` | Launcher logo SVG path, with the convention fallback. |
| `theme_logo()` | `Option<PathBuf>` | Primary brand logo SVG (`branding/{mode}/logo/logo.svg`) — about dialogs, splash screens. |
| `theme_logo_square()` | `Option<PathBuf>` | Square 1:1 logo variant (`logo/square.svg`) — app icons, login avatars, lockscreen badges. |
| `theme_logo_horizontal()` | `Option<PathBuf>` | Wordmark logo variant (`logo/horizontal.svg`) — header bars, sign-in screens. |
| `theme_app_icon( name )` / `theme_app_default_icon()` | `Option<PathBuf>` | Per-app icons under `icons/apps/`. |
| `theme_icon_path( "category/name" )` | `Option<PathBuf>` | Catalogue icon path (filled-then-line lookup). |
| `theme_icon_rgba( "category/name", size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. |
| `is_fallback_active()` | `bool` | `true` when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user. |
### Switching mode or document at runtime
Mutators live next to the readers. They take effect on the next render:
```rust,no_run
// Light → dark.
ltk::set_active_mode( ltk::ThemeMode::Dark );
// Replace the whole document (user picked a different theme id in a
// settings panel, etc.).
let doc = ltk::ThemeDocument::find( "midnight" )
.expect( "midnight theme not installed" );
ltk::set_active_document( doc );
```
The conventional wiring is to dispatch a message from the UI:
```rust,no_run
# struct MyApp;
enum Msg { ToggleTheme }
# impl MyApp {
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::ToggleTheme =>
{
let next = match ltk::active_mode()
{
ltk::ThemeMode::Light => ltk::ThemeMode::Dark,
ltk::ThemeMode::Dark => ltk::ThemeMode::Light,
};
ltk::set_active_mode( next );
}
}
}
# }
```
Every widget that read `theme_palette()` / `theme_surface(...)` /
... in `view()` automatically gets the new colours on the next
frame — there is no manual invalidation step, no observer
registration, no list of subscribed widgets. The entire reactivity
story is "ltk re-runs `view()` every frame and you read fresh values".
### Do / don't
- **Do** read palette / surfaces / icons inside `view()`, every frame.
- **Do** use `theme_color_or( id, fallback )` for non-palette slots so
a custom theme that omits a slot still paints something sane.
- **Do** use `theme_palette().<field>` for the eight common roles —
the palette is precomputed and cheap; named slot lookups only beat
it when you genuinely need a non-canonical token.
- **Don't** store `Color`, `Surface`, `Paint`, or icon `PathBuf`s in
your app state. They snapshot the moment of capture and stop
responding to mode changes.
- **Don't** hard-code `Color::hex( ... )` for chrome that should adapt
to mode — route it through a palette token or a custom slot.
- **Don't** call `set_active_mode` from `view()` (it's a side effect;
do it in `update()` from a `Msg`).
## Branding assets
The `branding/` directory holds the theme's identity assets — launcher
logo, wallpaper, lockscreen, brand wordmark — keyed by mode. The
runtime loader looks them up by convention so themes don't need to
declare them in `theme.json`.
### Layout
```text
branding/
├── light/
│ ├── launcher.svg
│ ├── wallpaper.svg
│ ├── wallpaper/ optional pre-rendered raster variants
│ │ ├── 1280x720.webp
│ │ ├── 1920x1080.webp
│ │ └── 3840x2160.webp
│ ├── lockscreen.svg
│ ├── lockscreen/
│ │ └── (same WIDTHxHEIGHT.webp set)
│ └── logo/ brand wordmark / icon variants
│ ├── logo.svg primary (about, splash)
│ ├── square.svg 1:1 (app icon, avatar)
│ └── horizontal.svg wordmark (header bar, sign-in)
└── dark/
└── (same structure)
```
The SVG is the canonical asset. Sized raster variants in the same-named
subdirectory are an optimisation: the loader prefers a pre-rendered
WebP / PNG / JPEG that already covers the surface (no upscale, no
runtime SVG rasterisation), falling back to the SVG when no raster
fits. Filenames must be `WIDTHxHEIGHT.<ext>` (literal numeric form,
lower-case `x`); the parser is strict so a bad name is silently
ignored rather than misclassified.
### Fallback chain — SVG
When a branded SVG asset is requested for the active mode, the runtime
tries three locations in order:
1. `branding/{active_mode}/{name}.svg` — preferred variant.
2. `branding/{opposite_mode}/{name}.svg` — graceful degradation when
the theme only ships one mode of the asset.
3. `branding/{name}.svg` — mode-agnostic asset, for themes that don't
bother with light/dark variants.
Returns `None` when none of the candidates exist; consumers fall back
to whatever default they prefer (solid `bg-page` for wallpapers, no
launcher decoration, etc.).
### Resolution — raster
When the surface size is known, [`theme_branding_raster`] looks for a
pre-rendered raster under `branding/{mode}/{name}/`. The same three-step
mode chain applies to the *directory*: tries the active mode first, then
the opposite mode, then the mode-less directory. Within the *first
existing* directory it parses every `WIDTHxHEIGHT.<ext>` filename
(`<ext>` ∈ {`webp`, `png`, `jpg`, `jpeg`}) and picks:
- the smallest entry whose two dimensions cover the surface, if any;
- otherwise the largest entry available — a fast-decoding upscaled
raster beats paying the SVG rasterisation cost.
The directory does *not* cross over to the opposite-mode tree once an
existing directory is found: a colour-wrong raster (light-mode asset
served in dark mode or vice versa) would be more jarring than an
upscaled same-mode raster. Cross-mode degradation only happens at the
SVG layer, where the vector form re-paints crisply at any size.
`theme_branding_image(name, sw, sh)` composes raster-then-SVG: tries
`theme_branding_raster` first, falls back to `theme_branding_asset(name,
"svg")` only when no raster files exist anywhere in the chain. Pass
`(0, 0)` to get the smallest available raster — every entry trivially
covers a zero-sized surface. Useful at startup before the
surface-configure event arrives, so the first frame paints from a
fast-decoded lightweight raster (typically a few ms) instead of the
SVG (typically 1-2 s for a gradient-heavy abstract wallpaper).
### Runtime API
```rust
// SVG resolution — mode-aware fallback chain on the file path.
let launcher = ltk::theme_launcher_icon(); // Option<PathBuf>
let wallpaper = ltk::theme_wallpaper(); // Option<WallpaperSpec>
let lockscreen = ltk::theme_lockscreen(); // Option<WallpaperSpec>
// Sized raster (WebP / PNG / JPEG) — pick the smallest covering variant.
let raster = ltk::theme_branding_raster( "wallpaper", 1920, 1080 );
// -> Option<PathBuf>
// Combined: raster first, SVG fallback. Recommended for wallpaper /
// lockscreen consumers that have the surface size at decode time.
let path = ltk::theme_branding_image( "wallpaper", 1920, 1080 );
// Brand wordmark / icon — three named variants under branding/{mode}/logo/.
let about_logo = ltk::theme_logo(); // Option<PathBuf> — primary
let app_icon = ltk::theme_logo_square(); // Option<PathBuf> — 1:1
let header_lg = ltk::theme_logo_horizontal(); // Option<PathBuf> — wordmark
// Generic helper for arbitrary branded SVG assets (splash, watermarks, …).
let splash = ltk::theme_branding_asset( "splash", "svg" );
```
`branding_asset(name, ext)` is the underlying SVG helper; `branding_raster`
and `branding_image` add the size-aware layer on top. The three `theme_logo*`
helpers are thin wrappers over `branding_asset( "logo/<variant>", "svg" )` —
the `name` argument freely accepts a `subdir/file` shape, so any other
sub-grouped asset family follows the same pattern.
## Icons
Two parallel trees under `icons/`:
- **`icons/apps/`** — per-application icons (`firefox.svg`,
`calculator.svg`, …). Each app's brand identity, mode-agnostic.
Looked up via [`theme::app_icon( "firefox" )`].
- **`icons/catalogue/`** — symbolic glyph catalogue intended for tinting
at runtime. Two style variants:
- `filled/` — solid silhouettes. Preferred by default.
- `line/` — outlined variants. Used as fallback when `filled` doesn't
ship a given icon. A theme that prefers a line aesthetic puts its
SVGs in `filled/` (where the lookup goes first); the directory
name reflects the *style of the asset*, not the lookup precedence.
Categories under `catalogue/{filled,line}/`: `accessibility`, `actions`,
`archives`, `communication`, `controls`, `customisation`, `energy`,
`features`, `feedback`, `general`, `hardware`, `keyboard`, `multimedia`,
`navigation`, `safety`, `session`, `system`, `window`.
### Adding an icon
1. Drop a monochrome SVG into
`catalogue/filled/<category>/<name>.svg`. Convention: `<svg
fill="none">` at the root and an explicit fill on the path (e.g.
`<g fill="#000000">`). The rasteriser keeps only the alpha channel
for symbolic tinting, so the actual RGB of the source doesn't
matter — pick `#000000` for consistency with the rest of the
catalogue.
2. Optionally ship the `line/` variant in
`catalogue/line/<category>/<name>.svg`.
3. Reference it from widget code by its slash-separated stem, no
extension: `theme::icon_path( "general/down-simple" )`.
### Runtime API
```rust,no_run
# fn _ex() -> Option<()> {
// Path resolution. Tries filled/<name>.svg first, then line/<name>.svg.
let path = ltk::theme::icon_path( "window/close" );
// -> Option<PathBuf>
// Rasterise to RGBA8 (cached per (path, size) for the lifetime of the
// active document — set_active_document flushes the cache).
let ( rgba, w, h ) = ltk::theme::icon_rgba( "window/close", 16 )?;
// Tint a symbolic icon: keep the alpha, replace the RGB with `tint`.
// `palette.icon` is the catalogue tint; for window-chrome glyphs use
// `ltk::theme_window_controls().icon` instead.
let palette = ltk::theme_palette();
let tinted = ltk::theme::tint_symbolic( &rgba, palette.icon );
# Some( () )
# }
```
The `icon_rgba` + `tint_symbolic` pair is the standard pipeline for
catalogue and chrome icons: rasterise once, recolour per mode via
palette tokens. Themes ship a single SVG per glyph and the per-mode
look comes from code, not from duplicated assets.
## Localisation
ltk integrates `rust-i18n` for built-in widget strings (context-menu
labels, calendar month / day-of-week names, …). Locale files live in
`ltk/locales/<lang>.yaml`. English is the fallback. Currently shipped:
`en`, `es`, `fr`, `it`, `de`, `pt`, `pt_BR`.
### Existing keys
```yaml
context_menu:
copy: "Copy"
cut: "Cut"
paste: "Paste"
delete: "Delete"
date_picker:
month_1: "January"
...
month_12: "December"
dow_short_0: "S"
...
dow_short_6: "S"
```
`dow_short_<n>` is indexed from each locale's `first_dow` (Sunday-first
in `en`, Monday-first in `es` / `fr` / `it` / `de` / `pt` / `pt_BR`).
The `Locale` struct ships `first_dow: u8` and the date picker indexes
`dow_short_*` accordingly.
Built-in widgets read these via `rust_i18n::t!( "context_menu.copy" )`
at render time, so switching locale at runtime via
`rust_i18n::set_locale( "es" )` flips the UI on the next frame without
reconstructing widgets.
### Adding a string
1. Pick a key like `my_widget.label`.
2. Add it to **every** file under `ltk/locales/` so each language has a
translation (English at minimum is required — it's the fallback).
3. Read it from widget code with `rust_i18n::t!( "my_widget.label" )`.
### Adding a language
1. Create `ltk/locales/<code>.yaml` with all existing keys translated.
2. The `i18n!("locales", fallback = "en")` macro at the crate root picks
it up automatically — no registration step needed.
3. Apps select the locale at startup via
`rust_i18n::set_locale( "<code>" )`.
## Common errors
| Symptom | Cause | Fix |
| --- | --- | --- |
| Red banner on every frame | No theme found at any of the three search paths | Install `ltk-theme-default` Debian package or set `LTK_THEMES_DIR` |
| `unknown reference @foo` at startup | Typo in `@name` reference | Check the `colors` / `gradients` / `inset_stacks` section spelling |
| `unknown field 'foo'` | Stale schema after a `ltk` upgrade | Compare against this document and the default theme |
| Slot is rendering with the wrong colour after `set_active_mode` | App caches a `Color` from a previous frame | Read palette / surface inside `view()` each frame; the per-frame `Arc` clone is cheap |
## Custom themes
A custom theme directory can live anywhere under
`$XDG_DATA_HOME/ltk/themes/`. Three practical recipes:
- **Repaint for a brand**: copy `themes/default/`, then change:
- the `colors` palette in `theme.json` (everything else cascades),
- the up-to-six SVGs in `branding/{light,dark}/` (launcher,
wallpaper, lockscreen),
- leave the gradients, inset stacks, slot wiring and the entire
`icons/` tree untouched.
- **Override a single icon**: drop a replacement SVG at the same
catalogue path under your theme's
`icons/catalogue/filled/<category>/<name>.svg`. The `icon_path`
lookup resolves against whichever theme is active, so a partial
catalogue overlays the default cleanly without forking the rest.
- **Build a flat-only theme**: drop every `backdrop` block from the
slots and route every `surface-*-flat` slot to a solid `colors`
reference. Visual parity with the software backend is automatic.
All three recipes keep the slot ids and reference shapes intact, so the
built-in widgets continue to work without code changes.

851
docs/widgets.md Normal file
View File

@@ -0,0 +1,851 @@
# ltk widget catalogue
A flat reference of every widget and layout exported at the crate root.
Each entry has the same four sections: **what it is**, **when to use it**,
**minimal example**, **see also**. The full builder API and per-method
docs live on `cargo doc`; this file is the at-a-glance index.
For mental model and architecture, read [`docs/onboarding.md`](./onboarding.md)
and [`docs/architecture.md`](./architecture.md) first. For copy-pasteable
patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md).
## Table of contents
- [Buttons and activations](#buttons-and-activations)
- [`button`](#button) · [`icon_button`](#icon_button) · [`pressable`](#pressable) · [`window_button`](#window_button) · [`list_item`](#list_item)
- [Stateful binary controls](#stateful-binary-controls)
- [`toggle`](#toggle) · [`checkbox`](#checkbox) · [`radio`](#radio)
- [Continuous controls](#continuous-controls)
- [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar)
- [Text input and display](#text-input-and-display)
- [`text`](#text) · [`text_edit`](#text_edit)
- [Decoration and chrome](#decoration-and-chrome)
- [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget)
- [Clipping wrappers](#clipping-wrappers)
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex)
- [Overlays and feedback](#overlays-and-feedback)
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog)
- [Pickers](#pickers)
- [`date_picker`](#date_picker) · [`time_picker`](#time_picker) · [`color_picker`](#color_picker)
- [Layouts](#layouts)
- [`column`](#column) · [`row`](#row) · [`stack`](#stack) · [`grid`](#grid) · [`spacer`](#spacer)
---
## Buttons and activations
### `button`
A standard text button. Activates on tap, Enter, or Space when focused.
**When**: any place a normal app would have a "Save" / "Cancel" / "Send"
control.
```rust,no_run
# use ltk::{ button, Element };
# #[ derive( Clone ) ] enum Msg { Save }
# fn _ex() -> Element<Msg> {
button( "Save" ).on_press( Msg::Save )
.into()
# }
```
**See also**: [`pressable`](#pressable) for activation on a richer
custom-shaped surface, [`icon_button`](#icon_button) for image-only
buttons.
### `icon_button`
A button whose visual is an RGBA bitmap instead of text. Same dispatch
shape as [`button`](#button).
**When**: toolbar icons, system tray glyphs, anywhere the visual is
icon-first.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ icon_button, Element };
# #[ derive( Clone ) ] enum Msg { OpenSearch }
# fn _ex( rgba_bytes: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
icon_button( rgba_bytes, w, h ).on_press( Msg::OpenSearch )
.into()
# }
```
**See also**: [`button`](#button), [`window_button`](#window_button)
(specialised icon button for window decorations).
### `pressable`
Wraps any [`Element`](../src/widget/mod.rs) so it dispatches a press
message. Invisible to drawing — the wrapped child paints itself.
**When**: a card, a custom-styled list row, or any non-trivial visual
that should behave like a button. Inner widgets that are themselves
interactive (a button nested inside) keep priority.
```rust,no_run
# use ltk::{ column, container, pressable, row, Element, Pressable };
# #[ derive( Clone ) ] enum Msg { OpenWifiPicker }
# fn _ex(
# icon: Element<Msg>,
# title: Element<Msg>,
# subtitle: Element<Msg>,
# ) -> Pressable<Msg> {
pressable(
container( row()
.push( icon )
.push( column().push( title ).push( subtitle ) ) )
.surface( "surface-card" )
)
.on_press( Msg::OpenWifiPicker )
# }
```
**See also**: [`button`](#button) when a plain text button is enough,
[`list_item`](#list_item) for the standard "label + subtitle + trailing"
pattern.
### `window_button`
A title-bar control button (minimize / maximize / restore / close).
Comes with a special hover-tint for `Close`.
**When**: building custom server-side window decorations.
```rust,no_run
# use ltk::{ window_button, window_controls, Element, WindowButton, WindowButtonKind };
# #[ derive( Clone ) ] enum Msg { CloseWindow, Minimize, Maximize, Close }
# fn _ex() -> ( WindowButton<Msg>, Element<Msg> ) {
let close = window_button( WindowButtonKind::Close ).on_press( Msg::CloseWindow );
// Or the full standard set:
let bar = window_controls(
Some( Msg::Minimize ),
WindowButtonKind::Maximize,
Some( Msg::Maximize ),
Some( Msg::Close ),
);
# ( close, bar.into() )
# }
```
`focusable( true )` opts the button into the Tab cycle (off by default
to match desktop convention).
**See also**: [`crate::theme_window_controls`] for the colour tokens
each mode supplies.
### `list_item`
A row with a primary label, optional subtitle, optional right-aligned
trailing text, and a tappable surface.
**When**: settings menus, navigation lists, contact rows. Doubles its
height when a subtitle is set.
```rust,no_run
# use ltk::{ list_item, ListItem };
# #[ derive( Clone ) ] enum Msg { OpenWifi }
# fn _ex() -> ListItem<Msg> {
list_item( "Wi-Fi" )
.subtitle( "Eduroam" )
.trailing( "" )
.on_press( Msg::OpenWifi )
# }
```
**See also**: [`pressable`](#pressable) for free-form tappable rows,
[`scroll`](#scroll) to wrap a list of items in a scrollable container.
---
## Stateful binary controls
### `toggle`
A two-state on / off switch. Renders as a horizontal pill with a sliding
thumb.
**When**: prominent settings toggles ("Wi-Fi", "Do not disturb").
```rust,no_run
# use ltk::{ toggle, Toggle };
# #[ derive( Clone ) ] enum Msg { ToggleWifi }
# struct App { wifi_enabled: bool }
# impl App { fn _ex( &self ) -> Toggle<Msg> {
toggle( self.wifi_enabled )
.label( "Wi-Fi" )
.on_toggle( Msg::ToggleWifi )
# }}
```
**See also**: [`checkbox`](#checkbox) for less prominent opt-ins,
[`radio`](#radio) for mutually-exclusive groups.
### `checkbox`
A two-state opt-in with a square box and a check glyph.
**When**: form fields, terms acceptance, multi-select lists.
```rust,no_run
# use ltk::{ checkbox, Checkbox };
# #[ derive( Clone ) ] enum Msg { ToggleTerms }
# struct App { accept_terms: bool }
# impl App { fn _ex( &self ) -> Checkbox<Msg> {
checkbox( self.accept_terms )
.label( "I accept the terms" )
.on_toggle( Msg::ToggleTerms )
# }}
```
**See also**: [`toggle`](#toggle), [`radio`](#radio).
### `radio`
A single option inside a mutually-exclusive group. Build one per
variant; the application owns "which is selected".
**When**: priority pickers, layout choices, anywhere exactly one of N
is selected.
```rust,no_run
# use ltk::{ column, radio, Element };
# #[ derive( Clone, PartialEq ) ] enum Priority { Low, Medium, High }
# #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) }
# struct App { priority: Priority }
# impl App { fn _ex( &self ) -> Element<Msg> {
column()
.push( radio( self.priority == Priority::Low ).label( "Low" ).on_select( Msg::SetPriority( Priority::Low ) ) )
.push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) )
.push( radio( self.priority == Priority::High ).label( "High" ).on_select( Msg::SetPriority( Priority::High ) ) )
.into()
# }}
```
**See also**: [`checkbox`](#checkbox), [`toggle`](#toggle).
---
## Continuous controls
### `slider`
A horizontal slider for selecting a value in `[0.0, 1.0]`.
**When**: brightness / volume / scrub bars. Drag the thumb or tap a
position; the change message fires continuously during drag.
```rust,no_run
# use ltk::{ slider, Slider };
# #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) }
# struct App { brightness: f32 }
# impl App { fn _ex( &self ) -> Slider<Msg> {
slider( self.brightness )
.on_change( |v| Msg::SetBrightness( v ) )
# }}
```
`accent_thumb( true )` swaps the default thumb for the two-circle
brand-coloured variant. `track_surface( id )` and `fill_surface( id )`
override the default theme slots.
**See also**: [`vslider`](#vslider) for the vertical axis,
[`progress_bar`](#progress_bar) for read-only progress display.
### `vslider`
A vertical slider. Same value model as [`slider`](#slider) — `0.0` at
the bottom, `1.0` at the top.
**When**: column-shaped equalisers, compact volume / brightness picks
inside narrow side panels.
```rust,no_run
# use ltk::{ vslider, VSlider };
# #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) }
# struct App { volume: f32 }
# impl App { fn _ex( &self ) -> VSlider<Msg> {
vslider( self.volume ).on_change( |v| Msg::SetVolume( v ) )
# }}
```
**See also**: [`slider`](#slider).
### `progress_bar`
A read-only linear progress indicator.
**When**: determinate operations with a known fraction
(downloads, file copies, install steps).
```rust,no_run
# use ltk::{ progress_bar, ProgressBar };
# struct App { download_fraction: f32 }
# impl App { fn _ex( &self ) -> ProgressBar {
progress_bar( self.download_fraction )
# }}
```
**See also**: [`spinner`](#spinner) for indeterminate operations
without a known fraction.
---
## Text input and display
### `text`
A single-line label. Truncates with an ellipsis when wider than its
allocated rect.
**When**: titles, captions, anything non-interactive.
```rust,no_run
# use ltk::{ text, Color, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> ( Element<Msg>, Element<Msg> ) {
let title = text( "Title" ).size( 24.0 ).color( Color::WHITE );
let centred = text( "Centred" ).align_center();
# ( title.into(), centred.into() )
# }
```
**See also**: [`text_edit`](#text_edit) for editable text.
### `text_edit`
A single-line text input with cursor, Backspace, Enter / Submit,
clipboard support, a `secure( true )` password mode that masks the
visible characters and zeroizes the buffer on drop, and a
`password_toggle( visible, on_toggle )` builder that pins a show /
hide-password eye icon to the right edge of the field.
**When**: login fields, chat inputs, search boxes.
```rust,no_run
# use ltk::{ text_edit, Element };
# #[ derive( Clone ) ] enum Msg {
# UsernameChanged( String ), PasswordChanged( String ),
# TogglePassword, Submit,
# }
# struct App { username: String, password: String, show_password: bool }
# impl App { fn _ex( &self ) -> ( Element<Msg>, Element<Msg> ) {
let user = text_edit( "Username", &self.username )
.on_change( |s| Msg::UsernameChanged( s ) )
.on_submit( Msg::Submit );
// Password field with the built-in show / hide eye. The widget
// owns the icon hit-testing — taps inside the eye zone fire
// `TogglePassword` instead of moving the caret. Flip the bool in
// your `update`; the widget reads it back on the next render.
let pw = text_edit( "Password", &self.password )
.on_change( |s| Msg::PasswordChanged( s ) )
.password_toggle( self.show_password, Msg::TogglePassword );
# ( user.into(), pw.into() )
# }}
```
`password_toggle` keeps the wipe-on-drop and IME-bypass guarantees
of `secure( true )` regardless of the current visibility, so
flipping the eye does not weaken the field's threat model at
runtime — only what the user sees on screen changes.
**See also**: the password recipe in
[`docs/cookbook.md`](./cookbook.md#password-field-with-pam-submit).
---
## Decoration and chrome
### `container`
A wrapper that adds padding, a background colour or themed surface, a
border radius, and (via theme slots) shadows / inset shadows / backdrop
blur.
**When**: cards, panels, callouts, anywhere a child needs a backdrop or
elevation.
```rust,no_run
# use ltk::{ column, container, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( title: Element<Msg>, subtitle: Element<Msg> ) -> Element<Msg> {
container( column().push( title ).push( subtitle ) )
.surface( "surface-card" )
.padding( 16.0 )
.radius( 24.0 )
.into()
# }
```
Per-edge padding is available with `padding_top` / `padding_right` /
`padding_bottom` / `padding_left`. Per-corner radius takes a [`Corners`]
struct or a tuple.
**See also**: [`pressable`](#pressable) wrapping a `container` makes a
card interactive.
### `separator`
A horizontal divider line with theme-default colour and 1 px thickness.
**When**: visual breaks between settings groups, list categories,
content blocks.
```rust,no_run
# use ltk::{ column, separator, text, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
column()
.push( text( "General" ) )
.push( separator() )
.push( text( "Network" ) )
.into()
# }
```
### `img_widget`
A static image rendered from RGBA pixel data shared via `Arc`.
**When**: wallpapers, icons, illustrations. The shared `Arc` makes
re-using the same buffer across frames a pointer copy.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ img_widget, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> {
img_widget( rgba_bytes, width, height )
.opacity( 0.8 )
.into()
# }
```
`cover()` scales to fill the rect preserving aspect; `size( w, h )` sets
explicit display dimensions.
**See also**: [`Image::from_path`](../src/widget/image.rs) helper for
disk-loaded files (PNG, JPEG via the `image` crate).
---
## Clipping wrappers
### `scroll`
A vertically-scrollable viewport. Drag the content to scroll; clipping
is automatic.
**When**: lists or grids that may overflow the available height.
```rust,no_run
# use ltk::{ column, list_item, scroll, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
scroll(
column()
.push( list_item::<Msg>( "Item 1" ) )
.push( list_item( "Item 2" ) )
.push( list_item( "Item 3" ) )
)
.into()
# }
```
The scroll widget owns its own gesture handling — drags inside it do
not trigger the app-level `on_swipe_*` callbacks.
**See also**: [`viewport`](#viewport) for passive clipping without
gestures, [`grid`](#grid) inside a `scroll` for app drawers.
### `viewport`
A passive clipping wrapper. Clips its child to the assigned rect; no
scrolling, no gesture handling.
**When**: panels that animate in via a parent translation, fade-in
content, anywhere you want a hard clip without scrolling. The
`fade_bottom( px )` builder feathers the bottom edge to transparent
during slide-in animations (GLES backend; software backend renders a
hard edge).
```rust,no_run
# use ltk::{ viewport, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( panel_view: Element<Msg>, panel_height: f32 ) -> Element<Msg> {
viewport( panel_view )
.height( panel_height )
.fade_bottom( 16.0 )
.into()
# }
```
**See also**: the slide-in panel recipe in
[`docs/cookbook.md`](./cookbook.md#slide-in-panel).
### `flex`
A row-only filler wrapper. Treats its non-spacer child like a
[`spacer`](#spacer) for leftover-width distribution but draws the child
inside the allocated rect.
**When**: a row where one non-trivial child should fill the remaining
width (a card next to a fixed-size icon).
```rust,no_run
# use ltk::{ column, flex, row, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex(
# icon: Element<Msg>,
# title: Element<Msg>,
# subtitle: Element<Msg>,
# ) -> Element<Msg> {
row()
.push( icon )
.push( flex( column().push( title ).push( subtitle ) ) )
.into()
# }
```
`weight( n )` sets the relative share when there are several flex /
spacer siblings.
**See also**: [`spacer`](#spacer) for invisible fillers,
[`column`](#column) and [`row`](#row).
---
## Overlays and feedback
### `spinner`
Indeterminate progress indicator. Animates while in the tree.
**When**: long-running operations with no known fraction
(network calls, indexing, "waiting for compositor").
```rust,no_run
# use ltk::{ spinner, Spinner };
# fn _ex() -> Spinner {
spinner().size( 24.0 )
# }
```
**See also**: [`progress_bar`](#progress_bar) for determinate progress.
### `toast`
Transient notification that floats over the surface and dismisses
itself after a timeout.
**When**: confirmation snackbars ("Saved"), non-blocking errors,
status flashes.
```rust,no_run
# use ltk::{ toast, Toast };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Toast<Msg> {
toast( "Saved" ).duration( 3.0 )
# }
```
**See also**: [`tooltip`](#tooltip) for hover-anchored hints.
### `tooltip`
Anchored hint that appears next to a target widget on hover or focus.
**When**: discoverable affordances on icon-only controls, keyboard
shortcut reminders, helper text that should not occupy permanent
layout space.
```rust,no_run
# use ltk::{ tooltip, Tooltip, WidgetId };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Tooltip<Msg> {
tooltip( "Ctrl+S", WidgetId( "btn/save" ) ).max_width( 240 )
# }
```
The widget paints next to the widget that registered the matching
`WidgetId`. See [`docs/cookbook.md`](./cookbook.md) for the full
overlay wiring.
**See also**: [`toast`](#toast) for transient self-dismissing
notifications.
### `combo`
Editable single-select with a popup list. Supports type-to-filter
and free-text entry.
**When**: pick-one fields with too many options for a row of
[`radio`](#radio) buttons but where a free typed answer is also
valid (autocomplete, country list, theme picker).
```rust,no_run
# use ltk::{ combo, Combo, ComboState };
# #[ derive( Clone ) ] enum Msg { Pick( usize ) }
# fn _ex( state: ComboState ) -> Combo<Msg> {
let items = vec![
"One".to_string(),
"Two".to_string(),
"Three".to_string(),
];
combo( state, items ).on_select_idx( Msg::Pick )
# }
```
**See also**: [`radio`](#radio) for small mutually-exclusive sets,
[`notebook`](#notebook) for tabbed mode switches.
### `tabs`
A bare tab bar — visual selection without owning the page content.
Use this when you want full control over what each tab shows.
**When**: paged settings screens where the panes are large and you
want to keep them as siblings in the tree, not as
[`notebook`](#notebook) children.
```rust,no_run
# use ltk::{ tabs, TabBar };
# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) }
# fn _ex( active: usize ) -> TabBar<Msg> {
tabs::<Msg, _, _>( [ "Profile", "Network", "Privacy" ] )
.selected( active )
.on_select( Msg::TabChanged )
# }
```
**See also**: [`notebook`](#notebook) for tabs that own their pages.
### `notebook`
Tabbed container. Owns the pages and shows only the active one.
**When**: settings dialogs, multi-step forms, anywhere a flat
[`tabs`](#tabs) bar over hand-managed pages would be more code than
benefit.
```rust,no_run
# use ltk::{ notebook, text, Notebook };
# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) }
# fn _ex( active: usize ) -> Notebook<Msg> {
notebook::<Msg>()
.page( "General", text( "general body" ) )
.page( "Advanced", text( "advanced body" ) )
.selected( active )
.on_select( Msg::TabChanged )
# }
```
**See also**: [`tabs`](#tabs) for a bare tab bar without page
ownership.
### `dialog`
Modal or non-modal centered confirmation card with a built-in scrim,
optional title, subtitle, custom body, and a right-aligned action
row. Pressing `Esc` fires the configured `cancel` message.
**When**: destructive confirmations ("Delete file?"), guided pickers
that block other interaction until resolved, "are you sure" gates
before a long-running operation.
```rust,no_run
# use ltk::{ button, dialog, ButtonVariant, Element };
# #[ derive( Clone ) ] enum Msg { Cancel, Confirm }
# fn _ex() -> Element<Msg> {
dialog()
.title( "Delete partition?" )
.subtitle( "This will erase every file on /dev/sda2." )
.cancel( Msg::Cancel )
.action( button::<Msg>( "Cancel" ).variant( ButtonVariant::Tertiary ).on_press( Msg::Cancel ) )
.action( button::<Msg>( "Delete" ).variant( ButtonVariant::Primary ).on_press( Msg::Confirm ) )
.into()
# }
```
`modal( false ) + dismiss_on_scrim( msg )` makes a tap on the dim
background fire `msg`; combining `dismiss_on_scrim` with `modal( true )`
panics at lower time (the contracts contradict). `body( elem )`
swaps a custom element in between the subtitle and the action row —
the example app at `examples/dialog.rs` uses this for an in-dialog
slider.
**See also**: [`toast`](#toast) for non-blocking transient
notifications, [`combo`](#combo) for a single-pick popup that does
not require modal blocking.
---
## Pickers
### `date_picker`
Calendar-grid date selector with month / year navigation.
**When**: birthdays, deadlines, any single-date input.
```rust,no_run
# use ltk::{ date_picker, Date, DatePicker };
# #[ derive( Clone ) ] enum Msg { Picked( Date ) }
# fn _ex() -> DatePicker<Msg> {
date_picker( Date::new( 2026, 5, 7 ) ).on_change( Msg::Picked )
# }
```
### `time_picker`
Hour / minute selector. Wheel-style scrubbing on touch.
**When**: alarms, scheduling, time-of-day input.
```rust,no_run
# use ltk::{ time_picker, Time, TimePicker };
# #[ derive( Clone ) ] enum Msg { Picked( Time ) }
# fn _ex( now: Time ) -> TimePicker<Msg> {
time_picker( now ).on_change( Msg::Picked )
# }
```
### `color_picker`
Hue + saturation/value picker with a hex/RGB readout.
**When**: theming UIs, paint tools, accent customisation.
```rust,no_run
# use ltk::{ color_picker, Color, ColorPicker };
# #[ derive( Clone ) ] enum Msg { Picked( Color ) }
# fn _ex( current: Color ) -> ColorPicker<Msg> {
color_picker( current ).on_change( Msg::Picked )
# }
```
---
## Layouts
### `column`
Vertical flow. Children stack top-to-bottom with optional padding,
spacing, alignment.
```rust,no_run
# use ltk::{ button, column, spacer, text, Element };
# #[ derive( Clone ) ] enum Msg { Ok }
# fn _ex() -> Element<Msg> {
column()
.padding( 24.0 )
.spacing( 12.0 )
.push( text( "Title" ) )
.push( spacer() )
.push( button( "OK" ).on_press( Msg::Ok ) )
.into()
# }
```
`max_width(px)` caps the inner content width; `fit_content()` reports
the natural content width to the parent (otherwise the column claims
the full available width). `center_y( true )` centres the content
block vertically when there are no spacers.
### `row`
Horizontal flow. Mirror of [`column`](#column) on the X axis.
```rust,no_run
# use ltk::{ flex, row, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex(
# label: Element<Msg>,
# field: Element<Msg>,
# submit_button: Element<Msg>,
# ) -> Element<Msg> {
row()
.spacing( 8.0 )
.push( label )
.push( flex( field ) )
.push( submit_button )
.into()
# }
```
### `stack`
Z-order overlay. Each child gets explicit horizontal and vertical
alignment (`HAlign` / `VAlign`) plus an optional margin and pixel
translation. Children are drawn in declaration order — the last child
sits on top.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ button, column, img_widget, stack, text, Element, HAlign, VAlign };
# #[ derive( Clone ) ] enum Msg { Add }
# fn _ex( background: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
stack()
.push( img_widget( background, w, h ) )
.push_aligned(
column().push( text( "Heading" ) ),
HAlign::Center, VAlign::Center,
)
.push_aligned_margin(
button( "+" ).on_press( Msg::Add ),
HAlign::End, VAlign::Bottom,
16.0,
)
.into()
# }
```
**See also**: [`column`](#column) and [`row`](#row) for flow layout.
### `grid`
Fixed-column-count grid that wraps its children into rows.
**When**: icon launchers, photo galleries, app drawers.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ grid, icon_button, Element };
# #[ derive( Clone ) ] enum Msg { OpenApp1, OpenApp2 }
# fn _ex( app1_rgba: Arc<Vec<u8>>, app2_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
grid( 4 )
.padding( 16.0 )
.spacing( 12.0 )
.push( icon_button( app1_rgba, w, h ).on_press( Msg::OpenApp1 ) )
.push( icon_button( app2_rgba, w, h ).on_press( Msg::OpenApp2 ) )
// ...
.into()
# }
```
Wrap inside [`scroll`](#scroll) when the grid may overflow.
### `spacer`
An invisible flexible filler. Inside a column / row, absorbs leftover
space along the parent's main axis.
```rust,no_run
# use ltk::{ column, spacer, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( header: Element<Msg>, footer: Element<Msg> ) -> Element<Msg> {
column()
.push( header )
.push( spacer() ) // pushes footer to the bottom
.push( footer )
.into()
# }
```
`weight( n )` sets the relative share; `height( px )` / `width( px )`
pin the spacer to a fixed size on its respective axis.
**See also**: [`flex`](#flex) for a non-empty filler.