Beginner 10 min

Choose the right interaction API

Match clicks, edits, continuous changes, commits, selections, and overlay state to the smallest typed callback.

Pick the callback that names the intent

Start with the widget method that describes the domain event. Direct handlers already unify pointer, touch, keyboard, and accessibility activation, so application code should not decode UiEvent for ordinary controls.

Widget APIUse it forContext helper
on_clickA button or one-shot action was activatedcallback
on_inputEach new controlled text value while editinginput_callback
on_submitA text value or form was deliberately submittedsubmit_callback or callback
on_changeEach next checkbox, switch, collection, color, or range valuevalue_callback
on_commitThe final value after a continuous slider interactionvalue_callback
on_selectA stable option, index, date, row, page, or node was chosenvalue_callback
on_open_changeAn overlay or disclosure requests open or closed statevalue_callback
on_action / on_activate / on_navigateA stable command, item, cell, or destination was invokedvalue_callback
listener + Element.onCapture, bubbling, delegation, shortcuts, or default prevention is intentionallistener or event_handler

Keep values controlled

A handler reports intent or a next value. Store it in the model and pass it back on the next render. callback, value_callback, input_callback, and submit_callback invalidate the current presentation automatically.

The live example uses on_change to update its preview and on_commit to record the final slider value. The button uses on_click because saving is a discrete action.

For document-sized buffers, Astra Editor uses on_edit so each keystroke delivers only the changed UTF-8 range instead of cloning the complete source file.

src/view.rs
Slider::new("volume", "Preview volume", self.volume, range)
    .on_change(cx.value_callback(|app, value| {
        app.volume = value;
    }))
    .on_commit(cx.value_callback(|app, value| {
        app.volume = value;
        app.saved_volume = value;
    }))
    .build(theme)

Reach for the event layer only when you need it

Use event_handler or value_event_handler when a callback must inspect the routed event, stop propagation, request focus, or issue commands. These advanced forms do not invalidate automatically, so call cx.notify() after changing visible state.

Use Context::listener with Element::on for deliberate ancestor delegation, capture, passive or one-shot listeners, application-wide shortcuts, and custom controls. Typed widget callbacks remain additive and still travel through the same retained event pipeline.

Reference files

These are the implementation and guide files used for this chapter.

docs/widgets/interaction-api.mddocs/simplified-api.mdcrates/argui-runtime/src/model/handler.rscrates/argui-widgets/src/slider.rsapp_examples/astra-editor/src/app/editor.rsapp_examples/astra-editor/README.md
Compiled example

See continuous change, final commit, and click callbacks separately

The Rust file below is imported verbatim by this page and compiled into the WebAssembly application running underneath it.

Open exact source
app_examples/docs-examples/src/examples/interaction_api.rs
use argui::{
    paint::{Border, CornerRadii, PaintStyle, QuadStyle},
    runtime::{Context, Render},
    text::TextStyle,
    ui::{
        AlignItems, Axes, ContinuousValuePhase, Element, EventType, JustifyContent, Overflow,
        RangeHandlerValue, Sides, StateSelector, StylePatch, StyleTransition, TextEdit,
        ValueHandler, VisualState, auto, length, percent, property,
    },
    widgets::{
        Button, RANGE_SCOPE, RangeAxis, RangeBehavior, RangeConfig, RangeDirection, RangePart,
        TextArea, WidgetTheme, default_theme,
    },
};

struct BigSlider {
    behavior: RangeBehavior,
    change_handlers: Vec<ValueHandler<f32>>,
    commit_handlers: Vec<ValueHandler<f32>>,
}

impl BigSlider {
    /// Creates the large custom slider used by this interaction example.
    fn new(key: &str, label: &str, value: f32, config: RangeConfig) -> Self {
        Self {
            behavior: RangeBehavior::new(key, label, value, config),
            change_handlers: Vec::new(),
            commit_handlers: Vec::new(),
        }
    }

    /// Adds a callback for each intermediate value produced while interacting.
    fn on_change(mut self, handler: ValueHandler<f32>) -> Self {
        self.change_handlers.push(handler);
        self
    }

    /// Adds a callback for the final value produced by an interaction.
    fn on_commit(mut self, handler: ValueHandler<f32>) -> Self {
        self.commit_handlers.push(handler);
        self
    }

    /// Builds a fully custom visual while preserving the standard range contract.
    fn build(self, theme: &WidgetTheme) -> Element {
        let ratio = self.behavior.ratio();
        let ticks = Element::row((0..9).map(|_| {
            Element::container([])
                .width(length(1.0))
                .height(length(12.0))
                .paint_style(PaintStyle::new(
                    QuadStyle::solid(theme.foreground).opacity(0.12),
                ))
                .when(
                    StateSelector::scope(RANGE_SCOPE, VisualState::Hovered),
                    StylePatch::new().set(property::Opacity, 0.42),
                )
                .when(
                    StateSelector::scope(RANGE_SCOPE, VisualState::Pressed),
                    StylePatch::new().set(property::Opacity, 0.7),
                )
                .transition(StyleTransition::default())
        }))
        .absolute(Sides {
            left: length(18.0),
            right: length(18.0),
            top: auto(),
            bottom: auto(),
        })
        .height(percent(1.0))
        .align_items(AlignItems::CENTER)
        .justify_content(JustifyContent::SPACE_BETWEEN);
        let fill = Element::container([])
            .absolute(Sides::length(0.0))
            .width(percent(1.0))
            .height(percent(1.0))
            .background(theme.primary.with_alpha(0.26));
        let thumb = Element::container([])
            .absolute(Sides {
                left: auto(),
                right: length(0.0),
                top: length(7.0),
                bottom: length(7.0),
            })
            .width(length(3.0))
            .background(theme.primary)
            .radius(CornerRadii::all(2.0));
        let progress = Element::container([fill, thumb])
            .absolute(Sides {
                left: length(0.0),
                right: auto(),
                top: length(0.0),
                bottom: length(0.0),
            })
            .width(percent(ratio));
        let track = self.behavior.decorate(
            RangePart::Track,
            Element::container([progress, ticks])
                .width(percent(1.0))
                .height(percent(1.0)),
        );
        let mut control = self.behavior.decorate(
            RangePart::Control,
            Element::container([track])
                .absolute(Sides::length(0.0))
                .width(percent(1.0))
                .height(percent(1.0)),
        );
        let config = self.behavior.config();
        for (phase, handlers) in [
            (ContinuousValuePhase::Change, &self.change_handlers),
            (ContinuousValuePhase::Commit, &self.commit_handlers),
        ] {
            let source = RangeHandlerValue::new(
                self.behavior.value(),
                config.minimum,
                config.maximum,
                config.step,
                config.axis == RangeAxis::Vertical,
                config.direction == RangeDirection::Reverse,
                phase,
            );
            for handler in handlers {
                for event in [
                    EventType::Key,
                    EventType::Gesture,
                    EventType::SemanticAction,
                ] {
                    control =
                        control.on(handler.direct_listener(event).range_handler_value(source));
                }
            }
        }
        self.behavior.decorate(
            RangePart::Root,
            Element::container([
                control,
                Element::text(format!("{:.0}%", self.behavior.value()))
                    .absolute(Sides {
                        left: auto(),
                        right: length(14.0),
                        top: length(18.0),
                        bottom: auto(),
                    })
                    .text_style(TextStyle {
                        color: theme.foreground,
                        weight: 700,
                        ..TextStyle::default()
                    })
                    .semantic_hidden(true),
            ])
            .width(percent(1.0))
            .height(length(58.0))
            .background(theme.card)
            .border(Border::all(1.0, theme.border))
            .radius(CornerRadii::all(12.0))
            .overflow(Axes {
                x: Overflow::Hidden,
                y: Overflow::Hidden,
            }),
        )
    }
}

pub struct Example {
    live_volume: f32,
    committed_volume: f32,
    saves: u32,
    source: String,
    edit_count: u32,
    last_edit: String,
}

impl Default for Example {
    fn default() -> Self {
        Self {
            live_volume: 35.0,
            committed_volume: 35.0,
            saves: 0,
            source: "fn main() {\n    println!(\"fast edits\");\n}".into(),
            edit_count: 0,
            last_edit: "No edits delivered yet".into(),
        }
    }
}

impl Render for Example {
    fn render(&mut self, cx: &mut Context<Self>) -> Element {
        let themes = default_theme(cx.environment());
        let theme = themes.resolve(cx.environment().color_scheme);
        let label = |value: String, color, weight| {
            Element::text(value).text_style(TextStyle {
                color,
                weight,
                ..TextStyle::default()
            })
        };

        let live_value = cx.value_callback(|app, value| app.live_volume = value);
        let committed_value = cx.value_callback(|app, value| {
            app.live_volume = value;
            app.committed_volume = value;
        });
        let save = cx.callback(|app| app.saves = app.saves.saturating_add(1));
        let edit_source = cx.edit_callback(|app, edit: TextEdit| {
            let summary = format!(
                "bytes {}..{} → {} byte(s)",
                edit.range.start,
                edit.range.end,
                edit.replacement.len()
            );
            if edit.apply_to(&mut app.source).is_ok() {
                app.edit_count = app.edit_count.saturating_add(1);
                app.last_edit = summary;
            } else {
                app.last_edit = "Rejected stale edit".into();
            }
        });

        Element::column([
            label("Choose callbacks by intent".into(), theme.foreground, 700),
            label(
                "on_change previews continuously; on_commit stores the final value.".into(),
                theme.muted_foreground,
                450,
            ),
            Element::column([
                BigSlider::new(
                    "volume",
                    "Preview volume",
                    self.live_volume,
                    RangeConfig::new(0.0, 100.0, 1.0),
                )
                .on_change(live_value)
                .on_commit(committed_value)
                .build(theme),
                label(
                    format!("Live value: {:.0}%", self.live_volume),
                    theme.foreground,
                    600,
                ),
                label(
                    format!("Committed value: {:.0}%", self.committed_volume),
                    theme.muted_foreground,
                    500,
                ),
            ])
            .padding(Sides::length(18.0))
            .gap(10.0)
            .background(theme.card)
            .border(Border::all(1.0, theme.border))
            .radius(CornerRadii::all(10.0)),
            Element::column([
                label(
                    "Use deltas for document-sized text".into(),
                    theme.foreground,
                    650,
                ),
                label(
                    "on_edit sends one UTF-8 range replacement instead of cloning the whole value."
                        .into(),
                    theme.muted_foreground,
                    450,
                ),
                TextArea::new(
                    "incremental-source",
                    &self.source,
                    "Paste or type Rust…",
                    theme.input(),
                )
                .on_edit(edit_source)
                .build()
                .height(length(118.0)),
                label(
                    format!(
                        "Incremental edits: {} · {}",
                        self.edit_count, self.last_edit
                    ),
                    theme.muted_foreground,
                    500,
                ),
            ])
            .padding(Sides::length(18.0))
            .gap(10.0)
            .background(theme.card)
            .border(Border::all(1.0, theme.border))
            .radius(CornerRadii::all(10.0)),
            Button::new("save", "Save preset", theme.button())
                .on_click(save)
                .build(),
            label(
                format!("Saved {} time(s)", self.saves),
                theme.muted_foreground,
                500,
            ),
        ])
        .width(percent(1.0))
        .height(percent(1.0))
        .padding(Sides::length(28.0))
        .gap(16.0)
        .background(theme.background)
    }
}