# 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/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: painting a surface's view on its canvas (`paint.rs`), the same for the software and GPU renderers and on every platform, damage tracking, the layout walk and the chrome the runtime paints itself. - `egl_context.rs` — EGL bootstrap for the GPU path. - `event_loop/` — the runtime state (`AppData`) and the steps of a run-loop iteration every backend shares: building the state and its event sources (`setup.rs`), updating and drawing (`step.rs`), frame scheduling, invalidation, the clipboard buffer, text editing, tooltips, focus, the accessibility tree and its actions, perf guardrails. - `gles_render/` — GPU backend (EGL + GLES2 / GLES3). - `input/` — platform-neutral input events (`event.rs`), pointer, keyboard and touch handling, the gesture machine, dispatch. Nothing in it names a windowing system. See [Input events and platform backends](#input-events-and-platform-backends). - `platform/` — the `Platform` trait the runtime asks the windowing system through, and the backends. `platform/wayland/` implements it, holds the protocol state (`WaylandPlatform`, which `AppData::platform` owns) and each surface's Wayland half (`WaylandSurfaceState`, on `SurfaceState::platform`) and the handlers smithay-client-toolkit dispatches to, turns Wayland's input into `input/event.rs`, shares the clipboard and the primary selection with other clients, and carries out what the runtime asks of the system: presenting painted frames, window move, resize, minimise and fullscreen, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection. It also owns `ltk::run` (`run.rs`), the overlay and subsurface reconciliation, session management, and output probing and window fitting. - `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](#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//` 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` 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//` session directory. - `view(&self) -> Element` — main surface contents. - `update(&mut self, msg: Msg)` — state transitions. - `save_state(&self) -> Option>` / `restore_state(&mut self, Vec)` — 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>` — 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` — 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` — 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`](./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` — pull-once focus retargeting. - `on_text_input_focused(active)` — surface IME state. - `external_text_input()` → `Option` 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](#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 `update`s; 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>` 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. ## Input events and platform backends Input reaches the runtime as platform-neutral events, defined in `input/event.rs`. A platform backend translates what its input system reports into them and calls the runtime's entry points; everything past those entry points is shared by every platform. This is the first step towards backends other than Wayland: an Android backend will feed the same events from its `MotionEvent` and `KeyEvent`, and apps will not see the difference. | Event | What it carries | | --- | --- | | `KeyInput` | The keysym, the physical key's code in the platform's numbering, and the text the key types once dead keys and compose sequences are applied. `KeyInput::raw` builds the `RawKey` that `App::on_raw_key_event` receives. | | `PointerInput` | The surface, the position, and a `PointerKind`: enter, leave, motion, press or release of a `PointerButton` (left, right, middle, back, other), or an axis scroll with its distance, its wheel detents and its `AxisSource`. | | `TouchInput` | The finger's id, the time, and a `TouchPhase`: down on a surface at a position, motion to a position, or up. | | Source | Entry points on the runtime | | --- | --- | | Mouse, touchpad | `pointer_input( &PointerInput )` | | Fingers | `touch_input( &TouchInput )`; `reset_touch_state()` when the platform cancels the sequence | | Keyboard | `keyboard_enter( focus )`, `keyboard_leave()`, `key_press( KeyInput )`, `key_release( &KeyInput )`, `key_repeated( KeyInput )` for a repeat the platform generates itself, `modifiers_changed( ctrl, shift, alt )`, `set_repeat_info( rate, delay )` | Behind them sit hover and focus, the gesture machine (tap, long-press, drag, scroll, swipe, fling), text editing, the context menu, the built-in on-screen keyboard, back handling, the runtime's own key repeat and every `App` input callback. None of it knows which platform the event came from. **Units.** Positions are in the logical coordinates of the surface, as a compositor reports them; the handlers scale them to physical pixels, so a platform that reports physical pixels divides by the surface's scale first. Time is in milliseconds on the platform's monotonic input clock; only differences are read, for fling velocity. Scroll distances are in logical pixels, positive downwards and rightwards; the runtime multiplies a wheel's by ten before it moves a viewport, since a wheel reports a few pixels per detent. **What stays with the backend.** The events carry only what the shared logic reads. What a protocol needs for its own later requests is recorded by the backend before it forwards the event: on Wayland, the serials that a window move or resize, a selection, a popup grab and the cursor shape quote back to the compositor. The backend also resolves its surfaces to a `SurfaceFocus`; a platform with one surface always passes `SurfaceFocus::Main`. Later events of a finger carry no surface, since they go to the one the finger landed on. **What the runtime asks of the backend** is the `Platform` trait (`platform/mod.rs`), which `AppData::platform` implements. Input handling needs a few things only the windowing system can do, and asks for them through it without touching a protocol object. Requests about the window take the main surface's backend half, `&self.main.platform`: | `Platform` method | Wayland (`platform/wayland/`) | | --- | --- | | `is_toplevel( main )` | The main surface is an xdg-toplevel, not a layer-shell or lock surface | | `begin_window_move( main )`, `begin_window_resize( main, edge )` | `xdg_toplevel.move` / `resize` with the last left-press serial; `ResizeEdge` is ltk's own | | `minimize_window( main )`, `set_fullscreen( main, on )` | `xdg_toplevel.set_minimized`, `set_fullscreen` / `unset_fullscreen`; `false` when the main surface is no toplevel | | `request_frame( surface, focus )` | A frame callback and a commit with no new buffer, for an animation that moves only subsurfaces | | `set_cursor_shape( shape )` | `wp_cursor_shape_device_v1.set_shape` with the last enter serial; `false` without the protocol | | `activate_text_input( secure, purpose )`, `deactivate_text_input()`, `reenable_text_input( secure, purpose )`, `text_input_active()` | `zwp_text_input_v3` enable, content type and disable | | `prepare_canvas( surface, canvas, pw, ph, scale )` | `eglMakeCurrent` and a GLES canvas when the surface has an EGL window surface, else a software canvas over the SHM pool; created on the first frame, resized to the buffer | | `present( surface, canvas, presentation )` | GPU: blit the FBO and `eglSwapBuffers` with the damage turned to EGL's bottom-left origin. Software: copy the pixmap into a slot of the pool, attach and damage it. Both set the opaque and input regions, ask for the frame callback and commit | | `precise_damage( surface )` | `true` on the software path, where a full repaint reports the widgets that changed; the GPU path damages the whole buffer | | `publish_clipboard( text )`, `publish_primary_selection( text )` | A `wl_data_device` copy-paste source, a `zwp_primary_selection_v1` source; other clients' pastes are served from `AppData::clipboard` and `AppData::primary_selection` | `AppData` keeps thin wrappers where several call sites share one: `main_is_toplevel()`, `sync_fullscreen()`, which remembers what was applied, the text-input trio, which remembers `secure` and `purpose` for a later renewal, and `publish_clipboard()` and `publish_primary_selection()`, which keep the text. **Drawing a frame** is the runtime's, presenting it the backend's. For each surface due a frame, `event_loop/frame.rs` picks a partial repaint, under the rects whose focus, hover or press changed when they cover less than half of the surface, or a full one; asks the backend to `prepare_canvas`; paints with `draw::paint_partial` or `draw::paint_full`, which return the damage; and hands the canvas to `present` with a `Presentation`: the damage, background, opaque and input regions, logical size and scale, and the `SurfaceFocus` the frame callback is reported for, `None` for a surface not paced by frames. The canvas belongs to the surface and keeps its pixels, so a partial repaint touches only its rects, and the only copy is the one a software canvas always needs into the system's buffer. When presenting fails the next frame repaints in full. **The Wayland backend** is `platform/wayland/`. Its `input.rs` holds the `KeyboardHandler`, `PointerHandler` and `TouchHandler` implementations, which record serials, map evdev buttons (`BTN_LEFT` … `BTN_BACK`), take high-resolution wheel detents (`value120`) over the older whole ones, drop pointer and touch events on the secondary per-output surfaces and forward the rest; `window.rs`, `cursor.rs` and `text_input.rs` carry out the `Platform` requests. It carries no input logic and is the template for a new backend. **The protocol state is the backend's.** Globals, seat devices, the serials, the text-input, cursor, activation, session-management, data-device, primary-selection and foreign-toplevel objects, the EGL context and the queue handle live in `WaylandPlatform`, which `AppData` holds as `platform`. **So is each surface's Wayland half.** `SurfaceState::platform` is a `WaylandSurfaceState` (`Platform::Surface`, named `NativeSurface` in `platform/mod.rs`): the role object (`SurfaceKind`: layer surface, xdg window or popup, lock surface, or one still waiting for an output), the SHM pool or the EGL window surface, and what the compositor was last told (requested size and exclusive zone, popup anchor and reposition token, opaque region). Everything else on `SurfaceState`, from the canvas and the laid-out widgets to focus, gestures and frame pacing, belongs to the runtime. Configuring a surface, which picks the GPU or SHM path, is the backend's too (`platform/wayland/surface.rs`). **The runtime is generic over its platform.** `AppData>` and `SurfaceState` take the backend as a parameter; the runtime's `impl` blocks are written for any `P`, the backend's for its own (`impl AppData`, which the default makes the native one). Everything is resolved at compile time, as before. What a backend does with a configure is split the same way: it resizes its buffers and calls `SurfaceState::set_size`, and for the main surface `AppData::on_main_resized`, which tells the app and the accessibility tree. A backend builds the state with `AppData::new` and installs the sources every backend has with `AppData::install_sources`: the channel the app sends messages from other threads on, the desktop's text scale and the `App::poll_interval` timer. The backend-neutral part of a loop iteration is `event_loop/step.rs`. `next_wakeup` bounds the sleep by the runtime's own deadlines (a still press turning long, a tooltip, the built-in keyboard). `before_draw` fires the deadlines that passed, takes `App::poll_external` and a clipboard set by another application, runs `process_messages`, which hands the queued messages to `App::update` and applies their invalidation, and then what the update asks of the window: exit, fullscreen, external text input, a drag that just began. `draw_due` rebuilds the cached view and overlays when stale and draws every surface due a frame. `after_draw` publishes the accessibility tree and applies its actions, then the app's requests to blur, copy, paste and move the focus. The Wayland loop dispatches Wayland events and does its own work around these calls (the primary selection and drops from other clients, activation tokens, unlocking a session lock, overlays and subsurfaces), and a new backend's loop will do the same with its system. `AppData::secondaries`, the surfaces covering other outputs, pair each with the backend's `Platform::Output`. **Testing the runtime.** `platform/mock.rs`, built only for tests, is a `Platform` that talks to no system: it paints on a software canvas, logs every request (presentations with their damage and frame surface, bare frames, cursor shapes, text-input, clipboard, window requests) and can be told to fail presentations. `event_loop/tests.rs` drives an `AppData` through the backend-neutral input entry points and `process_messages` / `draw_due`, the same code the run loop runs, and checks what reached the platform: the first frame presented whole with a frame callback, no drawing while a frame is pending, a click reaching `update`, a hover presenting only the widget's rect, a failed presentation leaving a full repaint, a secure or terminal field reaching the input method with its content type, and a copy publishing the clipboard. **Building with and without a backend.** The Wayland backend is the `wayland` feature, on by default, which brings in smithay-client-toolkit, wayland-protocols, wayland-egl, wayland-scanner, calloop-wayland-source and accesskit_unix, the AT-SPI2 adapter. The Android backend is the `android` feature, which brings in android-activity and ndk and builds only for Android targets. The browser backend is the `web` feature, which brings in wasm-bindgen, wasm-bindgen-futures, js-sys and web-sys and builds only for `wasm32-unknown-unknown`. The three are exclusive, `build.rs` refuses more than one and `web` for another target, so an Android or a web build disables the default features. On `wasm32` the core builds without calloop, khronos-egl and raw-window-handle: `egl_context.rs`, `UiSurface::try_new_gles` and `from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` are left out, the monotonic clock is `crate::time::Instant` (`web_time`'s), and themes, fonts, layouts and images are read through `vfs.rs`, which is the file system elsewhere and files held in memory there. `build.rs` sets `cfg( backend )` when one is enabled; it gates the runtime, that is `event_loop/`, `input/`, `platform/`, `draw/paint.rs` and `run` / `try_run` / `RunError`, while `feature = "wayland"` gates `platform/wayland/`, `protocol/`, `session_state.rs`, `EglContext::new` and `EglContext::create_surface`, `EglSurface::resize`, and the wlr-layer conversions of `Layer` and `Anchor`, and `feature = "android"` gates `platform/android/`, `EglContext::new_android` and `EglContext::create_native_surface`. `EglContext` and `EglSurface` exist with either. `platform/mod.rs` names the backend in use through two aliases, `NativePlatform` for `AppData::platform` and `NativeSurface` for `SurfaceState::platform`, each set under its backend's `cfg`. With `--no-default-features` only the runtime-free core builds: `UiSurface`, the widgets, layout, theming, rendering and `EglOffscreenContext`. **Nothing in the runtime names Wayland.** `event_loop/`, `input/` and `draw/` reach the system only through `Platform`; what was Wayland-bound in `event_loop/` (the run loop, overlay and subsurface reconciliation, output probing, window fitting and session management) lives in `platform/wayland/`. `egl_context.rs` creates its `EglContext` from a Wayland connection or from Android's default display, and its `EglSurface` on a `wl_egl_window` or an `ANativeWindow`. **The Android backend** is `platform/android/`, on `android-activity` in its NativeActivity mode. The app's `android_main` hands its `AndroidApp` to `ltk::set_android_app` and calls `ltk::run`, which fails with `RunError::NoAndroidApp` otherwise; `ltk::AndroidApp` is re-exported so the app needs no direct dependency. The activity's thread sleeps in `AndroidApp::poll_events` on its `ALooper`, to which calloop's epoll descriptor is added with a callback that wakes it, so the app's message channel and the text-scale watcher still reach the loop; calloop's timers raise no descriptor, so the looper's timeout is bounded by the runtime's deadlines, `App::poll_interval` and a held button's repeat. Lifecycle events are handled inside `poll_events`, since the window must be released before the system destroys it: the window's arrival creates the EGL surface on it, a resize or a configuration change refits the main surface, its end releases it. The main surface's buffer scale is the display's density in multiples of 160 dpi, rounded; its logical size is the window's pixels at that scale, and its buffers are set to exactly that many physical pixels, which the system stretches by less than a pixel to fill the window, so input positions are divided by the window's pixels per logical pixel. EGL swaps with an interval of 1, so `eglSwapBuffers` waits for the display, and the backend reports each presented frame to the runtime at once (`AppData::frame_presented`, which the Wayland backend calls from its frame callbacks), which paces animations at the display's refresh. Without EGL the canvas is copied into a locked `R8G8B8A8` window buffer. Fingers and styluses are touches and a mouse a pointer; keys take their keysym from a table for keys that type nothing (`keys.rs`) or from the character the device's `KeyCharacterMap` gives with Shift and Caps Lock, and repeat on Android's clock. Text is typed on ltk's own keyboard, never the system's: a NativeActivity gets only key events from an input method, which would lose composed text and suggestions. It serves text fields and an app's own text input (`App::external_text_input`), whose keys reach the app as physical ones, and its height is taken from the view as on any platform. The clipboard is the system's, through JNI on the activity (`java.rs`): a copy goes to `ClipboardManager`, and its text is read back whenever the app gains the focus, the only time Android lets an app read it. Back with nothing left to go back to minimises the app, `moveTaskToBack`, or closes it, as `App::back_at_root` says; `Platform::back_leaves` tells the runtime whether the main surface may leave, which on Wayland is the xdg-toplevel and on Android always. An app that asks to exit has its activity closed and taken out of the recent apps (`finishAndRemoveTask`), and `ltk::run` returns once the system has destroyed it. Android freezes an app soon after it leaves the screen; `ltk::keep_running( Some( text ) )` keeps it running with an ongoing notification, through `java/net/liberux/ltk/KeepAlive.java`, a foreground service that `scripts/android-apk.sh` compiles in with `KEEP_ALIVE=1` (javac against the platform's `android.jar`, then `dx`), and `None` stops it. `set_android_app` also sets `HOME` to the app's internal storage, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, which preferences, TLS clients, locales and the keyboard's layout read. Keys the runtime has no keysym for, the volume keys among them, go back to the system. Themes and fonts travel in the APK under `assets/ltk/`, listed in `assets/ltk/MANIFEST` since the asset manager cannot list subdirectories; `set_android_app` unpacks the tree into the app's internal storage when the manifest changed since the last start (its first line is a hash of the contents) and hands it to `ltk::set_resource_dir` (`resources.rs`), so the theme is read from files as anywhere else. The resource directory's `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, and a font the theme names by an absolute path that does not exist is read from its `fonts/` by file name, so the default theme's Debian paths work unchanged. `scripts/android-assets.sh`, or `make android-assets`, builds the tree from installed themes, the fonts they name and the keyboard layouts next to the script. The per-script fallback fonts come from Android's `/system/fonts`; the primary face stays the embedded Sora. `make android-check` typechecks the backend with Debian's rustc, rust-src and NDK installer. An app is a `cdylib` with an `android_main`; `scripts/android-apk.sh` packages it as a NativeActivity APK with no Java code, using Debian's `aapt`, `zipalign`, `apksigner` and `android-framework-res`, with the launcher icon from `ICON` (an SVG goes through `rsvg-convert` to each density), and `examples/android_hello.rs` with `make android-apk` / `make android-install` is the end-to-end check. Standard output and error, where ltk's diagnostics and panics go, are redirected to logcat (`log.rs`). The system bars cover part of the window: from Android 11 the window's insets, read through JNI, become the main surface's `SurfaceState::insets` (the status bar and any cutout on top, elsewhere only bars that take taps, so the view runs under a gesture bar), and before it the activity's content rect does; `draw/paint.rs` keeps the view out of them while the background still fills the whole window. The APK's theme has no action bar, which the content rect would otherwise leave out though it is never shown. Not done yet: overlays, secondary surfaces and subsurfaces are not created, dead keys from a physical keyboard are not composed, and the activity's saved state is not used. **The browser backend** is `platform/web/`, for `wasm32-unknown-unknown` through wasm-bindgen. A page's main thread must not block, so `ltk::run` sets the app up and returns, and the browser drives the loop (`run.rs`): `AppData` lives in a closure held by a thread-local `Loop`, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw; an iteration drains the page's events, runs the due timers and the channels, reports a frame presented at the previous animation frame through `AppData::frame_presented`, and calls `before_draw`, `draw_due` (only in an animation frame) and `after_draw`, then schedules the next from `next_wakeup` and the next timer. The timers and channels are `event_loop/sources.rs`'s queue rather than calloop's: the backend takes the due timers out, runs them on `AppData` and hands them back, and a timer removed meanwhile is not armed again; `ChannelSender::send` wakes the loop through a waker the backend registers. Event listeners never touch `AppData`: they translate what they receive and queue it, and the iteration hands it to the runtime, so nothing a listener sets off, such as the focus event `canvas.focus()` fires at once, can run inside an iteration. The app draws on a canvas laid over the whole page, or the one `ltk::web::set_canvas` gives, through WebGL2 with the GLES canvas unchanged, its shaders being GLSL ES 1.00 and its frame kept in an FBO that `present` blits, so WebGL clearing its drawing buffer after each frame does not matter; without WebGL2 the software canvas is unpremultiplied into `putImageData`. A lost context drops the GPU canvases, and its restoration wraps it in a new `glow::Context`, so the glyph atlas and the caches the old one kept, found through a `Weak`, are not reused. The main surface's logical size is the canvas's CSS size and its scale the page's `devicePixelRatio`, fractional as it may be, so the canvas holds the display's own pixels; the window's `resize` refits it, and on ltk's own canvas a hidden element padded by `env( safe-area-inset-* )` gives the insets a phone's notch and bars cover. Input (`input.rs`) comes from the canvas's Pointer Events, a mouse as the pointer and fingers and pens as touches at `offsetX` / `offsetY`, the wheel, whose lines are detents and whose pixels scroll continuously, and keys, whose keysym comes from `KeyboardEvent.key` and code from `.code` as the Linux input code Wayland reports (`keys.rs`); keys repeat on the browser's clock, AltGr types its character rather than counting as Ctrl and Alt, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen also those `navigator.keyboard.lock()` gives in Chromium. Copies go to `navigator.clipboard.writeText` (`clipboard.rs`); the page reads the clipboard only from a `paste` event, so Ctrl+V is let through to the browser and held until its `paste` has put the text in `AppData::clipboard`. The built-in keyboard serves text where the page is used by touch (`(pointer: coarse)`), and its language is the page's (`platform::language`). The cursor's shape is the CSS `cursor` of the element taking the input of the surface the pointer last entered (`cursor.rs`), as on Wayland it follows the pointer. Resources are the tree an Android package carries, built by `scripts/web-assets.sh` and served next to the page; `ltk::web::load_resources( base )` fetches its `MANIFEST` and every file it lists into `vfs.rs` under `/ltk` and makes that the resource directory, and the per-script fallback fonts are found by file name in its `fonts/`. Panics go to the browser's console (`log.rs`); standard output and error go nowhere. When the app exits, the listeners are removed, the canvas receives an `ltk-exit` event and the app is dropped. `make web-check` typechecks the backend; `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli, whose version is the wasm-bindgen ltk pins, built from its sources for Debian's rustc into `target/tools` by `scripts/wasm-bindgen-cli.sh`, which the package installs for other projects to run; `examples/web_hello.rs` with `make web-example` / `make web-serve` is the end-to-end check. Not done yet: overlays, secondary surfaces and subsurfaces are not created, dead keys and input methods are not composed, there is no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. ## 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`), the resource directory's `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//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//` when the env var is set 2. `$XDG_DATA_HOME/ltk/themes//` (defaults to `~/.local/share/ltk/themes//`) 3. `/usr/share/ltk/themes//` 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 `UiSurface`s). 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()`: ```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 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: ```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 } # impl App { fn view( &self ) -> Element { 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` 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` 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`, 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`](./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` 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` 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` image buffers. `img_widget` takes an `Arc>`; 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 `ListItem`s 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 comes with the `wayland` feature; a build without it has no accessibility state and skips both directions. Where present it 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 `platform/wayland/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//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. **Platforms other than Wayland — in progress.** Input already reaches the runtime as platform-neutral events (see [Input events and platform backends](#input-events-and-platform-backends)), input handling, drawing and the clipboard reach the windowing system only through the `Platform` trait, and the protocol state, each surface's Wayland half, presentation and the handlers live in `platform/wayland/`, the Wayland dependencies are the optional `wayland` feature, and the run loop, overlay reconciliation and subsurfaces are the Wayland backend's, around backend-neutral steps any loop can call. The Android backend is the second (see [Input events and platform backends](#input-events-and-platform-backends)); what it still lacks is listed there. **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`](./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.