Files
ltk/docs/architecture.md
T
admin 85aa85a357
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The GLES backend keeps one glyph atlas per GL context, split into pages, so a surface that is created again does not rasterise and upload its text again
Every GLES canvas and sub-canvas had a glyph atlas of its own, a 2048×2048 texture created empty with the canvas, so every surface that was torn down and built again, and every scroll viewport inside it, rasterised and uploaded all of its glyphs on its first frame. crustace's app launcher pays this on every opening: the sliding subsurface and its scroll sub-canvas are destroyed when the launcher closes, and the interactive overlay that replaces the subsurface at the top has scroll sub-canvases of its own. With `LTK_PERF_RASTER=1` on a PinePhone Pro (RK3399, Mali-T860, panfrost) the first raster of the subsurface took 175 to 330 ms of CPU and the first full raster of the overlay 175 to 260 ms, on every opening, with the GPU done 3 ms later; the user saw a delay of two or three tenths of a second before the launcher followed the finger and a stall of about one more when it reached the top. On a Librem 5 (i.MX8MQ, GC7000L, etnaviv) the same rasters took 18 to 40 ms.
Panfrost makes each glyph upload expensive. The glyphs of a line are written into the atlas with `glTexSubImage2D` after earlier lines were drawn from it, and when a pending batch still samples a texture that is written to, Mesa 25.0 allocates a new buffer for the texture and copies the whole old one into it with the CPU (`panfrost_ptr_map` in `pan_resource.c`), 4 MiB for the old atlas, read from write-combined memory. Etnaviv flushes and waits for the GPU instead (`etna_transfer_map`), whose cost does not depend on the size of the texture.
The new `gles_render/glyph_atlas.rs` holds one `GlyphAtlas` per GL context, found through `shared_glyph_atlas` as the image cache is through `shared_image_cache`, and every canvas and sub-canvas keeps an `Rc` to it, so glyphs rasterised by one surface are resident for the next. The context is held as a `Weak`, so the atlas of a context that was dropped is forgotten instead of being found again by a new context allocated at the same address. The atlas is made of 512×512 pages, created as glyphs need them, up to 8 MiB in all; a glyph larger than a page gets a page of its own, up to the 2048 px of the old atlas, and those pages are freed when the atlas is reset, the regular ones kept and rewound. A write into a page that a pending draw samples then costs panfrost a copy of 256 KiB, not of 4 MiB. The glyph cache moves into the atlas with `GlyphEntry`, which records the page of each glyph, and its key; the cap of 8192 glyphs before a reset is now for the context as a whole, not for each canvas. `draw_text` uploads through the atlas and groups the quads of a line by page, one draw call per page instead of one per line, which only differs when the glyphs of a line are split across pages. `GlesCanvas::new` and `sub_canvas` no longer create an atlas texture each, and dropping a canvas no longer deletes one. `docs/architecture.md` says that glyphs are cached per GL context too and how the atlas is paged.
Measured with `LTK_PERF_RASTER=1`, opening and closing the launcher a dozen times. On the PinePhone Pro the subsurface raster of the first opening went from 330 to 95 ms and of the later ones from 175–215 ms to 2–10 ms, with peaks of 17 and 35 ms, and the first raster of the overlay from 175–260 ms to under 1.5 ms; the other frames stay at 2 to 13 ms. On the Librem 5 the subsurface raster went from 22.7 ms on average, 85.6 ms on the first opening and over 15 ms in 9 of 14 openings, to 13.2 ms, 69.7 ms and 3 of 14, and the other frames from 7.7 ms on average and 43 ms at most to 6.8 ms and 28.5 ms.
Not done. The first raster of a session still uploads its glyphs interleaved with the draws that sample them; uploading all of them before the first draw would need a second pass over the view on every frame. There are no tests for the atlas. The image cache still identifies its context by address only.
2026-10-15 22:34:03 +02:00

44 KiB
Raw Blame History

ltk architecture

If you are new to the library, start with docs/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/crates/lockscreen/src/) — greeter / lock screen. keyboard_exclusive, focus management, a subsurface-driven reveal animation, async PAM on a worker thread drained in poll_external.

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.

Module map

Where things live under src/, one line each:

  • a11y/ — AccessKit tree building and the AT-SPI2 bridge.
  • app.rs — the App trait, OverlaySpec, SubsurfaceSpec, run / try_run.
  • chassis.rs — scaffolding for full-screen ambient surfaces (greeter, lock screen, kiosk): theme bring-up, branding/wallpaper loading, the wallpaper-backed view stack.
  • core.rs — UiSurface, runtime-free embedding.
  • draw/ — the per-frame drawing pipeline shared by both backends.
  • egl_context.rs — EGL bootstrap for the GPU path.
  • event_loop/ — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, session management, perf guardrails.
  • gles_render/ — GPU backend (EGL + GLES2 / GLES3).
  • input/ — pointer, keyboard and touch handling, the gesture machine, dispatch.
  • layout/ — composable arrangers for Element trees.
  • osk/ — the on-screen keyboard: layouts from JSON files, a host-driven Panel, and its drawing. See On-screen keyboard.
  • protocol/ — in-tree wayland-scanner bindings for protocols wayland-protocols does not generate yet (xdg-session-management-v1).
  • render/ — software rendering surface used by every widget.
  • secure_mem.rs — volatile wipe of secret buffers behind TextEdit's secure mode.
  • session_state.rs — $XDG_STATE_HOME/<app_id>/ session id + app-state files (atomic writes, clean-exit marker).
  • system_fonts.rs — primary-font resolution and the per-glyph fallback chain.
  • text_shaping.rs — BiDi reordering and rustybuzz (HarfBuzz) shaping.
  • theme/ — theme documents, slot stores, the embedded fallback.
  • tree.rs — element-tree traversal helpers.
  • types.rs — geometry and primitive value types (Length, Color, Rect, …).
  • wallpaper.rs — orientation-aware wallpaper helper.
  • widget/ — the widget set.

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.
  • app_id(&self) -> &str — reverse-DNS id shared by the toplevel app_id, the a11y tree and the $XDG_STATE_HOME/<app_id>/ session directory.
  • view(&self) -> Element<Msg> — main surface contents.
  • update(&mut self, msg: Msg) — state transitions.
  • save_state(&self) -> Option<Vec<u8>> / restore_state(&mut self, Vec<u8>) — opaque bytes the runtime persists (every 30 s when changed, on close, on SIGTERM/SIGINT) and hands back before the first frame on a session restore or after an unclean exit — never on a plain launch. None / no-op for shell components and stateless tools.

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). An opaque colour also declares the whole surface opaque to the compositor, which then neither blends it nor repaints what lies underneath — return one whenever the view covers the surface (a wallpaper-backed homescreen).
  • overlay_opaque_region( id ) → the physical rects of an overlay that are fully opaque this frame (a panel column painted over an opaque wallpaper), for the same compositor savings on a surface whose background is transparent. None (default) declares nothing.

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. Characters, Backspace and Delete stay in a focused text input and reach the app otherwise, a focused button or pressable included; Return and Space go to the focused widget's submit or press message when it has one; arrows are offered to the app first and fall back to the text cursor or list navigation.
  • swipe_threshold, swipe_down_threshold — gesture sensitivity.
  • on_mouse_button( &MouseButtonEvent ) — every press and release of the left, middle and right buttons (MouseButton), with the position in physical pixels and Ctrl, Shift and Alt, before ltk handles it: for an app that hands the mouse on, as a terminal does to a program that asks for it.
  • on_modifiers( ctrl, shift, alt ) — the modifiers held changed, for an app that needs them with the wheel or a pointer motion, which do not carry them.
  • on_keyboard_leave() — the window lost the keyboard, so no release will come for the keys still held: an app that repeats keys on its own timer stops there, and forgets the modifiers it kept.
  • on_pointer_hover( SurfaceTarget, Option<( x, y )> ) — the mouse moved over that surface, in its physical pixels, or left it with None: for hover effects that follow the pointer, as a dock magnifying the icons under it.

Implement when the app has more than one place to be

  • on_back() → BackResponse — the system back button or gesture (XF86Back) and the mouse's back button. Return the message that undoes the top-most piece of navigation state, derived from the model; Unhandled at the root. The runtime closes its own transient UI and drops text focus before asking. Escape is a separate request that never navigates.
  • back_at_root() → BackAtRoot::Minimize (default) / Close / Ignore — what back does once nothing is left.
  • NavStack<R> — the ready-made navigation state for an app made of screens; the same value answers on_back and is what save_state persists. See docs/navigation.md.

Implement for animations and focus

  • is_animating() — return true while a tween is running; the loop redraws on every compositor frame callback (~60 Hz on a typical display, capped at ~30 Hz on the software backend — see Performance).
  • take_focus_request() → Option<WidgetId> — pull-once focus retargeting.
  • on_text_input_focused(active) — surface IME state.
  • external_text_input() → Option<u64> with on_external_text_commit / on_external_text_delete — hold the input method for a text field ltk does not own (an editable in an embedded web view); a new serial renews the request.
  • builtin_keyboard() → bool — draw ltk's own on-screen keyboard under a focused text field even when the compositor offers an input method. See On-screen keyboard.

Implement for window / toplevel lifecycle

  • app_id() — also the key of the compositor-side xdg-session-management-v1 session; ltk issues restore_toplevel before the first commit so size/position come back on every launch.
  • window_config() — deprecated forced-window escape hatch; prefer shell_mode().
  • on_close_requested() — return false to veto a close (compositor request, titlebar button, layer-shell closed event).
  • on_toplevel_event(event) — open / close notifications from ext-foreign-toplevel-list-v1, keyed by a stable handle id.
  • requested_exit() — polled after every batch of updates; return true to tear the surface down and exit the loop (a SessionLock surface is unlocked first).

Relatedly, ltk::try_run( app ) is the fallible variant of ltk::run — it returns a RunError (no Wayland connection, missing protocol) instead of aborting, for apps that want a CLI fallback or a clean diagnostic.

The defaults for everything else are sensible enough that a minimal app overrides only the items 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: the module's actual re-exports — ChannelSender, InvalidationScope, SurfaceTarget, the runtime theme state (active_document, set_active_mode and the theme_* accessors) and the embedding surface core::UiSurface. The trait hooks that pair with them (set_channel_sender, poll_external, poll_interval, invalidate_after, take_focus_request, is_animating) live on App itself.

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, anchor_widget_id, size, layer, exclusive_zone, 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.

Separate from overlays, subsurfaces() returns SubsurfaceSpec entries: input-transparent wl_subsurface children composited over the main surface and diffed by id, like overlays. They are the cheap way to move a pre-rendered element around every frame — the compositor repositions the subsurface instead of the app repainting the whole tree. Loginmanager's slide-to-unlock reveal is the reference.

If your application does not need overlays or layer-shell, you can ignore this entire section and stay in the ltk::window subset.

On-screen keyboard

Text entry on a phone comes from zwp_text_input_v3: a focused TextEdit enables the protocol and the compositor's input method, a separate process such as squeekboard, draws a keyboard and commits text through it. ltk carries a keyboard of its own for the cases where that chain is not there, and for compositors that would rather draw it themselves. Everything lives in ltk::osk.

Layouts are data. A layout is one JSON file per language, keyboards/es.json, searched in LTK_KEYBOARDS_DIR (when set), $XDG_DATA_HOME/ltk/keyboards (or ~/.local/share/ltk/keyboards) and /usr/share/ltk/keyboards, highest priority first; ltk::osk::layout::search_paths() returns the list, available() the names found, load( name ) one layout. The English layout is compiled in as the last resort. A file has name, an optional label for the layout-switch key, layers and variants. A layer is a list of rows; a row is a list of keys; a key is a bare string for the text it types, or an object with action (shift, backspace, enter, space, layer, layout, hide), layer for the target of layer, label, and width in key units, 1 by default, which share the row's width. The base layer is mandatory; a missing shift layer is derived from base by uppercasing. variants maps a key's text to what a long press offers; the shifted form of a key uses its base's variants uppercased.

A key may also be a named key, { "key": "Escape" }, Tab, the arrows, Home, End, Page_Up, Page_Down, Delete, Insert and F1 to F12, or a sticky modifier, { "modifier": "ctrl" } or alt. Those are what keyboards/bar-terminal.json is made of: the row a terminal needs, independent of the language, which the panel lays above the language's layout when its Purpose is Terminal. A bar-*.json file is a row, not a layout, and available() leaves it out of the layouts the layout-switch key cycles through.

Panel is a state machine a host drives. It holds the loaded layouts, the current layer, one-shot or locked shift, the sticky modifiers, the purpose and its extra row, the geometry of all of it inside the rectangle the host gave it with set_rect( rect, scale ), and the press in flight. The host feeds it press_down, press_move, press_up and, from its own timer, tick, each returning an Event the host acts on: Text, Backspace, Enter, Hide, Key( keysym, modifiers ) for what text cannot carry, an arrow, Escape, Tab, or a character while Ctrl or Alt is held, or Changed when only the panel's own look changed. set_purpose switches the extra row; the runtime derives the purpose from the focused field, and a compositor takes it from the content_type a client sets on zwp_text_input_v3, which has a terminal purpose that terminal emulators built on VTE declare; for one that does not, the compositor can decide by app_id. next_deadline() says when tick next has work: the variants popup opens 450 ms into a press, backspace repeats after the same delay. height( width, scale ) is what the panel needs at that width for its current layer, since keys are as tall as one key unit is wide; draw( canvas ) paints it with the active palette. Nothing in Panel knows about text fields or Wayland, which is what lets a compositor host it.

The runtime shows it under a focused text field when the compositor advertises no zwp_text_input_manager_v3, when LTK_BUILTIN_OSK=1 is set (useful to try it on a desktop that has an input method), or when the app returns true from builtin_keyboard(). The panel sits at the bottom of the surface; the widget tree is laid out in the space above it, so the focused field is never covered, and presses inside it never reach the gesture machine. Text goes through the same insertion path as a physical key, Key through the same dispatch as one, with Ctrl applied for its duration, Enter submits the field or inserts a newline in a multiline one, Hide keeps the focus and takes the panel away until the field is tapped again; losing the focus removes it.

A compositor builds a Panel for its own chrome, draws it with a UiSurface canvas, and turns the events into zwp_text_input_v3 commits and deletes for Text, and into key events on its seat for Key, which is the only way an arrow or a Ctrl combination reaches a client, while it shrinks the focused toplevel by the panel's height. Forge is meant to be that host.

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:

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

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. A panel that wants a frosted version of the same wallpaper takes bundle.blurred( divisor, sigma ), a bundle divisor times smaller on each side and blurred, which the renderer scales back up: a fraction of the memory of a second image, and no second decode.

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.

Responsive sizing

Every size in a widget tree is a Length, resolved to concrete pixels at layout time against the surface. Two coordinate spaces matter. Geometry (widths, heights, paddings, gaps, box sizes) is computed in physical pixels — the layout root rect is pw × ph — so geometry Length values resolve against Canvas::viewport_layout() (physical). Font sizes are the exception: they resolve against Canvas::viewport_logical() (physical ÷ dpi_scale) and are multiplied by dpi_scale again at raster time, so a vmin font ends up as a fraction of the physical short side regardless of dpi_scale. Keep this split in mind when adding a widget: resolve a geometry constant with Canvas::geom_px(n) and a font constant with Canvas::font_px(n) — the two helpers hide the difference. Font resolution additionally multiplies by the global accessibility text_scale() — the run loop keeps it synced to the desktop's org.gnome.desktop.interface text-scaling-factor GSettings key (initial gsettings get plus a gsettings monitor watcher thread) and repaints on change, so every ltk app follows the "large text" setting live; geometry never scales with it.

ltk offers two adaptation strategies, and both live in the same Length type so an app can mix them per value:

  • Fluid (Length::fluid(n), and the raw vmin / vmax / vw / vh / orient units): surface-proportional. fluid(n) reads a single design pixel n as vmin(n / fluid_reference() * 100).clamp(n * FLUID_MIN, n * FLUID_MAX) — at a surface whose short side equals the reference (412 px by default) it is exactly n, and it scales with the short side elsewhere, auto-clamped to [0.7n, 1.5n]. This tracks the width in portrait and the height in landscape, because the short side is the width in portrait and the height in landscape. orient(portrait, landscape) is the escape hatch for a different percentage per orientation.
  • Physical (Length::dp(n)): constant physical size. dp(n) resolves to n × the pixel density (default 1.0, typically set from the output DPI via set_density). It does not scale with the surface, only with pixel density — the mainstream HiDPI dp. The multiplication happens at resolution time, not when the Length is constructed: a dp value carries its design pixels, so a density change takes effect on the next paint without rebuilding the view's lengths.

Stock widgets do not hard-code either strategy. Each carries a design pixel per dimension (e.g. button height 48, font 16) and resolves it through the process-wide WidgetScaling mode: Length::widget(n) returns fluid(n) under WidgetScaling::Fluid (the default) or dp(n) under WidgetScaling::Physical. set_widget_scaling(mode) flips it once for the whole app. An explicit Length on an individual widget (button.height(...), text_edit.height(...), font_size(...)) bypasses the mode entirely — the mode only decides the meaning of the default design pixels, never an override the app wrote on purpose.

Both density() and widget_scaling() are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them. Density is also overridable per canvas: Canvas::set_density pins a canvas (and every sub-canvas derived from it) to its own factor, and all canvas-routed resolution — geom_px / font_px for stock-widget design pixels, Canvas::resolve_geom / resolve_font for explicit Length values — uses the local density when one is pinned and the process global otherwise. This is the hook for a surface sitting on an output whose DPI differs from the one the global was derived from (an overlay on a second monitor, an embedder with several UiSurfaces). The layout viewport follows the same inheritance model: sub-canvases (scroll and viewport clips, GLES clip layers) inherit the root surface's layout viewport so fluid and viewport-relative values resolve identically inside and outside a clip, and Viewport::local_viewport() opts a clip out of it — its child then resolves against the clip's own rect, for fixed-size floating mini-UIs whose content must not scale with the host surface.

Length adapts sizes to the orientation; to adapt the structure of a layout (a row of panels in landscape, the same panels stacked in portrait), branch the view on ltk::orientation(). The runtime records the main surface's physical dimensions on every configure (also readable as ltk::viewport_size()) and rebuilds the view after each resize, so a match ltk::orientation() { Landscape => row()…, Portrait => column()… } follows the window live. The portrait/landscape rule matches Length::orient (a square surface counts as portrait). examples/clip_path.rs shows the pattern.

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

# 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 on every compositor frame callback — ~60 Hz on a typical display with the GLES backend, capped at ~30 Hz on the software backend by default (see Performance). Do not mutate state in view(); instead read Instant::now() against a stored start time and compute the tween value:

# 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/, launcher/, quicksettings/, 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".

Navigation state is one value. The part of the model that says where the user is — current screen, open panel, position in a history — is what the back button walks (App::on_back) and what a relaunch restores (App::save_state). Keep it as one value (a NavStack<Route>, an enum, a small struct) rather than booleans scattered across screens, derive the back answer from it instead of recording things to undo, and persist that same value. Transient UI — menus, popups, dialogs, text focus — stays out of both: the runtime closes it on back and on Escape, and nobody expects it after a relaunch. docs/navigation.md is the full treatment.

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 Vecs. 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 ten 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 the full animation rate 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. A plain viewport without fade_bottom is cheaper: it clips with a scissor on the parent canvas and adds no offscreen pass, so it is the right wrapper for a panel whose visible height animates.
  • Themed surfaces with shadows are pre-rendered on GLES. A container.surface(...) whose slot carries outer or inset shadows (all with Normal blend) is drawn once per (size, corners, style) into a texture held by the context's cache and blitted afterwards, so a panel of a dozen glass cards costs a dozen textured quads per frame instead of six shader passes per card. Sliders and surfaces with PlusLighter / Overlay insets keep the direct path.
  • Image textures and glyphs are cached per GL context, not per canvas. On the GLES backend every canvas and sub-canvas of a process shares one content-keyed texture cache (32 MiB, LRU) and one glyph atlas, so an overlay or subsurface that is destroyed and recreated finds its icons, wallpaper and glyphs resident; destroyed overlay canvases are pooled and reused too, shaders included. The glyph atlas is made of 512×512 pages created on demand (8 MiB in all, a glyph too large for a page gets one of its own), so writing a new glyph into a page that a pending draw samples costs the driver at most a copy of that page. Keep image buffers stable (Arc clones) so the fingerprint hits.
  • GPU vs software: the GLES path is selected automatically when EGL is available, with no API-level difference for the application. The two backends are not yet pixel-identical — gradients and the shadow / backdrop pipeline degrade under software; the authoritative list of gaps is the README's Backend Differences section.

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.

Runtime guardrails. The rules above are the app's responsibility, but the runtime also helps catch and blunt the common footguns. Set LTK_PERF_WARN=1 to get one-shot stderr diagnostics during development when is_animating() stays true for 10 s (a settled animation that forgot to return false), when poll_interval() is under 100 ms (defeats the idle model), or when the software backend animates continuously for seconds (mobile CPU sink). Independently, animation on the software renderer is capped to ~30 Hz by default — GLES is never capped — since 60 fps software rasterization is a battery drain with no GPU offload; override App::cap_software_animation to keep the full rate. These live in event_loop/perf.rs. For layout problems rather than performance ones, LTK_DEBUG_LAYOUT=1 outlines every laid-out widget rect in red so misplaced or zero-sized boxes are visible at a glance.

Where to look in the consumer repos

Pattern File
Multi-overlay coordination, overlay id constants crustace/src/app.rs (overlays(), line ~126)
Background poller + channel sender crustace/src/app.rs (set_channel_sender, poll_external)
Sub-module per screen crustace/src/{homescreen/, launcher/, quicksettings/, powermenu.rs}
Cached icon loading via RefCell crustace/src/launcher/icon_cache.rs and use sites in app.rs
OSD overlay with auto-expiry crustace/src/osd.rs (Osd::show / tick / view)
keyboard_exclusive + take_focus_request loginmanager/crates/lockscreen/src/app.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 ~630-line file, see examples/mini_shell.rs.

Known gaps and non-goals

A short, honest list of what ltk does not currently provide. None of these are accidental — each is either deferred work or a deliberate non-goal. The list is here so that downstream consumers and audit reviewers know what to plan around without reading the source.

AT-SPI2 / assistive technology bridge — wired through AccessKit, with composite widgets still flat. Combo, Notebook tabs, DatePicker and TimePicker render as collections of inner widgets and currently expose those leaves individually (a combo trigger reads as "Button" + its caption, the popup items as ListItems inside an overlay). Promoting them to their semantic roles (ComboBoxMenuButton with Expanded state, TabList/Tab/TabPanel, Date) needs each compound widget to declare an "outer role hint" the layout pass can attach to the LaidOutWidget it pushes. Tracked separately.

ltk delegates the AT-SPI2 D-Bus protocol to accesskit_unix. After every layout pass, the runtime hands the platform adapter a fresh accesskit::TreeUpdate built from widget_rects: a Window root whose children are the main surface's widgets, with each overlay grouped under its own Dialog node and rich-text runs nested as TextRun children. Buttons / toggles / checkboxes / radios / list items map to Role::Button / Role::Switch / Role::CheckBox / Role::RadioButton / Role::ListItem; sliders to Role::Slider; pattern grids to Role::GenericContainer (a drawn gesture has no assistive equivalent, so the app must offer another unlock method); single- and multi-line text edits to Role::TextInput / Role::MultilineTextInput; non-interactive labels, images, separators and progress bars surface as Label / Image / Splitter / ProgressIndicator nodes. Inbound action requests are translated into the matching widget message on the next iteration of the run loop: Click and Focus on every interactive node, SetValue on sliders and text edits, Increment / Decrement on sliders, and the scroll actions on scroll viewports. Live regions are wired too — Container::live_region( true ) marks a subtree Live::Polite so status messages and OSDs announce themselves on appearance.

The adapter is constructed unconditionally — accesskit_unix::Adapter::new is infallible and simply stays inactive when no AT client is attached — and the tree is only built when AT-SPI2 is actually observing the application, so the pipeline costs nothing on headless runners. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up:

  • Generic container nesting: dialogs, text runs and the root do nest, but Column / Row / Container parents inside a surface are not represented — a surface's widgets are siblings. Adding that requires either recording the nesting on LaidOutWidget or walking Element again from the a11y side.
  • Per-widget accessible label / description / LabelledBy relations: the internal accessible_label plumbing exists (labels are derived from the widget's own content, with the tooltip as last fallback), but the public builders to override them (Button::accessible_name(...), etc.) are not exposed yet. Adding them is mechanical but touches every widget module.

Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 AA, EAA) should still treat the integration as a starting point that needs a real audit with assistive technology users — the foundation is in place but the per-widget metadata work is what determines whether Orca actually reads a useful announcement.

Cross-application drag-and-drop — deferred. The clipboard now bridges to the Wayland selection via wl_data_device_manager (see event_loop/data_device.rs), so Ctrl+C / Ctrl+V crosses application boundaries when the compositor advertises the global. Middle-click primary selection (zwp_primary_selection_v1) and inter-app drag-and-drop targets (drop-zone widgets that accept text / URI lists from outside the process) are still pending — they share most of the offer / source plumbing but need widget-level drop-target wiring on top.

Multi-touch — primary slot plus raw auxiliary fingers. The first finger to land on a surface becomes its primary slot and drives the built-in gesture machine (swipe, scroll, long-press, drag). Additional fingers bypass the gesture machine and surface directly through App::on_touch_down / on_touch_move / on_touch_up, so apps can implement pinch-zoom or two-finger pan without losing the built-in gestures. One exception keeps fast two-thumb typing whole: a finger landing while the primary is still a plain press (no drag, scroll, swipe or slider in flight) commits that press as a tap at its last position and takes the primary slot itself, so alternating taps whose touches overlap all register; the earlier finger's release then reports through on_touch_up. A press on a widget that fires on tap also stays a tap for as long as the finger is inside that widget, however far it rolls; the swipe machinery only engages, from the original press point, once the finger leaves it. Presses inside a scroll viewport keep the 8 logical px slop, since a list of full-width rows is scrolled by dragging on them. Apps that want the whole stream raw — an embedded web view, a drawing canvas — return true from App::claims_raw_touch: every finger, primary included, then reports through on_touch_* and widget presses, taps and swipes never fire. What ltk itself does not provide is recognition of multi-finger gestures — pinch and rotate detection is the app's job on top of the raw stream.

HarfBuzz shaping — wired in. src/text_shaping.rs::shape_line now drives both renderers: the line is BiDi-reordered, split into per-font sub-runs and shaped through rustybuzz. The glyph cache is keyed on (glyph_id, size_bits, font_id) and each glyph is rasterised by index via fontdue::Font::rasterize_indexed, so Arabic connected forms, Devanagari clusters and CJK shaped glyphs render correctly.

xdg-activation-v1 — wired in. Both directions work: a token found in $XDG_ACTIVATION_TOKEN at startup is used to activate the app's own window once it maps (so an external launcher can raise an ltk window with focus), and an app that spawns children requests fresh tokens through App::take_activation_requests and receives them via App::on_activation_token to place in the child's environment.

xdg-session-management-v1 — wired in (client side). 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" ), so the compositor restores geometry on every launch. The session id from created is persisted to $XDG_STATE_HOME/<app_id>/session.json next to state.bin, the app's own App::save_state bytes (saved every 30 s when changed, on close and on SIGTERM/SIGINT, which the runtime now turns into a clean exit). App state is handed back through App::restore_state only for reason session_restore (LTK_SESSION_RESTORE=1 in the environment, set by the shell) or recover (previous run left clean_exit: false) — a plain launch starts fresh, Android-style. Layer-shell and session-lock surfaces have no toplevel and skip all of it. Compositors without the global lose only the geometry half; the file-based state and reason detection still work. Multi-toplevel sessions and the compositor implementation in forge are tracked separately.

Fractional scale — deferred. wp_fractional_scale_v1 (so 125 % / 150 % outputs render natively instead of via compositor downscale) remains tracked as upcoming protocol work.

Software/GLES parity gaps — see docs/backends.md. The software backend renders gradients as a flat fill from the first stop, skips outer and inset shadows and backdrop blur, and hard-cuts the bottom-edge fade; oklab gradient interpolation falls back to linear-light on both backends. The capability matrix is the canonical per-feature table and must be updated in the same patch that closes any of these gaps.