Restore on launch. `restore_state` follows Android's saved-instance-state contract: the saved bytes come back after a crash or a session restore, and a launch from the launcher starts fresh. Some apps keep state their users expect to find however the last run ended, as an Android browser keeps its tabs in its own storage even when closed from the app switcher. `App::restore_on_launch`, false by default, has the runtime restore on a plain launch too; `SessionRuntime::bootstrap` asks it before deciding. Saving, the file layout and the window geometry are the same either way. Its documentation explains when it is asked, that it is not when persistence is off, that `restore_state` must then merge what the launch itself asks for, such as a URL on the command line, and that what `save_state` returns now outlives every exit; it carries a compiled example. `restore_state`'s documentation, `docs/onboarding.md` and the cookbook's session state recipe mention it. Focus on a press. `Button::focusable( false )` and `WindowButton` kept out of the Tab cycle but still took the focus when pressed, and Enter then pressed them again, which closed a browser's tab when the user pressed Enter in a page after closing another tab. `LaidOutWidget` gains `focus_on_press`, from `Element::focus_on_press`: a button's `focusable`, a `WindowButton`'s, a pressable's new `Pressable::focus_on_press`, true by default, and true for every other widget. `focus_pressed` replaces `set_focus` for pointer and touch presses and leaves the focus where it was when the widget does not take it. Enter's fallback to the widget under the pointer, which `hovered_idx` follows, skips such widgets too, and a rebuild that leaves the focus on one drops it (`drop_misplaced_focus`). The close buttons of `tab_strip`, on the desktop and on the phone's cards, are not focusable; `symbol_button` takes whether its button is. Focus by id. The focus was held as the index of a widget in the laid-out tree, so a rebuild that inserted or removed widgets before it moved it to whatever widget took that index. `WidgetId` becomes a name and a key, `WidgetId::keyed( name, key )` telling apart widgets of the same kind, such as the tabs of a strip; `WidgetId( "name" )` still builds one, through a `const fn` of that name, so the calls in every app compile unchanged, and only code that read `.0`, the toasts, the combo and the tooltip here, moves to the derived `Hash` or `name()`. After each layout `follow_focused_id` finds the focused id and moves the focus to its new index with the field's pending text, cursor, selection and scroll; it drops the focus when the widget is gone, keeps it when a scroll only clipped it, and leaves it when the widget at the old index still has the id. `draw_surface` returns whether the focus moved, and its callers draw the surface again rather than marking it done, since the frame just drawn showed the focus in the old place. Widgets without an id keep the index. Each tab and each card of `tab_strip` carries `WidgetId::keyed` with the tab's key, so a chosen tab keeps the focus while others close. Focus ring. A press that focused a button, a toggle or a list item left its ring drawn, which browsers have not done since `:focus-visible`. `SurfaceState::focus_visible` is set by Tab and Shift+Tab and cleared by a press that moves the focus, `show_focus_ring` redrawing the whole surface when it changes, since the partial redraw only sees indices. The draw is given the focused index only while the ring shows or the widget is a text field, which draws its cursor however it was focused; the focus itself is unchanged, so Enter and Space still press the widget. Key repeat. A key repeated by the platform rather than by ltk's own timer reached the focused widget even while `App::claims_keyboard` was true; `key_repeated` checks it as `key_press` does. Browser surfaces. Every surface is painted through the app's one WebGL context on one `OffscreenCanvas`, and was handed to its own canvas, a `bitmaprenderer`, with `transferToImageBitmap`. WebKit reuses the buffer of a bitmap it was handed, so in a WebKit browser each surface showed another's frame, or none, and only the last one painted was right: forge/web's background, dock and windows came and went in a WPE browser while Chromium showed them all. A test page with two canvases fed from one offscreen canvas showed their colours swapped in WPE and right with a copy. Each surface's canvas is now a `2d` one, and `present_copy` copies the offscreen canvas with `drawImage` in `copy` composition, set before every copy since resizing a canvas resets its context, so a transparent frame replaces the last rather than leaving trails behind an animation. Subsurfaces go the same way. `WebSurfaceState` loses `bitmap` and `new` its `gpu` argument, and web-sys loses the `ImageBitmap` and `ImageBitmapRenderingContext` features. CHANGELOG records each of these. Not done. The tests that build `LaidOutWidget` by hand set the new field, but no test covers a press on a non-focusable button, the focus following an id or the ring. For one frame after a rebuild moves the focused widget, its ring and its cursor may show in the old place.
496 lines
15 KiB
Markdown
496 lines
15 KiB
Markdown
# 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 — on Debian (the crate's own packaging target):
|
|
`fonts-sora`, `fonts-liberation` or `fonts-dejavu`
|
|
- 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
|
|
LTK_THEMES_DIR=themes cargo run --example showcase
|
|
```
|
|
|
|
The `LTK_THEMES_DIR=themes` prefix points theme lookup at the in-repo
|
|
`themes/` directory (see *Theme and font setup* below); without it, on a
|
|
machine without the `default` theme installed, the example runs on the
|
|
embedded B/W fallback with a red banner.
|
|
|
|
The other examples, same prefix:
|
|
|
|
- `cargo run --example widgets` — broad widget survey
|
|
- `cargo run --example inputs` — plain and secure text fields with a
|
|
show/hide-password toggle
|
|
- `cargo run --example scroll` — the two main scroll use cases: a long list
|
|
and an app-drawer-style grid
|
|
- `cargo run --example sliders` — the Glass effect on horizontal and
|
|
vertical sliders
|
|
- `cargo run --example combo` — select/dropdown with editable query and
|
|
multi-select chips
|
|
- `cargo run --example pickers` — notebook tabs, date, time and color
|
|
pickers
|
|
- `cargo run --example dialog` — modal confirm, non-modal pick and the
|
|
other dialog shapes
|
|
- `cargo run --example carousel` — focused-tile carousel
|
|
- `cargo run --example responsive` — fluid vs physical scaling side by side
|
|
- `cargo run --example clip_path` — per-path canvas clipping
|
|
- `cargo run --example mini_shell` — overlays, animation and theme switching
|
|
|
|
All examples require a running Wayland compositor.
|
|
|
|
## Build and test
|
|
|
|
The `Makefile` wraps the cargo invocations the repo expects:
|
|
|
|
- `make all` — release build
|
|
- `make test` — `cargo test --features test-support`; a bare `cargo test`
|
|
fails because the integration tests import the feature-gated
|
|
`ltk::test_support`
|
|
- `make doctest-md` — typechecks the code snippets in `docs/*.md`, so API
|
|
drift surfaces in CI like a normal doctest failure
|
|
- `make examples` — runs every example in sequence with
|
|
`LTK_THEMES_DIR=themes`
|
|
- `make doc` — `cargo doc --no-deps`
|
|
|
|
## 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. `src/system_fonts.rs` walks a
|
|
chain of common system font *file paths* — the files installed by the
|
|
Debian packages `fonts-sora`, `fonts-liberation`, `fonts-freefont` and
|
|
`fonts-dejavu`, plus the equivalent locations other distros use — and
|
|
loads 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
|
|
font resolution 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;
|
|
|
|
// Names the window for the compositor and the a11y tree, and the
|
|
// `$XDG_STATE_HOME/<app_id>/` directory the runtime saves state in.
|
|
fn app_id( &self ) -> &str { "net.example.Counter" }
|
|
|
|
// Opaque bytes ltk persists for you; handed back only on a session
|
|
// restore or after a crash — a plain launch starts fresh.
|
|
fn save_state( &self ) -> Option<Vec<u8>>
|
|
{
|
|
Some( self.value.to_string().into_bytes() )
|
|
}
|
|
|
|
fn restore_state( &mut self, state: Vec<u8> )
|
|
{
|
|
if let Some( v ) = String::from_utf8( state ).ok().and_then( |s| s.parse().ok() )
|
|
{
|
|
self.value = v;
|
|
}
|
|
}
|
|
|
|
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`, `img_widget`
|
|
- `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()`
|
|
- repainting an external texture's new frame without rebuilding the view via `needs_repaint()`
|
|
- 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 multi-surface example, use `examples/mini_shell.rs` as the
|
|
reference entry point — note it runs as a regular window (it never overrides
|
|
`shell_mode()`) and demonstrates screen routing, coordinated overlays, an
|
|
animated OSD and live theme switching. The layer-shell knobs themselves are
|
|
exercised by the downstream shell components, not by the in-repo examples.
|
|
|
|
## The APIs you will touch first
|
|
|
|
In practice, most first apps only need a small subset of the surface area.
|
|
|
|
Start here:
|
|
|
|
- `App` — `app_id`, `view`, `update`, `save_state` / `restore_state`
|
|
- `Element<Msg>`
|
|
- `button`, `text`, `text_edit`, `img_widget`
|
|
- `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`.
|
|
|
|
## Responsive sizing: fluid vs physical
|
|
|
|
`ltk` gives you two ways to make an interface adapt to the display, and you can
|
|
mix them per value. **Fluid** sizes are a fraction of the surface (best for
|
|
full-screen system surfaces); **physical** sizes stay a constant real-world
|
|
size (best for conventional windowed apps). In practice you write a size as a
|
|
`Length` and bound it with `.clamp`:
|
|
|
|
```rust
|
|
use ltk::{ text, Length };
|
|
|
|
// Fluid: 6 % of the surface's short side, never below 20 px nor above 44 px.
|
|
text("Welcome").size(Length::vmin(6.0).clamp(20.0, 44.0));
|
|
```
|
|
|
|
Stock widgets follow a process-wide mode (`set_widget_scaling`, fluid by
|
|
default); an explicit `Length` on a widget overrides it. For the units
|
|
(`vmin` / `orient` / `dp` / …), the clamp discipline and how it all resolves,
|
|
see the *Responsive sizing* section of
|
|
[`architecture.md`](architecture.md#responsive-sizing) and the `Length`
|
|
rustdoc.
|
|
|
|
## 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`.
|
|
|
|
## Session state
|
|
|
|
`ltk` owns the plumbing, you own the bytes. `save_state` returns whatever a
|
|
relaunch needs (any encoding — the runtime never looks inside) and the
|
|
runtime writes it to `$XDG_STATE_HOME/<app_id>/state.bin` every 30 s when it
|
|
changed, when the window closes and on `SIGTERM` / `SIGINT`. `restore_state`
|
|
gets those bytes back before the first frame — but only when the shell
|
|
relaunches the app as part of a session restore (`LTK_SESSION_RESTORE=1`) or
|
|
when the previous run did not exit cleanly. Opening the app from the launcher
|
|
starts fresh, unless `restore_on_launch` returns `true`, as a browser does to
|
|
bring its tabs back; window size and position still come back on every launch
|
|
because the compositor restores them through `xdg-session-management-v1`.
|
|
Shell components (panels, lock screens) return `None` and no-op. See the
|
|
cookbook recipe *Surviving relaunch: session state* for a worked example.
|
|
|
|
What those bytes should hold is the app's *navigation state* — which screen,
|
|
which panel, how far into a history — because that is also what the system
|
|
back button walks through `App::on_back`. Keeping the two the same makes back
|
|
retrace the same places after a restore. An app with more than one screen
|
|
should read [`docs/navigation.md`](./navigation.md) before growing its second.
|
|
|
|
## 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
|
|
|
|
In the README's recommended order — onboarding, then the widget catalogue,
|
|
then the cookbook, then architecture:
|
|
|
|
- [`docs/widgets.md`](./widgets.md) — per-widget catalogue: what each one
|
|
is, when to use it, minimal example
|
|
- [`docs/cookbook.md`](./cookbook.md) — concrete recipes: slide-in panels,
|
|
password fields, runtime theme toggle, channel-driven state
|
|
- [`docs/architecture.md`](./architecture.md) — multi-surface patterns,
|
|
theming, animation and performance
|
|
- [`docs/theming.md`](./theming.md) — JSON theme schema, slot conventions,
|
|
runtime APIs
|
|
- [`docs/backends.md`](./backends.md) — software/GLES capability matrix
|
|
- [`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
|