Application code should describe a tree of components and children. The scene is the retained rendering backend; manually appending scene nodes is not the intended translation of a view body. The composition API connects that declarative structure to the existing fine-grained reactive runtime, widget behavior, layout cache, and damage tracker.
A component is constructed once for each mounted instance. Its local state belongs to that instance and survives updates to text and other reactive properties. Reading a signal while constructing a component does not subscribe an enclosing dynamic branch to that signal. Reactive text and dynamic selectors install their own subscriptions. This separation prevents an unrelated title or list update from rebuilding an entire application.
A view function returns a child tree. Components create state and resolve services during mounting; ordinary container builders describe their children:
use zgui::{
compose::{View, button, column, component, text_signal},
style::Styled,
};
fn counter() -> View {
component(|cx| {
let count = cx.state(0_u32);
column().gap(8.0).p(12.0)
.child(text_signal({
let count = count.clone();
move || format!("Count: {}", count.get())
}))
.child(button().child(zgui::compose::text("Increment")).on_click(move || {
count.update(|value| *value += 1);
}))
})
}Ui::mount(view) adds a retained view tree and returns a handle; Ui::render(view) replaces the root children after the new tree mounts successfully. If construction panics, newly mounted nodes/resources are cleaned up and the previous root remains available. Conditional replacement has the same ownership cleanup and preserves its previous branch on construction failure. This does not roll back arbitrary application state writes.
Initial reactive bindings belong to the public mount that created them, including bindings created by its initial conditional or keyed children. If one panics, that mount is removed even when an outer batch or active effect deferred the callback until after mount returned. Unrelated mounted siblings survive. Such late failures do not restore root children that render already replaced. A panic in a subsequent reactive update leaves the existing view mounted; application code must correct the failing dependency before retrying. Panics propagate to the caller rather than becoming native Application::run errors.
The UI owns the mounted tree, so dropping a handle does not remove the view. Call handle.unmount() to dispose it explicitly. In a native application, WindowContext::render(view) supplies the window's task runner automatically.
A component can call cx.service::<Model>() to obtain the nearest typed provider, cx.state(initial) for local state, and cx.tasks() for component-owned asynchronous work. For a headless host, wrap the tree in provide(TaskRunner::from_executor(executor), tree). The complete model example preserves the supplied language sample, including its yielding handlers, error cases, and independent Count and Title subscriptions. Run it with cargo run -p zgui --example model.
The native version is cargo run -p zgui-desktop --example components. It renders the same Model, Count, Title, and Dialog body, with fluent background, rounded corners, padding, spacing, hover, pressed, and keyboard-focus styles. The style closures return sparse overrides, so a focus border does not replace an unrelated hover background. Layout and paint changes continue through the retained scene's existing invalidation paths.
use zgui::{compose::{button, text}, scene::Color, style::Styled};
let action = button()
.w(72.0).h(40.0).rounded(8.0)
.bg(Color(42, 53, 76, 255))
.hover(|style| style.bg(Color(58, 76, 108, 255)))
.focus(|style| style.border(2.0).border_color(Color(112, 174, 255, 255)))
.child(text("+"));| Language construct | Meaning in the Rust component tree |
|---|---|
service Model |
An ordinary Rust type containing shared list and signal handles. |
provider Shared / provide Shared |
A typed provider enclosing a subtree. Descendants resolve the nearest provider of that type. |
view App |
A mounted component with its own initialization, local state, children, and resources. |
state done: Int = 0 |
A signal created once during the component's initialization. |
column { ... } / row { ... } |
Container elements with child elements. |
text expression |
A reactive text binding that tracks the expression's actual reads. |
slot body |
Caller-supplied children with the caller's lexical provider context. |
on click |
An activation handler, shared by pointer, keyboard, and accessibility activation. |
task.yield() |
A local future that yields to the executor before continuing. |
catch |
Ordinary Rust Result matching with the language's error mapping preserved. |
Provider lookup and slot ownership are separate concerns. Slot content captures the caller's service context, so an internal provider in a dialog cannot silently change what Model means inside the caller's body. Once mounted, that body belongs to its mounted subtree: removing the dialog also disposes the body, its subscriptions, handlers, and owned tasks.
The sample's list has an initial length of zero and a logical capacity of four. Three sequential appends run inside one reactive batch after yielding. A batch delays notifications; it is not a transaction. A subsequent append sequence can insert one item and then return the capacity error, leaving four items. Editing a row does not invalidate length readers, and writing the same row value twice produces only one notification. These behaviors are preserved independently of how the view syntax is written.
The retained component tree does not perform a whole-tree render after each state write. A text expression updates its existing text node; a structural condition reevaluates its selector and replaces only the selected child subtree. Component initialization runs without capturing dependencies for the surrounding selector. Effects created by that component still track their own reads normally.
State that must survive unmounting belongs in a longer-lived service, usually keyed by model identity. Local component state is intentionally released when its owning component is removed. The same rule applies to event subscriptions and asynchronous work owned by a component.
The Rust API is a target for a future template compiler, not an implementation of the parser. Language-level error typing, name resolution, and generated source diagnostics remain compiler responsibilities. Application authors can use Rust functions and typed data directly while retaining the same component, child, provider, and slot boundaries.
Headless TaskRunner::from_executor uses a weak spawning capability: a running component task can schedule another task without borrowing the executor during its own poll. New work starts on a later tick, and retaining the runner does not keep a disposed executor alive.
text_input(label, value) and text_area(label, value) mount the same retained editor used by the native host. The first argument is its accessible name, and the Signal<String> is synchronized in both directions. IME preedit remains local until committed. Selection, undo, clipboard, and native IME placement use the mounted editor's lifecycle.
use zgui::{compose::prelude::*, text_layout::FontFamily, widgets::Ui};
let mut ui = Ui::new(640., 480.);
let name = ui.signal(String::new());
let notes = ui.signal(String::new());
ui.mount(column().gap(12.).children([
text_input("Name", name.clone()).w(320.),
text_area("Notes", notes).size(480., 180.).text_wrap(true).font_family(FontFamily::Monospace),
]));
name.set("Ada".into());
ui.prepare_frame(); // Native hosts do this automatically before rendering/input.Multiline editors handle Page Up/Down and Shift+Page Up/Down using the padded viewport height, retaining one visual line of overlap when space permits. Paging and Up/Down share the preferred horizontal caret position, including through shorter lines. Paging scrolls with the caret and clamps at document boundaries; Shift retains the selection anchor. It does not change committed text or undo history, and remains available in read-only editors. Single-line editors and Control/Alt/Meta-modified paging leave the default unhandled so application or ancestor behavior can respond.
Pointer selection supports Shift-click to extend the current anchor, double-click to select a Unicode word-boundary segment, and triple-click to select a visual line (all text in single-line fields). Punctuation and whitespace are selectable segments; word boundaries expand to whole graphemes. Visual-line selection excludes hard newline terminators. Dragging after a double/triple-click extends in whole word/line units and reverses around the original unit's opposite edge. Shift takes precedence over the click count. Selection remains available in read-only editors and cancels preedit without editing the committed model.
Holding a captured selection pointer outside an editor autoscrolls the document and extends the selection even without further pointer movement. Single-line fields scroll horizontally; multiline fields also scroll vertically, with horizontal scrolling disabled by wrapping. Word/line drags keep their selection units. Speed increases with distance outside the viewport and is bounded; a scheduler stall contributes at most 50 ms of motion. Hit testing clamps to the visible edge before extending selection, rather than jumping to an arbitrarily distant document position.
The core retains at most one interaction deadline, armed only while scrolling
can advance. Release, cancellation, loss of capture/focus, disabling, removal,
changed external text, reentry or an exhausted scroll direction stops scheduled
work. Read-only fields remain selectable. Desktop hosts drive this automatically
and cancel captured interactions when hidden or suspended. Custom/headless hosts
combine Ui::next_interaction_deadline() with their event-loop deadline and call
Ui::advance_interactions(now) when due; Ui::cancel_interactions() cancels
captured gestures when a host becomes inactive. The fallible advance method
returns whether a tick ran. Idle editors schedule no interaction wakeups.
The native host forwards pointer modifiers and tracks clicks per target and
button within 500 ms and four logical pixels, cycling through counts 1, 2, 3,
then 1. This is the framework's portable policy, not a reflection of OS-specific
multi-click preferences. Movement outside that distance, leaving the target,
cancellation, deactivation, disabling or removal resets the chain. Event handlers
can inspect EventContext::pointer_modifiers() and click_count(); counts apply
to pointer-down events. Custom hosts use Ui::dispatch_with_modifiers (or its
fallible counterpart). Existing event literals remain unchanged, and ordinary
Ui::dispatch retains unmodified single-click behavior for synthetic input.
Use .read_only(true) for selectable output or .read_only_when(move || locked.get()) for a reactive editing policy. Read-only editors remain focusable and support selection, navigation, scrolling and copy. Typing, paste, deletion, undo/redo, IME composition and accessibility value replacement cannot change their text; cut copies the selection without deleting it. External model updates remain permitted. Entering read-only mode cancels existing preedit while retaining focus, selection and history. The native host disables IME for the read-only editor, and accessibility exposes its read-only state while retaining selection actions. These options apply to editor roots, including components that return an editor; they do not propagate from containers. Direct access to the low-level TextEditor remains an application mutation API.
Committed editor text uses LF line endings: CRLF and lone CR become LF, then text_input removes line breaks. This applies to initial values, external model updates, typing, paste, accessibility replacements, and IME commits. The bound signal receives the canonical text. Tabs remain intact; ordinary text events still reject other control characters as a whole. Native preedit stays unmodified so its cursor offsets remain valid. Canonical-equivalent model updates preserve selection, preedit, and history, and initial normalization does not create an undo entry.
External model replacements record a single changed UTF-8 span after removing the common prefix and suffix, so appending a small token does not retain another whole-document undo snapshot. The default limits remain 100 entries and 4 MiB of inserted/deleted payload per undo or redo stack; metadata and allocator overhead are additional. A changed replacement still moves the caret to the document end, and undo restores the prior selection. Canonical model ingress borrows the signal text instead of cloning the full document. Comparing replacements still takes time proportional to document length; this is not a rope or constant-time append API.
Selection and read-only refreshes retain unchanged paint text and semantic value storage. The semantic text-input update reuses its string capacity when committed text changes. These avoid document copies in the core refresh path; cold text shaping and native accessibility projection have separate allocation costs.
Editors accept the ordinary dimension, padding, decoration, typography, focus, and disabled styles. Their internals remain stable when layout reallocates their width. text_area preserves explicit line breaks and scrolls horizontally for long lines by default. Add .text_wrap(true) to wrap at the allocated content width. This inherited style also supports reactive changes; visual wrapping never inserts model newlines. Single-line text_input stays unwrapped. Both editor constructors are leaves and reject child views. Unmounting releases input handlers and model subscriptions. Custom headless hosts should call Ui::prepare_frame() before flushing or rendering the scene so editor viewports follow allocated layout.
virtual_list mounts fixed-height rows from a reactive count and an indexed key
lookup. It evaluates keys only for the visible range plus overscan, so scrolling
100,000 items does not allocate or scan 100,000 keys. Ordinary fluent styles set
the viewport; its allocated content size controls the range after layout,
including changes from parent layout or padding.
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(640., 480.);
let offset = ui.signal(0.0);
let list = virtual_list(
offset.clone(), 28.0, 2,
|| 100_000,
|index| index,
|index, key, _cx| {
row().h(28.).child(text_signal(move || {
format!("Item {key}, position {}", index.get())
}))
},
).w(640.).h(480.);
ui.mount(list);
ui.prepare_frame();
offset.set(28_000.0); // Bidirectional logical-pixel position; automatically clamped.Row factories receive the component context, inherited providers and typography.
A key that remains visible keeps its component, local state, subscriptions and
node identity when reordered. Its Signal<usize> updates to the current index.
Rows leaving the overscan range are unmounted; persistent row state belongs in
the model, indexed by key. Visible keys must be unique. The list scrolls with the
wheel and passes scrolling to an enclosing viewport when it reaches an edge.
Headless hosts call Ui::prepare_frame before flushing or inspecting allocated
geometry; native hosts do this automatically.
For rows whose allocated heights change, use variable_virtual_list with a
shared VariableHeights index. The same component context, fluent viewport
styles, retained keys, scrollbars and keyboard navigation apply:
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(640., 480.);
let offset = ui.signal(0.);
let heights = VariableHeights::new(&ui.runtime, 100_000, 28.);
ui.mount(variable_virtual_list(
offset.clone(), heights.clone(), 2,
|index| index,
|index, key, _cx| {
row().w_full().h_full().child(text_signal(move || {
format!("Item {key}, position {}", index.get())
}))
},
).size(640., 480.).scrollbar(true).keyboard_navigation(true));
ui.prepare_frame();
heights.set_height(3, 84.).unwrap();
ui.prepare_frame();Heights must be finite and positive. try_new, set_height and resize
return HeightError for invalid heights, invalid indices or allocation failure;
equal writes do not notify subscribers. Batch related mutations through the
runtime. Point updates preserve the first visible index and its intra-row
position, clamping that position if the row shrinks. Explicit changed scroll
requests win over compensation. Count changes preserve/clamp the pixel offset.
Heights belong to positions: reorder them alongside keyed data. The viewport
does not relocate its anchor by key after a reorder.
With variable_virtual_list, applications supply allocated heights; use
measured_virtual_list below to populate the cache from natural child layout. row_offset, row_at,
row_height and content_height expose indexed geometry. Page keys move by
viewport pixels through the prefix index, progressing at least one row even
when the focused row is taller than the viewport. The native
variable_virtual_list example exercises asynchronous growth and anchoring.
The fixed-height path uses constant metadata space. Variable heights use about 20 bytes per row plus vector capacity and reactive handles; point updates and prefix lookup take O(log n), and geometry iteration takes O(log n + mounted rows). Growing the count initializes only added prefix-tree entries; it does not rebuild the previous rows. Shrinking occasionally releases excess capacity. Coordinates remain finite but have ordinary floating-point precision limits at very large offsets.
measured_virtual_list shares the same child/component architecture while
allocating row heights from their natural layout. Give it a VariableHeights
cache initialized with a reasonable estimate, then compose normal children:
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(640., 480.);
let heights = VariableHeights::new(&ui.runtime, 100_000, 72.);
let message = ui.signal("A streamed message".to_owned());
let read = message.clone();
ui.mount(measured_virtual_list(
ui.signal(0.), heights.clone(), 2, |index| index,
move |_, key, _cx| {
let read = read.clone();
column().w_full().p(8.).gap(4.)
.child(text(format!("Message {key}")))
.child(text_signal(move || read.get()).w_full().text_wrap(true))
},
).size(640., 480.).scrollbar(true).keyboard_navigation(true));
ui.prepare_frame();
message.set("A longer streamed message that reflows naturally within each row.".into());
ui.prepare_frame();The row wrapper measures natural children at the allocated viewport width, including their margins and padding. A layout pass publishes mounted measurements as a batch; equal sizes leave the index alone. Retained rows resize without reconstructing their components. Wrapped text, additional children and native width changes all update mounted heights. Keyboard destinations remain pending until measurement settles, so newly discovered tall rows cannot discard focus.
Unvisited or offscreen rows retain estimates, including after width, font or content changes. Their cached heights refresh when they mount again. Scrollbar extent and pixel offsets are approximate until relevant rows have been measured; this API does not lay out the entire collection to obtain an exact total. Heights are positional, so reorder cached heights with data. Use a separate cache for independently sized list instances; competing width-dependent measurements in one cache can produce a feedback error.
Measured mode applies a one logical pixel minimum to this shared cache at mount time and to later height writes/growth. Empty content therefore occupies one pixel; remove truly hidden items from the model. Applying the floor scans the cache once in O(n); repeated mounts of that cache do not repeat the scan. This prevents undersized estimates from mounting an entire collection. Overestimated rows use geometric discovery, doubling the tentative range until measured rows cover the viewport, then pruning to visible rows plus overscan. Construction may temporarily exceed the final mounted count. Cyclic content/height dependencies still report the bounded layout feedback error described below.
A size-dependent row constructor or disposal callback can repeatedly change its own viewport. Ui::try_prepare_frame returns LayoutFeedbackError after at most 64 stabilization passes instead of hanging. Reentrant preparation defers notifications to the outer call. Correct the model dependency before retrying; application mutations are not rolled back. try_dispatch also reports this error without delivering input against unstable geometry. The convenience prepare_frame and dispatch methods panic with the same diagnostic, while native drawing and input propagate it through Application::run.
use zgui::{widgets::Ui, input::InputEvent};
# fn prepare() -> Result<(), zgui::widgets::LayoutFeedbackError> {
let mut ui = Ui::new(640., 480.);
ui.try_prepare_frame()?;
ui.try_dispatch(InputEvent::PointerMove { x: 10., y: 10. })?;
# Ok(())
# }Keyed reconciliation stages new children before removing previously mounted ones. If a constructor panics, staged nodes, subscriptions and retained resources are disposed, while the prior child set, order and instance-local state remain intact. A later changed key set can retry construction. This guarantees ownership cleanup, not rollback of arbitrary model writes performed by user constructors. Old and staged children coexist temporarily during successful construction; retained size remains proportional to the requested child set. The lower-level KeyedChildren API follows the same initializer-failure policy.
A checkbox binds a Signal<bool> in both directions. Pointer activation on either
its indicator or label, Space, Enter, and accessibility activation toggle the
same value. External writes update its indicator and accessible checked state.
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(400., 200.);
let notifications = ui.signal(false);
let view = ui.mount(
column().gap(12.).text_size(16.).child(
checkbox("Enable notifications", notifications.clone())
.focus(|style| style.border(1.).border_color(rgb(0x5599ff)))
.disabled_style(|style| style.opacity(0.4)),
),
);
notifications.set(true);Checkboxes inherit typography and accept ordinary layout, decoration, reactive,
hover, active, focus, and disabled styles. .disabled_when(...) prevents user
activation while preserving external model updates. An optional .on_click(...)
callback observes the value after the toggle. The label is both visible text
and the accessible name. A checkbox owns its indicator and label; additional
.child(...) or .children(...) calls are rejected at mount. Compose surrounding
content in a row or column. Removing the view disposes its subscriptions and
input listeners.
slider(label, value, min..=max) is a horizontal numeric control backed by a
Signal<f32>. For example, slider("Volume", volume, 0.0..=100.0).w(320.).h(44.)
creates a retained slider with an accessible name. Place a separate text beside
it when a visible label is wanted. A slider owns its rail and thumb and rejects
additional children. Root backgrounds, padding, borders, sizing and interaction
styles use the same fluent API as other views; inherited text color styles the
thumb. The rail uses the theme hover color and its fill uses the theme accent.
The rail follows the actual allocated content box, including reactive dimensions,
parent layout constraints and padding. Pointer dragging captures the pointer;
arrow keys and accessibility increment/decrement actions move by one percent of
the range, and Home/End select its endpoints. Disabled sliders ignore input while
still following external model changes. The range must be finite and increasing;
external NaN values become the minimum and values outside the range clamp to an
endpoint. Internal f64 arithmetic supports the entire finite f32 range.
progress(label, value) displays determinate progress from a Signal<f32> in
0..=1. It exports a read-only accessible progress indicator; the accessible
label is not drawn. Values outside the range clamp to an endpoint and NaN
normalizes to zero, including external writes. The component owns its fill and
rejects extra children. Use a surrounding row or column for a visible label.
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(640., 480.);
let fraction = ui.signal(0.25);
ui.mount(column().gap(8.).text_color(rgb(0x5ea5ff))
.child(text("Downloading"))
.child(progress("Download", fraction.clone())
.w(320.).h(16.).p(2.).bg(rgb(0x232e40))));
fraction.set(0.75);The fill inherits text_color; bg decorates the track. Dimensions and padding
follow allocated layout. A full-width fill translates under a rectangular content
clip, so changing only the value performs no layout work. Equal values remain
idle; resizing updates retained geometry. As with other views, rounded decorates
the root background and does not imply rounded clipping of descendants.
Decoded images are leaf views:
use std::sync::Arc;
use zgui::{compose::prelude::*, image::ImageData, widgets::Ui};
let mut ui = Ui::new(400., 200.);
let source = Arc::new(ImageData::new(1, 1, vec![40, 120, 220, 255]).unwrap());
let current = ui.signal(source);
ui.mount(image_signal("Blue swatch", move || current.get())
.w(120.).h(80.).p(8.).bg(rgb(0x202020)).overflow_hidden());image(label, Arc<ImageData>) retains a fixed source; image_signal(label, source)
tracks reactive reads and replaces the decoded pixels without replacing the view
or its bitmap node. The label is the accessible image description. Additional
children are rejected, including children attached through a component's root.
Decoding and file/network I/O happen outside the core view constructor; callers
supply immutable decoded RGBA data and may share it between views.
An unspecified dimension uses the corresponding source pixel dimension plus
padding. Source dimension changes update intrinsic sizing in either direction.
Explicit sizes and layout constraints allocate the outer box; pixels stretch to
its content box after padding by default. .object_contain() preserves aspect
ratio and centers the full image; .object_cover() fills the content box and
clips the excess. .object_fit(ObjectFit::None) uses natural pixel dimensions,
and ObjectFit::ScaleDown contains the image without upscaling. Fitting changes
bitmap placement inside the allocated box, not the outer intrinsic sizing rule.
Background,
border, shadow, clipping, opacity, blur and interaction styles apply to the outer
retained view. A same-identity source write is idle; replacing pixels with another
image of the same dimensions requires paint without layout. Unmounting releases
owned subscriptions and image references.
scroll(offset) mounts ordinary retained children inside a vertical viewport:
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(500., 400.);
let offset = ui.signal(0.0_f32);
ui.mount(scroll(offset).w(320.).h(240.).p(12.)
.child(column().gap(8.).children((0..20).map(|n| text(format!("Row {n}"))))));The default viewport is 320 × 240 logical pixels. Padding surrounds a clipped
content box, and children stack vertically. Put a styled column or row inside
when content needs its own gaps, alignment or other layout. Root styles decorate
and size the viewport. Content width follows the viewport's allocated content
width; content height comes from ordinary child layout, including reactive text,
conditional views and image sizes. No explicit content-height argument is needed.
The offset follows external writes and clamps when content or viewport dimensions
change. NaN becomes zero and infinities clamp to an endpoint. Wheel input scrolls
this viewport while it can move and bubbles to an outer scroller at an edge;
a delta crossing an edge is consumed by the inner viewport for that event.
Disabled viewports ignore input while still following model updates. Offset-only
changes translate the retained subtree without layout or remounting children.
All children remain mounted offscreen; use virtual_list for large collections
that need bounded row construction and memory.
When keyboard navigation, accessibility or a direct focus request moves focus to
a descendant, an ordinary scroll viewport reveals it with the smallest vertical
movement needed. Nested viewports reveal from the inside outward, including focus
requests inside reactive batches. Padding remains outside the visible content
area. An oversized focused control aligns its nearest edge, or stays put if it
already spans the viewport. Manual wheel and model offset changes remain in
place until a new focus transition; there is no continuous focus snap-back.
This behavior applies to ordinary retained scroll children, not offscreen rows
that a virtual_list has unmounted.
scroll_x(offset) provides the same retained viewport behavior on the horizontal
axis. Its ordinary children stack in a row, their intrinsic widths determine the
scroll extent, and content height follows the allocated viewport height. Use a
nested styled row when content needs gaps or alignment. For example:
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(500., 200.);
let offset = ui.signal(0.0_f32);
ui.mount(scroll_x(offset).w(320.).h(100.).p(8.)
.children((0..8).map(|n| button().w(100.).h(70.).child(text(format!("Card {n}"))))));Horizontal viewports consume finite horizontal wheel deltas; vertical-only wheel
input bubbles to an enclosing vertical scroller. Accessibility scroll actions use
the viewport's declared axis. Focus reveal follows that same axis, including
mixed horizontal/vertical nesting and batched focus requests. Both scroll variants
retain offscreen children and apply offset changes through translation without
layout. Both variants hide their scrollbar by default; .scrollbar(true) adds an
interactive overlay without changing content allocation.
Use .scrollbar(true) on scroll, scroll_x, virtual_list, variable_virtual_list or measured_virtual_list for an interactive
overlay along the content box's trailing edge. The 8-pixel track is inside padding;
the proportional thumb is at least 24 pixels, bounded by the track length. Root
opacity, clipping and disabled state also apply to the scrollbar. The track uses
the theme hover color, the thumb uses the accent color, and keyboard focus changes
the thumb to the theme text color. This opt-in property also passes through
component roots; attaching it to another kind of view is a mount error.
Dragging the thumb captures the pointer and preserves the initial grab position. Clicking elsewhere on the track pages by one viewport. Focused scrollbars support arrow keys (40 logical pixels), Home/End, PageUp/PageDown and accessible numeric value/increment/decrement actions. Offset changes translate the thumb without layout. When overflow disappears the scrollbar stops painting, releases focus and capture, leaves the accessibility tree and permits hits on underlying content; it returns if the extent grows again. Ordinary children and virtual rows keep their existing retention behavior.
Opt into virtual-row keyboard navigation with .keyboard_navigation(true):
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(500., 400.);
let offset = ui.signal(0.0_f32);
ui.mount(virtual_list(offset, 28., 2, || 1_000_000, |index| index,
|_, key, _| text(format!("Item {key}")))
.w(320.).h(240.).keyboard_navigation(true).scrollbar(true));The list viewport and its mounted row wrappers become focusable. From the viewport, ArrowUp/Down enters the first visible row; from a focused wrapper those keys move one row. Home/End jump to the collection endpoints and PageUp/PageDown move by a viewport's row count. The target range is mounted before its wrapper receives focus, including when navigation occurs inside a reactive batch. Work and mounted rows remain bounded by the viewport and overscan; endpoint navigation does not construct or scan every item. Focus paints a theme hover background and a 2-pixel accent marker over the row's leading edge, preserving interior child hit testing.
Interactive descendants retain their own keyboard handling. For example, arrow and Home/End keys in a text editor edit its text instead of navigating the list. Tab continues ordinary focus traversal through the viewport, mounted wrappers and interactive descendants. Accessibility exposes row list-item roles, one-based positions and the total set size. Disabled lists reject navigation.
Reordering a key within the retained range preserves its wrapper, local state and focus while updating its index. If that key moves outside the mounted range, or the focused row is removed or scrolled out, focus returns to the viewport. The framework deliberately does not scan the whole collection to find an offscreen key's new index. A subsequent navigation key chooses a new row. This opt-in method is only valid on virtual lists (including a component that returns one); leaving it off preserves the existing focus behavior of application-created row contents.
Modal and anchored popup panels use ordinary component children:
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(600., 400.);
let open = ui.signal(false);
let launch = open.clone();
let dismiss = open.clone();
ui.mount(column()
.child(button().child(text("Settings")).on_click(move || { launch.set(true); }))
.child(modal("Settings", open).w(360.).gap(12.)
.child(text("These children retain their local state while closed."))
.child(button().child(text("Done")).on_click(move || { dismiss.set(false); }))));modal(label, open) centers its panel in the current window and dims the backdrop.
popover(label, open, anchor) keeps the anchor in ordinary layout and places an
anchored modal popup below it, flipping above when needed and clamping to the
window. Its .child/.children populate the popup panel. Anchor scrolling,
translations, content sizing and window resizing update placement automatically.
Neither constructor requires parent IDs or application scene mutation.
Fluent styles and .id(...) apply to the panel. A private positioning node keeps
user .translate(...) additive. Default panels have 16-pixel padding, a theme
surface background and rounded corners; modal width defaults to 360 pixels and
popup width to 240. Unspecified height follows children. Default maximum dimensions
fit the window with a small margin; explicit maximum-size styles override them.
Use a scroll child when panel content needs scrolling. Panels escape the clipping
and layout constraints around their logical owner through an internal overlay
portal. Provider lookup, lexical slots, local state and resource ownership remain
with that owner. ViewHandle::node() identifies the logical owner; panel IDs
resolve through the same handle's find method.
Both kinds trap focus while open, initially preferring an enabled child control.
An empty panel provides fallback focus. Escape dismisses the top active popup;
backdrop clicks dismiss by default, configurable with .dismiss_on_backdrop(false).
Blank panel space does not count as backdrop, and dismissal does not activate a
control beneath it. Closing restores eligible prior focus. Nested dialogs close
with their parent. Sibling dialogs stack in opening order; closing or removing a
lower sibling preserves the upper dialog and repairs its eventual focus return.
Disabling a logical ancestor closes its open portal and resets the open signal;
reenabling the ancestor requires an explicit new open request.
Closed panels retain children, state and subscriptions while leaving paint, input
and accessibility inactive. Removing the logical owner removes its portal,
subscriptions and focus scope immediately. Constructor failures clean up staged
portals. Replacing an application tree can replace an open dialog without leaving
a stale scope. These popovers are focus-trapping dialog popups. Use menu for action menus
with menu-specific roles and keyboard item navigation.
Action menus share the retained portal lifecycle:
use zgui::{compose::prelude::*, widgets::Ui};
let mut ui = Ui::new(600., 400.);
let open = ui.signal(false);
let show = open.clone();
let saved = ui.signal(false);
ui.mount(menu("Document actions", open,
button().child(text("Actions")).on_click(move || { show.set(true); }))
.p(4.).gap(2.)
.child(menu_item("Save").on_click(move || { saved.set(true); }))
.child(menu_item("Export").disabled(true)));menu(label, open, anchor) exposes an accessible menu, and menu_item(label)
creates a focusable action with its own visible label. Items use ordinary fluent
styles and .on_click; additional children on an item are rejected. Components,
providers, conditional views and keyed collections may produce menu items.
Activating an item closes its enclosing menu chain and restores focus before
running the callback, so the action may safely open another dialog. Trigger
semantics retain their existing role/name and expose a menu popup with expanded
state. Disabled items are skipped by this framework's navigation policy.
Opening prefers the first enabled item. Up/Down wrap through enabled items; Home/End choose the first/last item. Printable keys perform case-insensitive prefix search with a 750-millisecond continuation window and a 256-byte UTF-8 storage bound; repeated letters cycle matching items. Escape closes the current menu. Tab/Shift-Tab close the menu chain and continue ordinary traversal from its trigger, including within an enclosing dialog. Interactive non-item children keep their own editing keys. If a focused keyed item disappears, menu navigation remains available through the active scope.
Long menus automatically scroll within the available window height. Wheel input
moves retained content and keyboard focus reveals the selected item. Width follows
the panel's allocated content width, and reactive gaps/alignment reach the menu
content. Menus remain vertically arranged; row-style layout is not a menubar API.
Use submenu("More", child_open).children(...) within a menu for a cascading
menu. Its retained trigger fills the parent row and displays a trailing chevron.
Fluent styles and .id(...) describe the child panel; .trigger_id(...) identifies
the owned trigger. .disabled(...) and .disabled_when(...) disable the trigger
and close an open child. Ordinary components, providers and keyed children work
inside both levels.
Right, Enter or clicking the trigger opens its child without closing the parent;
Left or Escape closes just the child and restores its trigger. Opening a sibling
closes the previous child. A child action closes the whole menu chain before its
callback. Clicking an ancestor item or sibling trigger works in one gesture;
clicking outside all menus closes the chain and consumes the gesture. The optional
.dismiss_on_backdrop(false) keeps outside clicks from closing the menu. Submenus
open beside the full trigger row, flip left when necessary, and follow scrolling,
transforms and window resizing. Accessibility exposes child menus beneath their
logical parent menu despite their separate portal placement. Opening is explicit
by click or keyboard; hovering does not automatically open a child.
The WAI-ARIA menu pattern was used as a keyboard/semantics reference. zgui's navigation skips disabled items; the APG web pattern keeps disabled items focusable. This native API does not claim full APG conformance.
Attach .on_event(|event| { ... }) to any view for pointer, wheel, keyboard,
text/IME, focus and activation events. The callback receives &mut EventContext;
inspect its event and phase (Capture, Target, or Bubble). Ancestors receive
capture and bubble phases; the hit/focused root receives the target phase. Portal
panels route through their physical overlay rather than their logical owner.
use zgui::{compose::prelude::*, input::{EventPhase, InputEvent, Key}, widgets::Ui};
let ui = Ui::new(400., 300.);
let value = ui.signal(0);
let counter = value.clone();
let control = div().focusable(true).on_event(move |event| {
if event.phase == EventPhase::Target
&& matches!(event.event, InputEvent::KeyDown { key: Key::ArrowRight, .. })
{
counter.update(|value| *value += 1);
event.prevent_default();
}
});Repeated .on_event(...) calls append listeners in declaration order. Component,
provider and slot wrappers append their listeners after those declared by the
underlying view, preserving the component's behavior. Listeners live with the
mounted root and are released on unmount, including keyed/conditional removal.
Disabled roots and descendants follow the dispatcher's inherited disabled policy.
prevent_default() cancels default actions without stopping propagation;
stop_propagation() stops travel to another node, while
stop_immediate_propagation() also stops remaining listeners on the current node.
Built-in handlers follow user listeners, including editor handlers. A prevented
activation does not invoke on_click, and a prevented editor input event does not
edit the value. Ancestor capture can intercept input before it reaches a child. focus(), capture_pointer() and
release_pointer() request the dispatcher's corresponding operations.
Prevented wheel, navigation, slider and scrollbar actions also leave their model
unchanged. Focus/blur notifications and pointer-release/cancel cleanup still run;
preventing a release must not leave a control dragging or visually pressed.
On desktop, preventing KeyDown also suppresses the printable Text event
associated with that native keystroke and its clipboard shortcut default. Raw
keyboard cancellation does not suppress a separate IME commit; handle
ImeCommit explicitly when needed. Ordinary editor Space inserts through Text,
while its key release suppresses button activation. Focus and pointer-release
cleanup still run after default prevention.
An event listener alone does not make a passive view focusable. .focusable(true)
opts its root into focus and normal Tab traversal, without changing its semantic
role; .focusable(false) overrides a control's default focusability. A wrapper's
explicit focusability overrides the inner view. Use .focus(...) to provide a
visible focus style for custom controls. Input handlers do not replace a custom
control's responsibility to expose suitable accessibility semantics.
Inherited .line_height(pixels) applies to labels, boxed text and editable text.
Use .line_height_normal() to reset an inherited explicit value; ordinary sparse
patches preserve the inherited metric. Reactive changes update geometry while
retaining component state and editor selection. See the line-height styling
contract for exact pixel spacing and clipping behavior.
Component disposal detaches scene ownership before listener captures and retained resources are destroyed. Destructors can remove the same subtree or its ancestor without leaving stale scene IDs. Task completion checks and task-guard destruction run outside the component task-list borrow, so a custom executor can reenter the task capability; new live guards remain owned until unmount.