Files
ltk/docs/onboarding.md
Pedro M. de Echanove Pasquin ccf07de593
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Session management: xdg-session-management-v1 client, mandatory App::app_id / save_state / restore_state, runtime-managed state persistence and clean exit on signals (0.3.0)
Applications built on ltk had no way to come back where the user left them: the toolkit hardcoded `app_id = "ltk"` on every toplevel, never wrote anything to disk, and died on SIGTERM without a chance to save. This release gives the runtime the whole plumbing and asks each application only for the bytes worth keeping, in the spirit of Android's saved-instance state.
The `App` trait gains three mandatory methods, deliberately without default bodies so every application states its position: `app_id()` (reverse-DNS, used for `xdg_toplevel.set_app_id`, the AccessKit application name and the state directory — the `app_id` element of the deprecated `window_config` tuple is now ignored and a one-time warning reports a mismatch), `save_state() -> Option<Vec<u8>>` and `restore_state(Vec<u8>)`. The bytes are opaque; the trait carries no serde bound. Their rustdoc is the contract: when the runtime saves, where the files live, when the bytes come back and when they do not, what must never go in them, and a worked serde_json example.
The runtime persists under `$XDG_STATE_HOME/<app_id>/` (falling back to `~/.local/state`): `session.json` holds the compositor session id, a clean-exit marker and the writer's pid; `state.bin` holds the application bytes. Writes are atomic (temp file + rename, mode 0600, directory 0700) and best-effort. State is saved every 30 s when the bytes changed, once after the event loop exits (which covers `on_close_requested`, `requested_exit` and lost connections), and on SIGTERM/SIGINT — a calloop signal source, installed before any thread exists, now turns those into a clean exit of the loop instead of process death. `restore_state` runs synchronously in `try_run` before the window is created and before the first `view()`, and only when the process is relaunched as part of a session restore (`LTK_SESSION_RESTORE=1`, removed from the environment before the app can spawn children) or when the previous run left `clean_exit: false`; a plain launch starts fresh. A second concurrent instance detects the live pid and runs with persistence disabled rather than clobbering the first.
The compositor side of geometry restore goes through `xdg-session-management-v1`. Neither wayland-protocols nor sctk ship generated code for it yet, so the XML is vendored under `protocols/` and `wayland-scanner` generates the client module in-tree (`src/protocol/`), resolving the crate names through sctk's reexports so the bindings stay on the crate instances sctk links. Before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, calls `get_session(reason, stored_id)` and `restore_toplevel(toplevel, "main")`; the three window-creation paths in `run.rs` are folded into one `make_window` helper so the attach always sits immediately before `commit()`. `created` persists the id, `replaced` destroys the objects and stops persisting. Compositors without the global lose only the geometry half. Layer-shell and session-lock surfaces skip the whole machinery.
Every `App` implementor in the tree is updated: the twelve examples (`showcase`, `scroll` and `mini_shell` persist real state; the rest return `None`), both integration tests (`event_loop_flow` gains `save_restore_round_trip`), the in-source and markdown doctests, README, onboarding, cookbook (new recipe "Surviving relaunch: session state") and architecture docs, and the changelog. `src/session_state.rs` carries unit tests over a temporary state directory. `Makefile install` now copies `protocols/` into the cargo registry — without it downstream builds would fail inside the proc-macro — and `debian/copyright` covers the vendored XML.
The trait change is breaking, hence 0.3.0. Also fixes the pre-existing `viewport_tests` module in `render/mod.rs`, which used `Length` without importing it and broke `cargo test`.
2026-08-15 10:16:30 +02:00

15 KiB

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

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 testcargo 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 doccargo 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:

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

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

[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()
  • 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:

  • Appapp_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:

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

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

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 and the Length rustdoc.

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

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.

In the README's recommended order — onboarding, then the widget catalogue, then the cookbook, then architecture: