Exclusive zones (event_loop/overlays_reconcile.rs, event_loop/surface.rs). `OverlaySpec::size` is documented as physical pixels and converted to logical for `layer_surface.set_size` by dividing by the parent's integer scale; `exclusive_zone` sat right beside it in the same `LayerConfig` and was passed through raw, so at scale 2 the reserved band was expressed in a unit twice as coarse as the surface it is meant to match. Worse, `set_exclusive_zone` appeared exactly once in the whole crate — at materialize time — while the reconcile loop propagated later size changes through `last_requested_size`, so an overlay whose size kept being recomputed carried a reservation frozen at whatever the first frame produced. Crustace's dock is the visible case: it derives both numbers from the same `desktop_pill_height`, and with the zone stuck at the density-1 value (icon at its 40 px floor, 40 × 1.70 = 68 logical) while the surface grew to 87, a maximized window overlapped the top fifth of the dock — measured on a 1.75 output as 151 physical px of painted dock against a 120 px reserved band. The zone now goes through the same divisor as the size, with `-1` (ignore other zones) and `0` (reserve nothing) passing through untouched as the sentinels they are, and `SurfaceState` tracks `last_requested_zone` so the reconcile loop re-sends it whenever the spec moves, committing once for both. Slider::on_release / VSlider::on_release (widget/slider, widget/vslider, widget/handlers.rs, widget/element.rs, input/gesture). Sliders only had `on_change`, which fires on every motion event, so an app whose commit is expensive — a subprocess, a D-Bus round trip, a compositor reconfigure — paid for it per pixel of travel: dragging the text-scale slider in Eydos Settings wrote `gsettings` once per motion and swept the whole desktop through a repaint each time. The new builder fires once with the final value when the drag ends, leaving `on_change` to move the thumb and nothing else; `on_change` alone behaves exactly as before, and the mapped variant propagates through `map_msg` like its sibling. The handler snapshot carries the callback next to `on_change` and exposes `slider_release_msg`; the gesture machine's slider branch of `on_release`, which previously returned an empty event list, now resolves the widget through `find_widget`, recomputes the value from the release position with the same `slider_value_from_pos` the drag path uses, and pushes `ReleaseEvent::PushMsg` — the variant whose documentation already described "button press or final slider value on release". A unit test covers the new emission; the existing one asserting an empty release still holds, because it passes an empty widget list and the lookup finds nothing. ListItem elision (widget/list_item/mod.rs). The label and subtitle were painted with `draw_text` and no width budget, so a row title longer than its width ran under the trailing text or the disclosure icon instead of truncating. The trailing slots are now measured and positioned before the text is painted — their extent is what decides how much room the label has — and both lines are elided with an ellipsis against the space left over, accumulating per-character widths the way `Text` already did, with one icon gap kept between the text and whatever follows it. `Text` keeps its own inline copy of that algorithm for now; folding the two into a single crate-internal helper is the natural follow-up, and a precondition for teaching `Button` to elide once `Row` learns to distribute a width deficit instead of only leftover space.
35 KiB
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·icon_button·pressable·window_button·list_item
- Stateful binary controls
- Continuous controls
- Text input and display
- Decoration and chrome
container·separator·img_widget·external
- Clipping wrappers
- Overlays and feedback
- Pickers
- Layouts
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()
# }
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.
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 )
# }}
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()
# }}
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.
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 ).
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() )
# }}
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.
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 theCanvasand the widget's laid-outrect(physical pixels). Works on both backends and is the way to host a customonDraw-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's sub-canvas 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.
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.
carousel
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.
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.
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.
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()
# }
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.