First commit. Version 0.1.0
This commit is contained in:
247
docs/architecture.md
Normal file
247
docs/architecture.md
Normal 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 ~250–380) |
|
||||
| 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
825
docs/cookbook.md
Normal 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
385
docs/onboarding.md
Normal 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
737
docs/theming.md
Normal 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
851
docs/widgets.md
Normal 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.
|
||||
Reference in New Issue
Block a user