Files
ltk/docs/widgets.md
T
admin ce98c7b82c
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
TabStrip, the open documents of an application as a browser or a terminal shows them, with tabs that grow, shrink and can be dragged, an overview of cards for a phone, a widget anchored under another one and list items in colours of their own
Trust and prompt each drew their own strip of tabs, their own phone bar with a counter and their own grid of cards, and neither animated anything nor let a tab move. `tab_strip` does all of it once, in colours the application chooses, so the same widget serves a black terminal and a browser that follows the theme. Two things the menus of those applications needed come with it: a way to place a menu under the button that opens it, and list items that are not drawn in the theme's colours.
The widget. `tab_strip( tabs )` takes `TabItem`s, made with `tab( key, label )`, where the key stays the same while the tab is open, plus `.image( ImageData )` for a page's icon, `.symbol( name )` for a theme icon drawn in the tab's ink, and `.closable( bool )`. The builder sets the `selected` tab, `on_select`, `on_close`, `on_overview`, the buttons before the tabs (`leading`), right after the last one (`after_tabs`) and at the far right (`trailing`), the phone bar's `mobile_center`, the `TabStripColors`, the `TabLayout`, `reorderable` and the `TabMotion`. `TabStripColors` sets the strip's background and ink and the fill, edge and ink of the active tab and of the others; the default follows the theme.
On a desktop. `[leading] [tabs…][after_tabs]  clear  [trailing]`. The tabs share the width between 90 widget pixels and a fifth of the screen's width and scroll sideways when even the minimum does not fit, and 7.5 % of the width between the leading and trailing buttons is always left clear, also while they scroll. A horizontal scroll measures its child with no width limit, so the tabs inside it cannot see how much room there is; two new elements solve that. `TabRow`, outside the scroll, works the tab width out of its own rect, less the clear part and the buttons after the tabs, and hands it to the `TabMotion`; `TabCells`, inside the scroll, places the tabs, the gaps of closing ones and the buttons where the motion says. Each tab is clipped by a `viewport`, so it can grow from nothing and shrink away.
On a phone. `TabLayout::Mobile` is a bar with the leading buttons, the active tab's icon and title or the application's `mobile_center`, a counter that sends `on_overview` and the trailing buttons. `tab_overview( tabs )` is the grid of cards it opens, with a `title`, a `back` message and header `action`s: `build` gives the whole page, header and scrolling cards, and `cards` the grid alone for an application that lays its page out itself. The cards are placed by a third new element, `TabCards`, every one as tall as the tallest, so a dragged card can follow the finger.
The motion. `TabMotion` is the only state, kept by the application, handed to every view and reported from `App::is_animating`; it is an `Arc<Mutex>` because `view` only gets `&self`, and clones share it. A tab that opens grows from nothing while the others narrow, and one that closes leaves a gap that shrinks away while the others keep their width for a second, so the close button of the tab that slides in lands under the pointer; another close starts the second again. Changes take 180 ms, ease-out. The first layout and a resize alone place the tabs without moving them.
Reordering. With `reorderable( true )` each tab, and each card of the overview, carries its select message as `on_drag_start`, so it is dragged after 24 px of mouse motion or a long press, and dragging a tab selects it. The drag reaches the application, which forwards `App::on_drag_move` to `TabMotion::drag_move` and answers `App::on_drop` with the `( from, to )` that `TabMotion::drag_end` reports. The motion remembers where it drew every tab: the first move takes the tab under it and returns `false` when there is none, the tab then follows the pointer within the tabs and is drawn on top, and the others slide out of its way as its centre passes theirs, or as it enters another cell of the overview. On release it slides into its place. A strip and its overview are never shown at once and may share one motion.
Anchored. `AnchoredOverlay`, which only the combo used, is public as `anchored( child, id )`: it draws its child under the widget built with that `WidgetId`, as it was laid out on the previous frame. `align` lines it up with the anchor's left edge, its right edge or its middle, `gap` sets the space under it, and the child is now kept within the rect its parent gives it, which also keeps a combo's list inside the window.
List items and icons. `ListItem::ink( label, secondary )` draws the label, the subtitle and the trailing text in colours of the application's own and tints hover and press with the label's, for rows on a surface that is not the theme's. A button's icon is now fitted into its square without being stretched: a wide icon such as `actions/remove`, a 25 by 4 minus, was drawn as a block as tall as it was wide.
Documentation, example and tests. `docs/widgets.md` describes `tab_strip` and `anchored` and the new `ink`. `cargo run --example tab_strip` opens three documents that can be added, closed and dragged, on a desktop strip or a phone bar with its overview. The tests cover the share of the width, its limits and the clear part, the buttons after the tabs, the drawing order of a dragged tab, the builder's defaults, the overview's rows, and in `motion.rs` the growth, the gap, the wait and its restart, a resize, and dragging a tab and a card; `anchored` and the icon fit have tests of their own.
Not done. A strip that scrolls does not scroll to the active tab or follow a tab dragged towards its edge, and the overview does not scroll while a card is dragged to the edge of the screen.
2026-10-15 08:22:41 +02:00

43 KiB
Raw Blame History

ltk widget catalogue

A flat reference of every widget and layout exported at the crate root. Each entry has the same four sections: what it is, when to use it, minimal example, see also. The full builder API and per-method docs live on cargo doc; this file is the at-a-glance index.

For mental model and architecture, read docs/onboarding.md and docs/architecture.md first. For copy-pasteable patterns built from these widgets, see docs/cookbook.md.

Table of contents


Buttons and activations

button

A standard text button. Activates on tap, Enter, or Space when focused. font_size, height and width all take an impl Into<Length>, so the box can scale with the surface (e.g. a full-width form button) instead of sizing to its label.

When: any place a normal app would have a "Save" / "Cancel" / "Send" control.

# use ltk::{ button, Element, Length };
# #[ derive( Clone ) ] enum Msg { Save }
# fn _ex() -> Element<Msg> {
button( "Save" )
    .height( Length::vmin( 9.0 ).clamp( 44.0, 72.0 ) )
    .width( Length::orient( 95.0, 25.0 ) )
    .on_press( Msg::Save )
.into()
# }

See also: pressable for activation on a richer custom-shaped surface, icon_button for image-only buttons.

icon_button

A button whose visual is an RGBA bitmap instead of text. Same dispatch shape as button.

When: toolbar icons, system tray glyphs, anywhere the visual is icon-first.

# use std::sync::Arc;
# use ltk::{ icon_button, Element };
# #[ derive( Clone ) ] enum Msg { OpenSearch }
# fn _ex( rgba_bytes: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
icon_button( rgba_bytes, w, h ).on_press( Msg::OpenSearch )
.into()
# }

icon_size( n ) sets the drawn size. A bare number is an absolute pixel count; pass Length::widget( n ) instead to follow the widget-scaling mode, which is what stock icons (the list_item disclosure arrow) already do — use it when the two sit side by side and should match.

Give it a width( … ) or height( … ) and it becomes a framed key: it claims that size, paints the background and border of its variant like a text button, and centres the icon inside at icon_size. That is how a keypad mixes digit keys with an icon key of the same shape:

# use std::sync::Arc;
# use ltk::{ icon_button, ButtonVariant, Element };
# #[ derive( Clone ) ] enum Msg { Backspace }
# fn _ex( rgba_bytes: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
icon_button( rgba_bytes, w, h )
    .variant( ButtonVariant::Secondary )
    .width( 64.0 ).height( 64.0 )
    .icon_size( 28.0 )
    .on_press( Msg::Backspace )
.into()
# }

See also: button, window_button (specialised icon button for window decorations).

pressable

Wraps any Element so it dispatches a press message. Invisible to drawing — the wrapped child paints itself.

When: a card, a custom-styled list row, or any non-trivial visual that should behave like a button. Inner widgets that are themselves interactive (a button nested inside) keep priority.

# use ltk::{ column, container, pressable, row, Element, Pressable };
# #[ derive( Clone ) ] enum Msg { OpenWifiPicker }
# fn _ex(
#     icon:     Element<Msg>,
#     title:    Element<Msg>,
#     subtitle: Element<Msg>,
# ) -> Pressable<Msg> {
pressable(
    container( row()
        .push( icon )
        .push( column().push( title ).push( subtitle ) ) )
    .surface( "surface-card" )
)
.on_press( Msg::OpenWifiPicker )
# }

See also: button when a plain text button is enough, list_item for the standard "label + subtitle + trailing" pattern.

window_button

A title-bar control button (minimize / maximize / restore / close). Comes with a special hover-tint for Close.

When: building custom server-side window decorations.

# use ltk::{ window_button, window_controls, Element, WindowButton, WindowButtonKind };
# #[ derive( Clone ) ] enum Msg { CloseWindow, Minimize, Maximize, Close }
# fn _ex() -> ( WindowButton<Msg>, Element<Msg> ) {
let close = window_button( WindowButtonKind::Close ).on_press( Msg::CloseWindow );

// Or the full standard set:
let bar = window_controls(
    Some( Msg::Minimize ),
    WindowButtonKind::Maximize,
    Some( Msg::Maximize ),
    Some( Msg::Close ),
);
# ( close, bar.into() )
# }

focusable( true ) opts the button into the Tab cycle (off by default to match desktop convention).

See also: theme_window_controls for the colour tokens each mode supplies.

list_item

A row with a primary label, optional subtitle, optional leading icon, optional right-aligned trailing text and/or trailing icon, and a tappable surface.

When: settings menus, navigation lists, contact rows. Grows taller when a subtitle is set.

# use ltk::{ list_item, ListItem };
# #[ derive( Clone ) ] enum Msg { OpenWifi }
# fn _ex() -> ListItem<Msg> {
let mut it = list_item( "Wi-Fi" )
    .subtitle( "Eduroam" )
    .on_press( Msg::OpenWifi );
// Disclosure arrow from the active theme, tinted to match the
// trailing-text colour.
if let Some( ( rgba, w, h ) ) = ltk::theme_icon_rgba( "general/right", 21 )
{
    let tinted = std::sync::Arc::new( ltk::tint_symbolic( &rgba, ltk::theme_palette().text_secondary ) );
    it = it.trailing_icon( tinted, w, h );
}
it
# }

trailing( text ) right-aligns a text value (current setting, badge count); trailing_icon( rgba, w, h ) draws an icon at the right edge. When both are set the text sits to the left of the icon. pad_h( px ) overrides the horizontal inset between the row edge and its content (theme default 16 px) — lower it when the enclosing view's own padding already provides the margin.

height( len ) overrides the theme row height (56 design px, 68 with a subtitle) and font_size( len ) the primary-label size (16 design px; subtitle and trailing keep their theme sizes), both impl Into<Length>. Use them together where a dense list — a context menu, a compact picker — should trade the touch-target generosity of a settings list for row density; the resolved height is floored at the label's rendered height so the text never clips however aggressive the override.

ink( label, secondary ) draws the label in label and the subtitle and trailing text in secondary, and tints hover and press with label, in place of the theme's colours: for rows on a surface of the app's own, such as a menu in a fixed colour.

See also: pressable for free-form tappable rows, scroll to wrap a list of items in a scrollable container.


Stateful binary controls

toggle

A two-state on / off switch. Renders as a horizontal pill with a sliding thumb.

When: prominent settings toggles ("Wi-Fi", "Do not disturb").

# use ltk::{ toggle, Toggle };
# #[ derive( Clone ) ] enum Msg { ToggleWifi }
# struct App { wifi_enabled: bool }
# impl App { fn _ex( &self ) -> Toggle<Msg> {
toggle( self.wifi_enabled )
    .label( "Wi-Fi" )
    .on_toggle( Msg::ToggleWifi )
# }}

See also: checkbox for less prominent opt-ins, radio for mutually-exclusive groups.

checkbox

A two-state opt-in with a square box and a check glyph.

When: form fields, terms acceptance, multi-select lists.

# use ltk::{ checkbox, Checkbox };
# #[ derive( Clone ) ] enum Msg { ToggleTerms }
# struct App { accept_terms: bool }
# impl App { fn _ex( &self ) -> Checkbox<Msg> {
checkbox( self.accept_terms )
    .label( "I accept the terms" )
    .on_toggle( Msg::ToggleTerms )
# }}

See also: toggle, radio.

radio

A single option inside a mutually-exclusive group. Build one per variant; the application owns "which is selected".

When: priority pickers, layout choices, anywhere exactly one of N is selected.

# use ltk::{ column, radio, Element };
# #[ derive( Clone, PartialEq ) ] enum Priority { Low, Medium, High }
# #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) }
# struct App { priority: Priority }
# impl App { fn _ex( &self ) -> Element<Msg> {
column()
    .push( radio( self.priority == Priority::Low    ).label( "Low"    ).on_select( Msg::SetPriority( Priority::Low    ) ) )
    .push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) )
    .push( radio( self.priority == Priority::High   ).label( "High"   ).on_select( Msg::SetPriority( Priority::High   ) ) )
.into()
# }}

See also: checkbox, toggle.


Continuous controls

slider

A horizontal slider for selecting a value in [0.0, 1.0].

When: brightness / volume / scrub bars. Drag the thumb or tap a position; the change message fires continuously during drag.

# use ltk::{ slider, Slider };
# #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) }
# struct App { brightness: f32 }
# impl App { fn _ex( &self ) -> Slider<Msg> {
slider( self.brightness )
    .on_change( |v| Msg::SetBrightness( v ) )
# }}

on_release( f ) fires once with the final value when the drag ends. Pair it with on_change — which keeps moving the thumb — when the commit is expensive (a subprocess, a D-Bus round trip, a compositor reconfigure): a sweep would otherwise pay for it on every motion event. vslider carries the same builder.

accent_thumb( true ) swaps the default thumb for the two-circle brand-coloured variant. track_surface( id ) and fill_surface( id ) override the default theme slots.

See also: vslider for the vertical axis, progress_bar for read-only progress display.

vslider

A vertical slider. Same value model as slider — 0.0 at the bottom, 1.0 at the top.

When: column-shaped equalisers, compact volume / brightness picks inside narrow side panels.

# use ltk::{ vslider, VSlider };
# #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) }
# struct App { volume: f32 }
# impl App { fn _ex( &self ) -> VSlider<Msg> {
vslider( self.volume ).on_change( |v| Msg::SetVolume( v ) )
# }}

See also: slider.

progress_bar

A read-only linear progress indicator.

When: determinate operations with a known fraction (downloads, file copies, install steps).

# use ltk::{ progress_bar, ProgressBar };
# struct App { download_fraction: f32 }
# impl App { fn _ex( &self ) -> ProgressBar {
progress_bar( self.download_fraction )
# }}

See also: spinner for indeterminate operations without a known fraction.

pattern_grid

A 3×3 grid of dots for drawing an unlock pattern with a finger or the pointer. The widget is stateless: the app owns the list of visited nodes (0 to 8, row-major) and passes it back in for drawing.

When: lock screens and enrolment forms for a pattern secret.

# use ltk::{ pattern_grid, PatternGrid };
# #[ derive( Clone ) ] enum Msg { Node( u8 ), Released }
# struct App { path: Vec<u8> }
# impl App { fn _ex( &self ) -> PatternGrid<Msg> {
pattern_grid( self.path.clone() )
    .on_node( Msg::Node )
    .on_release( Msg::Released )
# }}

on_node( f ) fires with a node index when a press or a drag enters a node, once per entry rather than once per motion event, including nodes already in the path — appending, skipping a repeat, or taking in the node a straight stroke crosses is the app's rule, not the widget's. on_release( msg ) fires when the finger lifts. hide_stroke( true ) draws only the dots, against shoulder surfing; error( true ) paints the current path in the palette's danger colour. size( len ) sets the side of the square grid (any Length); without it the theme's fluid default applies, capped by the width the parent allocates.

It sizes itself square to min( max_width, 264 ) design pixels; wrap it in a container( … ).max_width( … ) to pin it.

See also: slider, which owns the pointer during a drag the same way.


Text input and display

text

A text label. By default it stays on one line and truncates with an ellipsis when wider than its rect; wrap( true ) instead word-wraps to the layout width, and line_height( mult ) scales the gap between the wrapped lines (1.0 = the font's natural leading).

When: titles, captions, multi-line hints, anything non-interactive.

# use ltk::{ text, Color, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> ( Element<Msg>, Element<Msg>, Element<Msg> ) {
let title    = text( "Title" ).size( 24.0 ).color( Color::WHITE );
let centred  = text( "Centred" ).align_center();
let hint     = text( "Swipe up or press Enter to unlock" )
    .align_center()
    .wrap( true )
    .line_height( 1.4 );
# ( title.into(), centred.into(), hint.into() )
# }

See also: text_edit for editable text.

text_edit

A text input with cursor, Backspace, Enter / Submit, clipboard support, a secure( true ) password mode that masks the visible characters and zeroizes the buffer on drop, and a password_toggle( visible, on_toggle ) builder that pins a show / hide-password eye icon to the right edge of the field. Single-line by default; multiline( true ) switches to a multi-row editor whose visible height follows rows( n ). A multiline field whose text overflows scrolls with the wheel or a touchpad, three lines per notch, and takes the motion ahead of any scroll around it while it can still move that way; once the cursor moves again the view follows it, by the least it has to.

When: login fields, chat inputs, search boxes.

# use ltk::{ text_edit, Element };
# #[ derive( Clone ) ] enum Msg {
#     UsernameChanged( String ), PasswordChanged( String ),
#     TogglePassword, Submit,
# }
# struct App { username: String, password: String, show_password: bool }
# impl App { fn _ex( &self ) -> ( Element<Msg>, Element<Msg> ) {
let user = text_edit( "Username", &self.username )
    .on_change( |s| Msg::UsernameChanged( s ) )
    .on_submit( Msg::Submit );

// Password field with the built-in show / hide eye. The widget
// owns the icon hit-testing — taps inside the eye zone fire
// `TogglePassword` instead of moving the caret. Flip the bool in
// your `update`; the widget reads it back on the next render.
let pw = text_edit( "Password", &self.password )
    .on_change( |s| Msg::PasswordChanged( s ) )
    .password_toggle( self.show_password, Msg::TogglePassword );
# ( user.into(), pw.into() )
# }}

color( c ) sets the colour of the value (the placeholder keeps the theme's), for a field dropped onto a surface the theme does not know about. on_focus( msg ) emits msg when the field takes the keyboard focus — from a press, Tab or App::take_focus_request — and not again for a press on the field that already has it; App::on_text_input_focused still reports whether some text field is focused, on_focus says which.

password_toggle keeps the wipe-on-drop and IME-bypass guarantees of secure( true ) regardless of the current visibility, so flipping the eye does not weaken the field's threat model at runtime — only what the user sees on screen changes.

purpose( InputPurpose::Terminal ) declares what the field is for: the compositor's input method is told through the text-input content type, and ltk's own on-screen keyboard, when it is the one shown, adds its row of Escape, Tab, Ctrl, Alt and arrows above the layout. The inputs example's Shell page is the reference; see On-screen keyboard.

See also: the password recipe in docs/cookbook.md.

rich_text

A wrapped paragraph that carries a Msg per clickable link range — the ltk counterpart of an Android Spanned with URLSpan / ClickableSpan. Unlike text, the layout pass emits one hit rect per link line, so a link that wraps across lines is hit-tested on each of its lines and taps land on the link rather than on the whole paragraph.

When: body copy with inline links — a terms-and-conditions blurb, a chat message with a URL, an "about" screen crediting a project.

# use ltk::{ rich_text, Element };
# #[ derive( Clone ) ] enum Msg { OpenTerms, OpenPrivacy }
# fn _ex() -> Element<Msg> {
let body = "By continuing you accept the Terms and the Privacy Policy.";
rich_text( body )
	.size( 14.0 )
	.link( 30, 35, Msg::OpenTerms )      // byte range of "Terms"
	.link( 44, 58, Msg::OpenPrivacy )    // byte range of "Privacy Policy"
	.into()
# }

Link ranges are byte offsets into the content [start, end), drawn underlined in link_color. color sets the non-link text colour, size the font size (any Length), and font( family, weight, style ) the typeface resolved through the active theme on draw.


Decoration and chrome

container

A wrapper that adds padding, a background colour or themed surface, a border radius, and (via theme slots) shadows / inset shadows / backdrop blur.

When: cards, panels, callouts, anywhere a child needs a backdrop or elevation.

# use ltk::{ column, container, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( title: Element<Msg>, subtitle: Element<Msg> ) -> Element<Msg> {
container( column().push( title ).push( subtitle ) )
    .surface( "surface-card" )
    .padding( 16.0 )
    .radius( 24.0 )
.into()
# }

Per-edge padding is available with padding_top / padding_right / padding_bottom / padding_left. Per-corner radius takes a Corners struct or a tuple.

max_width(px) caps the container's outer width — when the parent offers a wider rect, the container reports min( offered, px ) as its preferred width instead of stretching to fill. Note the semantics differ from column's max_width, which caps the content width while the column still claims the full available rect (row has no such flag); the container cap shrinks the widget itself, so a single decorated child gets a real width cap without extra wrapping.

See also: pressable wrapping a container makes a card interactive.

separator

A horizontal divider line with a theme-default colour and a mode-scaled thickness / vertical padding. thickness and pad_v take an impl Into<Length>; both default to the process widget-scaling mode, and passing pad_v( 0.0 ) gives a flush, padding-less divider (distinct from the mode default).

When: visual breaks between settings groups, list categories, content blocks.

# use ltk::{ column, separator, text, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
column()
    .push( text( "General" ) )
    .push( separator() )
    .push( text( "Network" ) )
.into()
# }

img_widget

A static image rendered from RGBA pixel data shared via Arc.

When: wallpapers, icons, illustrations. The shared Arc makes re-using the same buffer across frames a pointer copy.

# use std::sync::Arc;
# use ltk::{ img_widget, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> {
img_widget( rgba_bytes, width, height )
    .opacity( 0.8 )
.into()
# }

cover() scales to fill the rect preserving aspect; size( w, h ) sets explicit display dimensions.

See also: Image::from_path helper for disk-loaded files (PNG, JPEG via the image crate).

external

An escape hatch that reserves layout space and defers its pixels to a caller-provided producer, composited in-line with the rest of the tree. Two sources:

  • External::cpu( w, h, |canvas, rect| … ) — an immediate-mode CPU drawing closure invoked once per frame with the Canvas and the widget's laid-out rect (physical pixels). Works on both backends and is the way to host a custom onDraw-style routine — paths, clips, text — straight onto the canvas with no GL round-trip.
  • External::new( w, h, ExternalSource::Texture( … ) ) — samples a caller-owned GL texture each frame (a web engine, a video decoder). GLES only; the producer keeps the texture and ltk only composites.

When: a VectorDrawable / Lottie frame, a custom-painted gauge, or embedding another renderer's output.

# use ltk::{ External, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
External::cpu( 120.0, 120.0, |canvas, rect|
{
	canvas.fill_rect( rect, ltk::Color::rgb( 0.1, 0.1, 0.12 ), 8.0 );
	// any Canvas primitive: fill_path, set_clip_path, draw_text, …
} ).into()
# }

See also: the CPU-drawing and path-clip recipe in docs/cookbook.md.


Clipping wrappers

scroll

A scrollable viewport — vertical by default, with .horizontal() and .both() builders switching the axis (ScrollAxis::{ Vertical, Horizontal, Both }). Drag the content to scroll; clipping is automatic.

When: lists or grids that may overflow the available height.

# use ltk::{ column, list_item, scroll, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
scroll(
    column()
        .push( list_item::<Msg>( "Item 1" ) )
        .push( list_item( "Item 2" ) )
        .push( list_item( "Item 3" ) )
)
.into()
# }

The scroll widget owns its own gesture handling — drags inside it do not trigger the app-level on_swipe_* callbacks.

See also: viewport for passive clipping without gestures, grid inside a scroll for app drawers.

viewport

A passive clipping wrapper. Clips its child to the assigned rect; no scrolling, no gesture handling.

When: panels that animate in via a parent translation, fade-in content, anywhere you want a hard clip without scrolling. The fade_bottom( px ) builder feathers the bottom edge to transparent during slide-in animations (GLES backend; software backend renders a hard edge).

# use ltk::{ viewport, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( panel_view: Element<Msg>, panel_height: f32 ) -> Element<Msg> {
viewport( panel_view )
    .height( panel_height )
    .fade_bottom( 16.0 )
.into()
# }

By default the clip inherits the root layout viewport, so viewport-relative (vw / vh / vmin) and fluid Lengths inside resolve exactly as they would outside the clip — the right behaviour for scroll-like clipping. local_viewport() opts out: the child resolves those Lengths against the viewport's own rect instead. Use it for a fixed-size floating mini-UI — a phone-shaped panel pinned to a corner of a desktop-wide surface — whose content is calibrated against the panel rect and must not scale with the surface hosting it.

Without fade_bottom the clip is a plain scissor (a mask on the software backend) on the parent canvas: no offscreen pass, no blit, and the child's images and glyphs stay in the parent's caches. A viewport whose height changes every frame — a panel sliding in — therefore costs no more than drawing its child. With fade_bottom the child renders into a sub-canvas that is kept across frames and resized in place when the viewport's size changes.

See also: the slide-in panel recipe in docs/cookbook.md.

flex

A filler wrapper for both flow layouts. Treats its non-spacer child like a spacer for leftover-space distribution but draws the child inside the allocated rect: leftover width inside a row, leftover height inside a column. Weights split the leftover proportionally between flex / spacer siblings.

When: a row where one non-trivial child should fill the remaining width (a card next to a fixed-size icon), or a column where a child should absorb the remaining height (a log pane under fixed toolbars).

# use ltk::{ column, flex, row, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex(
#     icon:     Element<Msg>,
#     title:    Element<Msg>,
#     subtitle: Element<Msg>,
# ) -> Element<Msg> {
row()
    .push( icon )
    .push( flex( column().push( title ).push( subtitle ) ) )
.into()
# }

weight( n ) sets the relative share when there are several flex / spacer siblings.

See also: spacer for invisible fillers, column and row.

Horizontal carousel: the focused child sits centred in the viewport at focused_width_frac of the viewport width and its neighbours peek out on the left / right at gap separation. The widget is a pure layout primitive — the offset (positive shifts content right) is owned by the caller, so drag / inertia / snap-ease live in the host (compositor, gesture recogniser, etc.).

When: mobile-style app switchers, image / story strips, any single-focus horizontal navigation where neighbours hint at the next / previous tile.

# use ltk::{ button, carousel, container, Element, Color, Corners };
# #[ derive( Clone ) ] enum Msg { Open( usize ) }
# fn _ex( offset: f32 ) -> Element<Msg> {
carousel()
    .focused_width_frac( 0.8 )
    .gap( 16.0 )
    .offset( offset )
    .push( container( button::<Msg>( "Tile 1" ).on_press( Msg::Open( 0 ) ) )
        .background( Color::rgb( 0.95, 0.4, 0.4 ) )
        .radius( Corners::all( 12.0 ) ) )
    .push( container( button::<Msg>( "Tile 2" ).on_press( Msg::Open( 1 ) ) )
        .background( Color::rgb( 0.95, 0.85, 0.3 ) )
        .radius( Corners::all( 12.0 ) ) )
.into()
# }

Helpers on the widget translate between offsets and indices: snap_offset( viewport_w, idx ) returns the offset that centres tile idx; focused_index( viewport_w ) rounds the current offset to the nearest tile. The runnable demo at examples/carousel.rs shows Prev / Next buttons and arrow-key navigation against an external offset state.

See also: scroll for free vertical / horizontal panning of arbitrary content; tabs for a non-touch alternative.


Overlays and feedback

spinner

Indeterminate progress indicator. The widget is stateless: the application owns the rotation phase and advances it each frame — any monotonically increasing value works, only the fractional part is used. Pair with App::is_animating so the run loop keeps requesting redraws while the spinner is on screen.

When: long-running operations with no known fraction (network calls, indexing, "waiting for compositor").

# use ltk::{ spinner, Spinner };
# struct App { spinner_phase: f32 }
# impl App { fn _ex( &self ) -> Spinner {
spinner().phase( self.spinner_phase ).size( 24.0 )
# }}

See also: progress_bar for determinate progress.

toast

Transient notification pill anchored near the bottom of the surface. Not an Element — build it in App::overlays and return its .overlay() while a toast is pending. Auto-dismissal is the application's responsibility: duration( secs ) only stores a value read back via duration_value(), so the app schedules its own "toast expired" timer and clears the state when it fires.

When: confirmation snackbars ("Saved"), non-blocking errors, status flashes.

# use ltk::{ toast, OverlaySpec };
# #[ derive( Clone ) ] enum Msg {}
# struct App { toast_message: Option<String> }
# impl App {
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
	match &self.toast_message
	{
		Some( m ) => vec![ toast( m ).duration( 3.0 ).overlay() ],
		None      => vec![],
	}
}
# }

See also: tooltip for hover-anchored hints.

tooltip

Anchored hint rendered below a target widget. Like toast it is not an Element — return its .overlay() from App::overlays while the hint should be visible. Hover detection and the show / hide delay are the application's responsibility; the automatic variant is Button::tooltip( text ), which shows the hint by itself after a pointer dwell on the button.

When: discoverable affordances on icon-only controls, keyboard shortcut reminders, helper text that should not occupy permanent layout space.

# use ltk::{ tooltip, Tooltip, WidgetId };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Tooltip<Msg> {
tooltip( "Ctrl+S", WidgetId( "btn/save" ) ).max_width( 240 )
# }

The overlay anchors below the widget built with the matching .id( WidgetId ). See docs/cookbook.md for the full overlay wiring.

See also: toast for transient self-dismissing notifications.


anchored

Draws its child right under another widget, the one built with the matching .id( WidgetId ), as that widget was laid out on the previous frame: a menu under the button that opens it, inside the window. align lines the child up with the anchor's left edge (HAlign::Start, the default), its right edge (HAlign::End) or its middle (HAlign::Center), and the child is kept within the rect its parent gives it; gap is the space under the anchor, in physical pixels. Until the anchor has been drawn once the child fills its parent's rect.

When: a menu or a panel that drops from a button of a bar, laid over the page in a stack, usually over a scrim that closes it. For a popup that may leave the window, return an OverlaySpec with anchor_widget_id from App::overlays instead.

# use ltk::{ anchored, stack, Element, HAlign, WidgetId };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( page: Element<Msg>, menu: Element<Msg> ) -> Element<Msg> {
stack()
    .push( page )
    .push( anchored( menu, WidgetId( "menu-button" ) ).align( HAlign::End ).gap( 4.0 ) )
    .into()
# }

See also: tooltip and combo, which anchor overlays to a widget the same way.

combo

Select / dropdown with a popup list — single- or multi-select (multi_select( true ) adds selection chips), with optional type-to-filter (searchable( true )). The widget is a stateless projection over an app-owned ComboState, and the app places two pieces: the trigger (Combo::trigger()) goes in the view tree like any widget, and the open popup is either layered into the same surface via Combo::popup() inside a stack, or returned as a real xdg-popup from App::overlays via Combo::overlay().

When: pick-one (or pick-several) fields with too many options for a row of radio buttons (autocomplete, country list, theme picker).

# use ltk::{ column, combo, Combo, ComboState, Element, OverlaySpec };
# #[ derive( Clone ) ] enum Msg { Pick( usize ), ToggleOpen, Dismiss }
# struct App { fruits: ComboState }
# impl App {
# fn build_combo( &self ) -> Combo<Msg> {
# let items = vec![ "One".to_string(), "Two".to_string(), "Three".to_string() ];
combo( self.fruits.clone(), items )
	.on_toggle_open( Msg::ToggleOpen )
	.on_select_idx( Msg::Pick )
	.on_dismiss( Msg::Dismiss )
# }
fn view( &self ) -> Element<Msg>
{
	column().push( self.build_combo().trigger() ).into()
}

fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
	self.build_combo().overlay().into_iter().collect()
}
# }

update() then flips is_open on ToggleOpen / Dismiss and writes the selection into the ComboState on Pick.

See also: radio for small mutually-exclusive sets, notebook for tabbed mode switches.

tabs

A bare tab bar — visual selection without owning the page content. Use this when you want full control over what each tab shows.

When: paged settings screens where the panes are large and you want to keep them as siblings in the tree, not as notebook children.

# use ltk::{ tabs, TabBar };
# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) }
# fn _ex( active: usize ) -> TabBar<Msg> {
tabs::<Msg, _, _>( [ "Profile", "Network", "Privacy" ] )
    .selected( active )
    .on_select( Msg::TabChanged )
# }

See also: notebook for tabs that own their pages, tab_strip for the open documents of a browser or a terminal.

notebook

Tabbed container. Owns the pages and shows only the active one.

When: settings dialogs, multi-step forms, anywhere a flat tabs bar over hand-managed pages would be more code than benefit.

# use ltk::{ notebook, text, Notebook };
# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) }
# fn _ex( active: usize ) -> Notebook<Msg> {
notebook::<Msg>()
    .page( "General",  text( "general body"  ) )
    .page( "Advanced", text( "advanced body" ) )
    .selected( active )
    .on_select( Msg::TabChanged )
# }

See also: tabs for a bare tab bar without page ownership.

tab_strip

The open documents of an app, as a browser or a terminal shows them. On a desktop, a strip of tabs with their icon, title and close button, sharing the width between a minimum and a fifth of the screen, scrolling sideways when even the minimum does not fit, and always leaving 7.5 % of the width clear; buttons go before the tabs (leading), right after the last one (after_tabs) and at the far right (trailing). On a phone (TabLayout::Mobile), a bar with the active tab, or the app's own mobile_center, and a counter that sends on_overview; tab_overview is the grid of cards it opens, as a whole page with build or as the cards alone with cards. TabStripColors sets every colour, the theme's by default.

Tabs grow from nothing as they open and the others narrow; a closed tab leaves a gap that shrinks away while the others keep their width for a second, so the close button that slides in lands under the pointer. The app keeps a TabMotion, hands it to every view and reports TabMotion::is_animating from App::is_animating.

With reorderable( true ) the desktop strip's tabs, and the overview's cards, can be dragged into another order, the others sliding out of the way; the overview takes the strip's TabMotion too: the app forwards App::on_drag_move to TabMotion::drag_move and answers App::on_drop with the move TabMotion::drag_end reports, as ( from, to ).

When: a browser's tabs, a terminal's sessions, an editor's files: documents the user opens and closes, each with a key that stays the same while it is open.

# use ltk::{ tab, tab_strip, Element, TabLayout, TabMotion };
# #[ derive( Clone ) ] enum Msg { Select( usize ), Close( usize ), Overview, NewTab }
# fn _ex( titles: &[ ( u64, String ) ], active: usize, motion: &TabMotion, plus: Element<Msg> ) -> Element<Msg> {
tab_strip( titles.iter().map( |( key, title )| tab( *key, title.clone() ).symbol( "features/console" ) ) )
    .selected( active )
    .on_select( Msg::Select )
    .on_close( Msg::Close )
    .on_overview( Msg::Overview )
    .after_tabs( plus )
    .layout( TabLayout::Desktop )
    .motion( motion )
    .into()
# }

See also: tabs for a segmented selector between fixed sections.

dialog

Modal or non-modal centered confirmation card with a built-in scrim, optional title, subtitle, custom body, and a right-aligned action row. Pressing Esc fires the configured cancel message.

When: destructive confirmations ("Delete file?"), guided pickers that block other interaction until resolved, "are you sure" gates before a long-running operation.

# use ltk::{ button, dialog, ButtonVariant, Element };
# #[ derive( Clone ) ] enum Msg { Cancel, Confirm }
# fn _ex() -> Element<Msg> {
dialog()
    .title( "Delete partition?" )
    .subtitle( "This will erase every file on /dev/sda2." )
    .cancel( Msg::Cancel )
    .action( button::<Msg>( "Cancel" ).variant( ButtonVariant::Tertiary ).on_press( Msg::Cancel ) )
    .action( button::<Msg>( "Delete" ).variant( ButtonVariant::Primary  ).on_press( Msg::Confirm ) )
    .into()
# }

modal( false ) + dismiss_on_scrim( msg ) makes a tap on the dim background fire msg; combining dismiss_on_scrim with modal( true ) panics when the dialog is converted to an Element — the .into() asserts with "dialog: dismiss_on_scrim is not valid when modal=true", since the two contracts contradict. body( elem ) swaps a custom element in between the subtitle and the action row — the example app at examples/dialog.rs uses this for an in-dialog slider. max_width( … ) caps the card width; it accepts any Length and defaults to Length::fluid( 480.0 ) so the card scales with the same curve as the stock buttons inside it.

See also: toast for non-blocking transient notifications, combo for a single-pick popup that does not require modal blocking.


Pickers

date_picker

Calendar-grid date selector with month / year navigation.

When: birthdays, deadlines, any single-date input.

# use ltk::{ date_picker, Date, DatePicker };
# #[ derive( Clone ) ] enum Msg { Picked( Date ) }
# fn _ex() -> DatePicker<Msg> {
date_picker( Date::new( 2026, 5, 7 ) ).on_change( Msg::Picked )
# }

time_picker

Hour / minute selector. Wheel-style scrubbing on touch.

When: alarms, scheduling, time-of-day input.

# use ltk::{ time_picker, Time, TimePicker };
# #[ derive( Clone ) ] enum Msg { Picked( Time ) }
# fn _ex( now: Time ) -> TimePicker<Msg> {
time_picker( now ).on_change( Msg::Picked )
# }

color_picker

Hue + saturation/value picker with a hex/RGB readout.

When: theming UIs, paint tools, accent customisation.

# use ltk::{ color_picker, Color, ColorPicker };
# #[ derive( Clone ) ] enum Msg { Picked( Color ) }
# fn _ex( current: Color ) -> ColorPicker<Msg> {
color_picker( current ).on_change( Msg::Picked )
# }

Layouts

column

Vertical flow. Children stack top-to-bottom with optional padding, spacing, alignment.

# use ltk::{ button, column, spacer, text, Element };
# #[ derive( Clone ) ] enum Msg { Ok }
# fn _ex() -> Element<Msg> {
column()
    .padding( 24.0 )
    .spacing( 12.0 )
    .push( text( "Title" ) )
    .push( spacer() )
    .push( button( "OK" ).on_press( Msg::Ok ) )
.into()
# }

max_width(px) caps the inner content width; fit_content() reports the natural content width to the parent (otherwise the column claims the full available width). center_y( true ) centres the content block vertically when there are no spacers.

Watch the defaults: column() starts with 16 px padding on every side (and 8 px spacing). A column nested inside an already-padded container silently doubles the inset — pass .padding( 0.0 ) when the parent owns the margin.

row

Horizontal flow. Mirror of column on the X axis — except its default padding is 0, not column's 16 px.

# use ltk::{ flex, row, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex(
#     label:         Element<Msg>,
#     field:         Element<Msg>,
#     submit_button: Element<Msg>,
# ) -> Element<Msg> {
row()
    .spacing( 8.0 )
    .push( label )
    .push( flex( field ) )
    .push( submit_button )
.into()
# }

When the children do not fit, the row takes the shortfall off the widest ones first, so a long label gives up its width while a narrow neighbour (an icon button beside a title) keeps its size, and equal siblings share it evenly. Leaves that paint a line of text (button, list_item) truncate with an ellipsis into what they are given. Pinned spacers keep their width — an explicit gap is a decision, not slack. Use no_shrink() for strips that are meant to be wider than their viewport and scrolled.

stack

Z-order overlay. Each child gets explicit horizontal and vertical alignment (HAlign / VAlign) plus an optional margin and pixel translation. Children are drawn in declaration order — the last child sits on top.

# use std::sync::Arc;
# use ltk::{ button, column, img_widget, stack, text, Element, HAlign, VAlign };
# #[ derive( Clone ) ] enum Msg { Add }
# fn _ex( background: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
stack()
    .push( img_widget( background, w, h ) )
    .push_aligned(
        column().push( text( "Heading" ) ),
        HAlign::Center, VAlign::Center,
    )
    .push_aligned_margin(
        button( "+" ).on_press( Msg::Add ),
        HAlign::End, VAlign::Bottom,
        16.0,
    )
.into()
# }

See also: column and row for flow layout.

grid

Fixed-column-count grid that wraps its children into rows.

When: icon launchers, photo galleries, app drawers.

# use std::sync::Arc;
# use ltk::{ grid, icon_button, Element };
# #[ derive( Clone ) ] enum Msg { OpenApp1, OpenApp2 }
# fn _ex( app1_rgba: Arc<Vec<u8>>, app2_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
grid( 4 )
    .padding( 16.0 )
    .spacing( 12.0 )
    .push( icon_button( app1_rgba, w, h ).on_press( Msg::OpenApp1 ) )
    .push( icon_button( app2_rgba, w, h ).on_press( Msg::OpenApp2 ) )
    // ...
.into()
# }

Wrap inside scroll when the grid may overflow.

centre_last_row( true ) shifts a partial last row so its tiles sit centred under the full rows above instead of left-aligned — useful for app switchers and gallery layouts where a 7-of-9 leftover band reads better balanced.

grid_min_cell( width ) is the adaptive variant: instead of a fixed column count, it fits as many columns as the available width allows while keeping every cell at least width wide (never fewer than one), re-deriving the count on every layout — so the same grid shows more columns on a wide window and fewer on a phone. width accepts any Length; max_columns( n ) caps the derived count so cells grow instead of multiplying on very wide surfaces.

spacer

An invisible flexible filler. Inside a column / row, absorbs leftover space along the parent's main axis.

# use ltk::{ column, spacer, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( header: Element<Msg>, footer: Element<Msg> ) -> Element<Msg> {
column()
    .push( header )
    .push( spacer() )       // pushes footer to the bottom
    .push( footer )
.into()
# }

weight( n ) sets the relative share; height( px ) / width( px ) pin the spacer to a fixed size on its respective axis.

See also: flex for a non-empty filler.