Files
ltk/README.md
T
admin cacf871ddd
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
An Android backend, a platform layer that keeps Wayland out of the runtime, the built-in keyboard for an app's own text input, and APKs packaged with Debian's own tools
ltk only ran on a Wayland compositor: the runtime, the input handlers and the draw paths named smithay-client-toolkit's types throughout, so no other windowing system could be added without touching all of them. Prompt, the Eydos terminal, has to run on an Android phone, with its sessions and its sharing, and needs a toolkit that runs there.
The platform layer. Input reaches the runtime as platform-neutral events: `input/event.rs` defines `KeyInput`, `PointerInput` and `TouchInput`, and the pointer, touch and keyboard handlers take them through a few entry points (`pointer_input`, `touch_input`, `key_press`, `key_release`, `key_repeated`, `modifiers_changed`, `keyboard_enter`, `keyboard_leave`, `set_repeat_info`). The sctk handler implementations moved to `platform/wayland/input.rs`, which records the serials later requests need, resolves surfaces and translates. What the runtime asks of the windowing system (moving, resizing, minimising and fullscreening the window, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection, preparing a canvas, presenting it and its damage) forms the `platform::Platform` trait, with its surface type as `Platform::Surface`. The protocol state moved from `AppData` into `platform::wayland::WaylandPlatform`, held as `AppData::platform`, and each surface's Wayland half (its kind, SHM pool, EGL window surface and what the compositor was last told) from `SurfaceState` into `WaylandSurfaceState`, held as `SurfaceState::platform`. The compositor, shell, seat and output handlers, the data device, the primary selection, the run loop, overlay and subsurface reconciliation, session management, output probing and window fitting moved from `event_loop/` to `platform/wayland/`. Drawing is split from presenting: `draw/paint.rs` paints a surface in full or under its dirty rects, the same for the software and GPU canvases, in place of the four near-identical paths of `draw/software.rs` and `draw/gles.rs`, and the backend presents it (`platform/wayland/present.rs`, which also took the opaque and input regions and `pick_shm_format`). The runtime is generic over the platform, `AppData<A, P: Platform = NativePlatform<A>>` and `SurfaceState<Msg, S = NativeSurface>`, resolved at compile time. The backend-neutral half of a loop iteration is `event_loop/step.rs` (`process_messages`, `draw_due`, `next_wakeup`, `before_draw`, `after_draw`), and `event_loop/setup.rs` builds the state and the event sources every backend has; the accessibility tree and its actions moved to `event_loop/a11y.rs`. `platform/mock.rs` is a platform that logs what is asked of it, and `event_loop/tests.rs` drives the runtime on it through input, update, painting and presentation. The behaviour on Wayland is unchanged, except that a frame whose presentation fails leaves the next one to repaint in full. `ltk::Keysym` now comes from `xkeysym`, the crate sctk re-exported it from, so it is the same type.
Features. `wayland`, on by default, brings the Wayland backend and its dependencies, now optional, AT-SPI2 accessibility among them. `android` brings android-activity 0.6, ndk 0.9 and jni 0.21. A new `build.rs` refuses both at once and sets `cfg( backend )` when one is on, which builds the runtime behind `run` and `try_run`. Without a backend only the runtime-free core builds: `UiSurface`, widgets, layout, theming and rendering.
The Android backend. `ltk::run` runs inside a NativeActivity once `android_main` has handed its `AndroidApp`, re-exported as `ltk::AndroidApp`, to the new `ltk::set_android_app`; without it `run` fails with `RunError::NoAndroidApp`. The first call in the process sends standard output and error, panics included, to logcat, sets `HOME` to the app's internal storage and, unless set, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, and unpacks the bundled resources; every call keeps the latest activity, since Android starts the next one in the same process. The backend draws through EGL on the activity's window, or in software into a locked window buffer, and sizes the main surface at the display's density in whole multiples of 160 dpi. Fingers and styluses become touches, a mouse the pointer, and key events keysyms and text through the device's character map. calloop's descriptor rides on the activity's `ALooper`, so the app's channel and timers keep working. Text is typed on ltk's own keyboard, since a NativeActivity gets only key events from the system's. Through JNI on the activity (`platform/android/java.rs`): copies go to `ClipboardManager` and its text is read back whenever the app gains the focus; back with nothing left to go back to minimises the app with `moveTaskToBack` or closes it, as `App::back_at_root` says, which the new `Platform::back_leaves` decides per backend; and the window's insets come from `getRootWindowInsets()` on Android 11 and later, the status bar and any cutout on top and only bars that take taps on the other edges, so there is no gap above the view nor below it in gesture navigation, and a bar of buttons still keeps its room; earlier releases fall back to the content rect. When the app's last surface closes, ltk calls `finishAndRemoveTask` and waits for the activity to be destroyed, since a second activity started in a process whose `android_main` has returned never came up; an app should end the process after `run` returns. `ltk::keep_running( Some( text ) )` starts a foreground service with `text` on an ongoing notification, so Android does not freeze the app once it leaves the screen, and `None` stops it.
Surface insets. `SurfaceState::insets` holds the logical pixels the system covers along each edge; the view is laid out inside them while the background fills the whole surface, and positions stay absolute, so hit testing is unchanged. The built-in keyboard is placed inside them too, above a bar of navigation buttons. On Wayland they stay zero.
Resources in a package. `ltk::set_resource_dir( dir )` names an app's own copy of ltk's resources and `ltk::resource_dir()` returns it, so an app also finds its own bundled files there. Its `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, its `keyboards/` before `/usr/share`, and a font a theme names by an absolute path that does not exist is read from its `fonts/` by file name, so a theme written against Debian's paths works unchanged. On Android `set_android_app` unpacks `assets/ltk/` into the internal storage and uses it as the resource directory; `assets/ltk/MANIFEST` lists the tree, since the asset manager cannot list subdirectories, and its first line, a hash of the contents, decides whether to unpack again. On Android the per-script fallback fonts come from `/system/fonts` and the primary face is the embedded Sora.
APKs. `scripts/android-assets.sh` builds that tree from installed themes, without their cursors, with the fonts each `theme.json` names and ltk's keyboard layouts. `scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]` packages an app built as a `cdylib` with Debian's aapt, zipalign and apksigner, with no Gradle and no SDK download: it writes the manifest, with the library loaded as `libmain.so`, optional permissions and a theme without an action bar, which the content rect would otherwise leave room for; links it against `android-framework-res`; adds the assets and the library; aligns and signs, creating a debug keystore when there is none. `ICON` takes an SVG, rasterised with `rsvg-convert` for every density, or a PNG. `KEEP_ALIVE=1` compiles `java/net/liberux/ltk/KeepAlive.java` with javac and `dx` from art-standalone against the platform 34 `android.jar`, against which aapt then links too, since Debian's framework-res predates the `specialUse` service type, and declares the service with `KEEP_ALIVE_USE` as its reason and the permissions it needs. `examples/android_hello.rs` is the smoke test: `make android-check` typechecks the backend with Debian's rustc, `rust-src` and NDK through `-Zbuild-std`, `make android-apk` and `make android-install` package and install the example, `make deps` and `make android-deps` install what the targets need, and `make install` now copies `examples`, `scripts`, `java` and `build.rs` into the registry.
The built-in keyboard for an app's own text input. While `App::external_text_input` asks for text and no ltk text field has the focus, the built-in keyboard shows with `App::external_text_purpose`, so a terminal gets its row of Escape, Tab, Ctrl, Alt and arrows, comes back when the serial changes and hides when the request ends. Its keys reach the app as physical ones, pressed and released through `App::on_raw_key_event` with the panel's Ctrl and Alt held for them. Before, it only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method.
Clippy. `make clippy` is clean again: derived `Default`s replace the hand-written ones, `div_ceil`, `then_some`, struct update syntax for strokes, the map entry API in the text renderer, function paths in place of closures in the examples and tests, and fewer casts and explicit lifetimes.
`README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the features, the backend, the events and their units, and what stays with a backend.
Not done. On Android there are no overlays, secondary surfaces or subsurfaces, no dead keys from a physical keyboard and no saved state for the activity. Why a second activity in the same process never comes up was not found. The keep-alive service does not ask for the notification permission, so on Android 13 and later its notification may not show until it is granted in the app's settings.
2026-10-16 23:46:50 +02:00

16 KiB

ltk

ltk is a public Rust UI toolkit for Wayland applications.

It is developed by Liberux as part of the Eydos stack, where it powers shell and application surfaces, but it is published as a reusable library for third-party developers building their own Wayland software.

Being written in Rust is also part of the project's value proposition:

  • memory safety without a garbage collector
  • predictable resource lifetimes through ownership and borrowing
  • good control over allocations and data movement in rendering-heavy code
  • a strong fit for low-level UI, graphics, and system-integration work

For a Wayland toolkit, that combination is useful in practice: it reduces an entire class of memory-management bugs common in lower-level UI stacks while still allowing tight control over performance-sensitive paths.

What It Is

ltk is a lightweight, declarative toolkit with an Elm-shaped model:

  • implement App
  • return an Element<Msg> tree from view()
  • update your state in update()
  • run the event loop with ltk::run(app)

The runtime handles layout, drawing, input dispatch, focus, overlays, and backend selection between GLES and software rendering.

What It Is Not

ltk is not:

  • a browser UI toolkit
  • a cross-platform desktop toolkit
  • a general-purpose web-style framework

Today it is specifically a Wayland toolkit. If you are building native Wayland applications, panels, launchers, lock screens, or other shell-adjacent surfaces, it is in scope. If you need Windows, macOS, or browser targets, it is not.

Project Status

ltk is a public library intended for third-party use, but it is still shaped by real production needs inside the Liberux / Eydos ecosystem.

That means:

  • the API is usable for external applications today
  • the project is optimized first for native Wayland workloads
  • some advanced APIs are still more shell-oriented than app-oriented
  • public documentation and examples are present, but the project is not trying to present itself as a cross-platform beginner toolkit

If you are evaluating ltk for a third-party application, the right mental model is "public Wayland toolkit with production consumers" rather than "experimental demo crate".

Why Third Parties Might Use It

ltk is designed around a few practical goals:

  • low idle wakeups and event-driven redraws
  • partial redraws and damage tracking
  • a simple declarative tree instead of retained widgets
  • direct support for normal windows, layer-shell, and ext-session-lock surfaces
  • a runtime-free core (ltk::core::UiSurface) for embedding layout and drawing without ltk::run()

This makes it especially relevant for:

  • Wayland applications
  • Linux shells that adapt to any screen, from phones to desktops
  • launchers and dashboards
  • greeters and lock screens
  • compositor-side or embedded UI surfaces

Quick Start

Add ltk to your Cargo.toml:

[dependencies]
ltk = { path = "../ltk" }

If you consume ltk from outside this workspace, replace the path dependency with a versioned one.

Minimal app:

use ltk::{ App, Element, button, column, spacer, text };

#[derive(Clone)]
enum Msg
{
    Increment,
}

struct CounterApp
{
    value: u32,
}

impl App for CounterApp
{
    type Message = Msg;

    fn app_id( &self ) -> &str { "net.example.Counter" }
    fn save_state( &self ) -> Option<Vec<u8>> { None }
    fn restore_state( &mut self, _state: Vec<u8> ) {}

    fn view( &self ) -> Element<Msg>
    {
        column::<Msg>()
            .padding( 32.0 )
            .spacing( 16.0 )
            .center_y( true )
            .push( text( "Hello from ltk" ).size( 28.0 ) )
            .push( text( format!( "Count: {}", self.value ) ).size( 18.0 ) )
            .push( spacer() )
            .push( button( "Increment" ).on_press( Msg::Increment ) )
            .into()
    }

    fn update( &mut self, msg: Msg )
    {
        match msg
        {
            Msg::Increment => self.value += 1,
        }
    }
}

fn main()
{
    ltk::run( CounterApp { value: 0 } );
}

app_id names the window and the $XDG_STATE_HOME/<app_id>/ session directory; save_state / restore_state are the opaque bytes ltk persists for session restore and crash recovery — return None when you have nothing to keep.

Requirements

ltk currently assumes:

  • Rust 1.85 or newer (the toolchain shipped with Debian stable; declared as rust-version in Cargo.toml).
  • A running Wayland session — there is no X11 backend.
  • System headers for libwayland, libegl and libxkbcommon at compile time, and rust-clippy for make clippy. On Debian, make deps installs them:
    sudo apt-get install cargo rustc rust-clippy pkg-config libwayland-dev libegl-dev libxkbcommon-dev
    
    make android-deps installs what the android-* targets need: rust-src, google-android-ndk-r27c-installer (non-free), aapt, zipalign, apksigner, android-framework-res, librsvg2-bin, default-jdk-headless, art-standalone (for dx), google-android-platform-34-installer (non-free) and android-sdk-platform-tools.
  • A usable system font (fonts-sora, fonts-liberation, fonts-dejavu, …). If none is installed ltk falls back to an embedded Sora Regular build with a stderr warning.
  • A theme named default, installed system-wide (the ltk-theme-default Debian package drops it under /usr/share/ltk/themes/default/) or exposed through LTK_THEMES_DIR for development.

Rendering backend selection is automatic:

  • GLES when EGL is available (every modern Wayland compositor).
  • Software fallback otherwise.
  • Set LTK_FORCE_SOFTWARE=1 to force the software path even when EGL is available — useful for headless test runs and for diagnosing driver-specific bugs.

For development inside this repository:

export LTK_THEMES_DIR="$PWD/themes"
cargo run --example showcase

Examples

Useful entry points in this repository:

  • cargo run --example showcase
  • cargo run --example responsive
  • cargo run --example widgets
  • cargo run --example inputs
  • cargo run --example scroll
  • cargo run --example combo
  • cargo run --example dialog
  • cargo run --example sliders
  • cargo run --example pickers
  • cargo run --example carousel
  • cargo run --example clip_path
  • cargo run --example mini_shell

In general:

  • start with showcase for a regular app window
  • use responsive to see the fluid vs physical modes on stock widgets
  • use widgets to see the core controls
  • use mini_shell if you need overlays, theme switching, or shell-style composition

Public API Overview

Most applications should start with this subset:

  • App
  • Element<Msg>
  • widgets such as button, text, text_edit, img_widget
  • layouts such as column, row, stack, grid, spacer
  • Color
  • run

More advanced APIs are available when needed:

  • overlays()
  • shell_mode() and layer-shell controls
  • set_channel_sender() and poll_external()
  • gesture hooks such as on_swipe_*
  • core::UiSurface
  • runtime theme APIs
  • osk::Panel and its layout files, for a host that draws the on-screen keyboard itself

Responsive Design

ltk offers two first-class ways to make an interface adapt to the display, chosen per value or per process:

  • Fluid — sizes are a fraction of the surface, tracking the short side (width in portrait, height in landscape). Best for full-screen system surfaces. Written with Length::vmin / orient / fluid and bounded with .clamp.
  • Physical — sizes stay a constant real-world size across displays (the mainstream HiDPI dp model). Best for conventional windowed apps. Written with Length::dp plus set_density.

Stock widgets follow the process-wide mode (set_widget_scaling, fluid by default); an explicit Length on a widget always overrides it. The full mechanics live in docs/architecture.md, a walkthrough in docs/onboarding.md, and the per-item reference in the Length / WidgetScaling rustdoc.

Windows and Shell Surfaces

By default, ltk creates a regular xdg-shell window.

That is the right starting point for:

  • normal applications
  • internal tools
  • prototypes

Switch to layer-shell only when you are building shell surfaces such as:

  • top bars
  • docks
  • homescreens
  • notifications
  • greeters
  • lock screens

For a screen locker, use ShellMode::SessionLock instead of layer-shell: it presents an ext-session-lock-v1 surface that the compositor keeps on top of everything until the app returns true from requested_exit(), which makes the runtime call unlock and lift the lock.

Performance Notes

ltk is designed to sleep when idle and redraw only on real work.

The main rules for downstream applications are:

  • keep view() pure and cheap
  • do not perform I/O inside view()
  • use poll_interval() sparingly
  • return true from is_animating() only while something is actually moving — note that on the software backend animation redraws are capped at ~30 Hz by default (override with App::cap_software_animation)
  • cache decoded images and expensive derived state in your app

Set LTK_PERF_WARN=1 during development for opt-in diagnostics: it warns about stuck animations, sustained software-render animation, and low poll_interval values.

The library already provides:

  • event-driven redraw scheduling
  • per-surface invalidation
  • partial redraws for interaction-only changes
  • GPU and software backends behind the same widget API

Backend Differences

The public API is the same across backends, but visual parity is not perfect yet. The widget tree, layout, hit-testing, text, images, fills, strokes and single-rect / path clipping all paint identically on both paths. The gaps are in gradients, the shadow / backdrop pipeline, and multi-rect clipping: the GLES backend installs the bounding-box union of the rects as its glScissor — a coarse clip that does not cull pixels between disjoint rects — while the software backend clips each rect exactly.

Effects that currently render only on the GLES backend, and degrade on the Software backend:

  • Gradients (linear and radial, via Canvas::fill_paint_rect) — rendered with dedicated shaders on GLES; on software they collapse to a flat fill from the first stop (tiny-skia can render gradients natively, but that is not wired up yet).
  • Outer drop shadows (Canvas::fill_shadow_outer) — themed surfaces that declare a Shadow slot show the soft halo on GLES and a flat fill on software.
  • Inner / inset shadows (Canvas::fill_shadow_inset) — InsetShadow slots paint nothing on software.
  • Inset shadow blend modes — PlusLighter, Multiply, Screen and Overlay are GLES-only; the GLES Overlay path snapshots the framebuffer and computes the CSS Overlay formula in-shader, which has no software equivalent today.

Calls to these APIs are safe on both backends — they simply produce a flatter appearance under software. No widget panics, returns an error, or skips unrelated drawing.

If your application leans heavily on shadows or inset effects, validate both rendering paths before shipping. Force the software path with:

LTK_FORCE_SOFTWARE=1 cargo run --example showcase

Closing this gap (porting the shadow / inset-shadow pipeline to tiny-skia) is on the roadmap.

Documentation

File When to read it
docs/onboarding.md First hour with the library — environment, first app, what to ignore at first.
docs/architecture.md Runtime model, overlays, animation, theming, performance and where the cost of a frame lives.
docs/widgets.md Per-widget catalogue: what each one is, when to use it, minimal example, see-also.
docs/theming.md JSON theme schema, slot conventions, runtime APIs.
docs/backends.md Software/GLES capability matrix — what renders identically, what degrades, what is GPU-only.
docs/navigation.md Navigation state and the back button: what back closes and in which order, why the answer is derived from the model, how it lines up with session state, NavStack.
docs/cookbook.md Concrete recipes — slide-in panels, password fields, runtime theme toggle, channel-driven state, embedding without ltk::run.
cargo doc --open Per-item rustdoc for the public API.
CHANGELOG.md What changed in each release, and what is pending unreleased.
code_style_guide.md The Modified Allman style rules the codebase follows.
SECURITY.md How to report a vulnerability and what is in / out of scope.
CONTRIBUTING.md Build, test, code style, patch shape.

Recommended reading order for a new contributor:

  1. run examples/showcase.rs
  2. read docs/onboarding.md
  3. browse docs/widgets.md for the catalogue
  4. dip into docs/cookbook.md when you hit a specific shape
  5. open docs/architecture.md once you need overlays, animations, or runtime theming.

Relationship to Liberux and Eydos

Liberux is the promoter and primary maintainer of ltk.

The project exists because Eydos needs a native Wayland toolkit for its own shell and application stack, but ltk is intentionally published as a public library rather than kept as a private internal component. Third-party developers are part of the intended audience.

That origin matters because it explains the current priorities:

  • strong Wayland focus
  • support for layer-shell and shell-style overlays
  • attention to power usage on battery-powered devices
  • theming and runtime surfaces that fit an operating system environment

License

This project is licensed under LGPL-2.1-only.

That means third parties can use ltk in their own applications, including proprietary ones, subject to the obligations of the GNU Lesser General Public License v2.1. If you are planning a commercial or closed-source product, read the license text carefully and make sure your distribution model complies with it.

See LICENSE.

Third-party assets

ltk's default theme bundles two third-party asset sets that travel under their own licences. Anyone redistributing the toolkit (or a binary that embeds the default theme) must propagate the attributions below.

The remaining artwork in the default theme — wallpapers, lockscreens, launcher logo, brand-mark variants and per-application icons — is original to Liberux Labs and travels under the toolkit's own LGPL-2.1-only licence.

The full Debian-style declaration of every asset and its licence lives in debian/copyright; that is the file the .deb ships under /usr/share/doc/libltk*/copyright.

Contributing

Patches and bug reports are welcome. Read CONTRIBUTING.md for the practical mechanics: build prerequisites, how to run tests, the project's Modified Allman code style, and what shape a pull request should take.

For security-sensitive issues see SECURITY.md — please do not file those through the public issue tracker.

If you are evaluating ltk for a third-party product and are unsure whether your use case is in scope, open a discussion before writing code. That is especially useful when you are:

  • missing an app-facing example,
  • blocked by a shell-oriented assumption in the API,
  • trying to understand whether a given platform target is realistic.