A multiline text field was a box for a few lines: it wrapped its whole value by shaping each character alone on every frame, every click and every caret motion, so a file of some size made it crawl; it drew a tab as the font's glyph for it, had no scroll bar, no page keys and no way to number its lines, kept no record of the view it showed, so once the cursor had left the first screen it stayed on the last row and the arrows moved the text, and an app could neither colour its text, type a tab with Tab, give it a face of its own, ask it to copy, cut, paste or select, nor hear where its cursor was. Notes, the Eydos text editor, needs all of it, and every app that opens or saves the user's files needed a dialog of its own.
Speed. `Canvas::measure_char` measures a character's advance once per font and size and keeps it (`text_shaping::char_advance_cached`, emptied with the shaped lines), and `compute_visual_lines` keeps the last four layouts by the value's hash and length, the row width, the size and the face, so a value is wrapped again only when one of them changes, and drawing, hit testing and caret motion over the same value wrap it once.
The view. `TextEdit::draw` returns the view it showed, which the frame keeps in `text_scroll`, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. `Window::shown` gives the lines as the last frame showed them, wherever the cursor went since, and a press, the wheel, the scroll bar and the page keys act on them: a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. The cursor a scroll keeps is taken back to the value's length as the view's is, so a scroll made with the cursor at its end sentinel holds instead of going back to the cursor at once.
Tabs. In a multiline field a tab reaches the next stop, every four digits' widths, as four monospace columns are (`theme::TAB_COLUMNS`, `text_edit/tabs.rs`), when it is drawn, selected, clicked and crossed with the arrows. `TextEdit::tab_text( text )` has Tab type `text` while the field has the focus instead of moving the focus on; Shift+Tab still moves it back and Ctrl+Tab still reaches the app.
The scroll bar. A field whose lines do not all fit shows a scroll bar along its right edge (`Window::scrollbar`): its thumb is held and dragged, or a press on its track brings the thumb there, by pointer or touch, without moving the cursor (`AppData::text_scrollbar_grab`, `handle_text_scrollbar_press`, `handle_text_scrollbar_drag`); the pointer over it is the arrow rather than the text cursor, and a long press that holds it no longer opens the Copy / Cut / Paste menu.
Page Up and Page Down move the cursor of a multiline field by the lines on screen but one, at the same height on screen, and the view with it, to the start or the end from the first or the last page (`cursor_visual_page`, `handle_cursor_page`); Shift extends the selection, and with Ctrl, or in any other widget, the keys still reach the app. A key without Ctrl that types no text, such as F3, was dropped while a text field had the focus; it now goes to `App::on_key_with_modifiers`, as it does with no field focused.
The text as an editor's. `TextEdit::highlights( Arc<Vec<TextHighlight>> )` draws runs of a multiline value, byte ranges in order, in colours of their own, what no run covers in the field's colour and a run off a character boundary left out. `TextEdit::font( face )` lays a multiline field's text out in a face of its own at the field's size: wrapping, drawing, the selection, the cursor, clicks, tab stops and the arrows all measure through one `TextFont`, the size and the face, which `text_input_geometry` now gives the runtime's paths where it gave them the size; `Canvas::measure_char_with_font` is `measure_char` in another face. `ltk::monospace_font()` finds a monospace face, DejaVu Sans Mono from the app's resources, which a page and an APK carry, else the first of the system's installed, loaded once. `TextEdit::line_numbers( true )` numbers the lines in a column at the field's left, in the monospace face when there is one, as wide as the number of the last line needs, a wrapped line numbered on its first row and the cursor's in the text colour while the field has the focus; the text starts right of the column alike when drawn and in every runtime path, since they all lay it out in the rect `gutter::text_rect` gives. `borderless()` now also drops a multiline field's background and border.
Talking to the field. `App::take_text_command` hands a field, by its `WidgetId`, a `TextCommand` after the next frame: `Copy`, `Cut`, `Paste` and `SelectAll` through the paths the keys take, and `Select { start, end }`, which focuses the field, selects the range with its ends taken back to the value and to its characters' starts, and scrolls to it. `Reveal { start, end }` does the same without moving the focus, as a search does while its own field keeps the keys, and a multiline field without the focus now shows its selection dimmed (`theme::selection_inactive`). `TextEdit::on_cursor( |at| … )` sends the cursor's byte offset once it is placed and whenever it moves or the value changes length (`report_text_cursors`, after every frame), as an editor's status bar showing the line needs.
A file chooser. `ltk::FileChooser` (`src/file_chooser/`) picks a file to open or a folder and a name to save one as, over `ltk::files`, alike natively and in a browser. The app holds it, puts its `view()` in its own and hands its `FileChooserMsg`s to `update()`, which returns `Chosen::Path` or `Chosen::Cancelled` once the user chose; `wrap` makes the app's message of the chooser's, so its listings reach it through the app's `ChannelSender`. It offers the home folder, the xdg-user-dirs folders that exist, the root and the folders mounted in a browser, with a press there to mount another; it lists folders first, the hidden entries left out, each with its type's icon, a file with its size and the date of its last access or change, sorted as Crate sorts, by name, last access or size, each key in a direction of its own (`Sort`, `SortKey`, `Directions`). `dir` starts elsewhere, `filter( &[ "text/*" ] )` shows only some types of files, `compact( true )` puts the places behind a button for a phone, `sort` starts in an order kept from `current_sort`, and `take_focus_request` gives the field to focus. Saving asks before replacing a file and can create a folder; its errors are in the user's words, and its strings in ltk's seven locales. `files::mounts()` gives the folders mounted in a browser as the desktop last said, which the web backend now keeps. `docs/widgets.md` describes it and `examples/file_chooser.rs` shows it, in `make examples`.
ltk::files::callback. The operations of `ltk::files` with a function in place of a message (`list`, `stat`, `read`, `read_range`, `write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`), for code with no run loop's `ChannelSender` to take one, such as Prompt's session daemon, which in a browser runs in the page beside the window. `done` is called on ltk's worker thread natively and on the page's thread in a browser.
Closing. An app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request, from the compositor, the title bar's close button, back and the browser, now goes through `AppData::request_close`, which draws the view again when the app refused it.
`App::window_title`. The main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this.
`Text::fit` and `Text::valign`. `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it, on one line within its width and height or, with `wrap`, all its lines within its height, so a text whose length varies, such as a calculator's display, neither overflows a box the window sizes nor needs its size worked out from the window and its content; its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top, the centre or the bottom of the rect.
A stack keeps its children inside it. A child that `Stack` aligned to an edge or the centre took its preferred size even when that was larger than the stack, so one aligned to the bottom started above the stack's top and one aligned to the right left of its left edge, drawn over whatever was there. Each child's preferred size is now capped by the stack's, less its margin, before it is aligned; children placed with `push_placed` and the offsets of `push_translated` are as they were.
Tests cover Tab with and without `tab_text`, the text commands, `Reveal` leaving the focus alone, a highlight into a character, `on_cursor` reporting once, a refused close drawing again, a key that types nothing reaching the app from a field, tab stops, a click on a tab, the kept layouts, the scroll bar, a view kept while the cursor moves inside it and after a scroll with the cursor at its sentinel, a click landing on the view shown, the page keys, the width of the line numbers, the file chooser's listing, filters, names, user folders, sizes, sorting and its open, save and replace flows, `Text::fit` and a stack's aligned children. `CHANGELOG.md` and `docs/widgets.md` describe the changes.
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 fromview() - 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 withoutltk::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-versioninCargo.toml). - A running Wayland session — there is no X11 backend.
- System headers for
libwayland,libeglandlibxkbcommonat compile time, andrust-clippyformake clippy. On Debian,make depsinstalls them:sudo apt-get install cargo rustc rust-clippy pkg-config libwayland-dev libegl-dev libxkbcommon-devmake android-depsinstalls what theandroid-*targets need:rust-src,google-android-ndk-r27c-installer(non-free),aapt,zipalign,apksigner,android-framework-res,librsvg2-bin,default-jdk-headless,art-standalone(fordx),google-android-platform-34-installer(non-free) andandroid-sdk-platform-tools.make web-depsinstalls what theweb-*targets need:libstd-rust-dev-wasm32andcurlfrom Debian, andwasm-bindgen-cliat the version of thewasm-bindgencrate ltk pins, whichscripts/wasm-bindgen-cli.shbuilds from its sources intotarget/tools, where the Makefile finds it. The script is installed with the package, so a project that builds against the installed ltk runs it to get the same CLI into a directory of its own. - A usable system font (
fonts-sora,fonts-liberation,fonts-dejavu, …). If none is installedltkfalls back to an embedded Sora Regular build with a stderr warning. - A theme named
default, installed system-wide (theltk-theme-defaultDebian package drops it under/usr/share/ltk/themes/default/) or exposed throughLTK_THEMES_DIRfor development.
Rendering backend selection is automatic:
- GLES when EGL is available (every modern Wayland compositor).
- Software fallback otherwise.
- Set
LTK_FORCE_SOFTWARE=1to 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 showcasecargo run --example responsivecargo run --example widgetscargo run --example inputscargo run --example scrollcargo run --example combocargo run --example dialogcargo run --example sliderscargo run --example pickerscargo run --example carouselcargo run --example clip_pathcargo run --example mini_shellmake web-serve, then http://localhost:8000/:web_helloin a browser, on the in-tree default theme
In general:
- start with
showcasefor a regular app window - use
responsiveto see the fluid vs physical modes on stock widgets - use
widgetsto see the core controls - use
mini_shellif you need overlays, theme switching, or shell-style composition
Public API Overview
Most applications should start with this subset:
AppElement<Msg>- widgets such as
button,text,text_edit,img_widget - layouts such as
column,row,stack,grid,spacer Colorrun
More advanced APIs are available when needed:
overlays()shell_mode()and layer-shell controlsset_channel_sender()andpoll_external()- gesture hooks such as
on_swipe_* core::UiSurface- runtime theme APIs
osk::Paneland 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/fluidand bounded with.clamp. - Physical — sizes stay a constant real-world size across displays (the
mainstream HiDPI
dpmodel). Best for conventional windowed apps. Written withLength::dpplusset_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
truefromis_animating()only while something is actually moving — note that on the software backend animation redraws are capped at ~30 Hz by default (override withApp::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 aShadowslot show the soft halo on GLES and a flat fill on software. - Inner / inset shadows (
Canvas::fill_shadow_inset) —InsetShadowslots paint nothing on software. - Inset shadow blend modes —
PlusLighter,Multiply,ScreenandOverlayare GLES-only; the GLESOverlaypath 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:
- run
examples/showcase.rs - read
docs/onboarding.md - browse
docs/widgets.mdfor the catalogue - dip into
docs/cookbook.mdwhen you hit a specific shape - open
docs/architecture.mdonce 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.
- Symbolic icons under
themes/default/icons/catalogue/come from Streamline's Core Line Free set, distributed under Creative Commons Attribution 4.0 International (CC BY 4.0). © Streamline. Some files have been modified for the symbolic-tinting pipeline; details inthemes/default/icons/catalogue/LICENSE.md. Upstream: https://www.streamlinehq.com/icons/core-line-free. - Sora Regular (
src/theme/fallback/Sora-Regular.otf) is the embedded font fallback, distributed under SIL Open Font Licence 1.1. © 2019-2020 The Sora Project Authors, Jonathan Barnbrook, Julián Moncada. Upstream: https://github.com/sora-xor/sora-font.
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.