Writing UI
rsx!, widgets and events
If you have written Dioxus before, almost everything here is familiar: components, hooks,
signals and rsx! work as they do anywhere else. What is specific to this project
is the closed widget schema, the way key events are consumed, and the rule that text
composition never crosses the boundary.
The shape of an app
An application is a function returning Element plus a call to
launch. The prelude brings in the widgets, the hooks and the macros.
use dioxus_compose::prelude::*;
fn app() -> Element {
let mut count = use_signal(|| 0i32);
rsx! {
Column {
fill_max_width: true,
Text { text: format!("count: {}", count()) }
Button {
text: "increment",
on_click: move |_| count += 1,
}
}
}
}
fn main() {
dioxus_compose::launch(app);
}
launch is shorthand for LaunchBuilder::new().launch(app). The
builder carries one setting today, the loop mode:
LaunchBuilder::new()
.with_mode(LoopMode::Renderer) // desktop and iOS: Rust main drives the renderer
.launch(app);
LoopMode::Renderer is the default and the only mode that runs today.
LoopMode::Platform, where Android or the browser owns the loop and calls into
the Host, is defined in the protocol but has no implementation yet.
The widget set
The schema is closed on purpose. The renderer interprets exactly these eight widgets; a type
it does not recognise produces a ProtocolError event rather than a crash.
Adding a widget means extending the Rust schema, the Kotlin interpreter and the codegen
together.
| Widget | Properties | Notes |
|---|---|---|
Column | fill_max_width, fill_max_height, children | Vertical stack. |
Row | fill_max_width, fill_max_height, children | Horizontal stack. |
Box | fill_max_width, fill_max_height, children | Overlay container. See the naming caveat below. |
Text | text | Takes anything that converts into String. |
TextField | placeholder, enabled, multiline | Uncontrolled. Has no value property, by design. |
Button | text, enabled | enabled defaults to true. |
Spacer | width, height | Both f32, both default to 0.0. |
LazyColumn | item_count, buffer, key_of, item | A windowed list. Host side works; the renderer still draws it as a Column. See Lists & streaming. |
dioxus_compose::Box, not Box
The rsx! expansion in dioxus-core 0.7 refers to an unqualified
Box<T> internally. Importing the Compose Box through the
glob prelude would shadow std::boxed::Box and break that expansion, so the
prelude deliberately leaves it out. Use the qualified path until upstream qualifies its own.
rsx! {
dioxus_compose::Box {
fill_max_width: true,
Text { text: "qualified Box" }
}
}
Modifiers
Modifiers are serialised as a list of values, for example
[Padding(16), FillMaxWidth, Background(argb), Clickable(handler_id)], and the
renderer rebuilds them into a Compose Modifier chain. The variants that exist on
the wire today are Empty, Padding, FillMaxWidth,
FillMaxHeight, Width, Height, Size,
Background and Clickable.
Only fill_max_width and fill_max_height are exposed as
rsx! attributes, on Column, Row and Box.
Padding, explicit sizes, background colour and Clickable exist in the protocol
and the renderer, but there is no attribute that emits them yet. Treat the remaining
variants as the shape the API is growing into, not as something you can call.
Event handlers
Handlers are ordinary Rust closures. They are called synchronously: the renderer calls into the Host on its UI thread, your handler runs, the resulting diff is computed, and the mutation batch travels back in the same call. Nothing is queued.
| Handler | Payload | On |
|---|---|---|
on_click | () | Button |
on_value_change | String | TextField |
on_submit | String | TextField |
on_focus_lost | () | TextField |
on_key_down | KeyEvent | TextField |
on_range_requested | RangeRequest | LazyColumn, handled inside the component; you pass item and key_of instead |
Because handlers run on the UI thread, they must stay cheap. Anything that blocks, a PTY read, a network call, a file, belongs on a Host worker thread. Workers update signals; the Host asks the renderer for a frame on its own. You never call a boundary function yourself.
Event consumption: Enter versus Shift+Enter
A chat input needs Enter to send and Shift+Enter to insert a
newline. That requires telling the renderer, synchronously, whether the key was handled, the same idea as preventDefault() on the web or
PointerInputChange.consume() in Compose.
Dioxus 0.7 handlers return nothing, so consumption is marked on the event object instead. The
boundary reads that mark afterwards and returns it to the renderer, where
Modifier.onKeyEvent reports it as handled.
TextField {
placeholder: "Write a message",
multiline: true,
on_value_change: move |value| draft.set(value),
on_key_down: move |event: KeyEvent| {
if event.key() == Key::Enter && !event.shift_key() {
let message = draft().trim().to_owned();
if !message.is_empty() {
messages.write().push(message);
draft.set(String::new());
}
event.consume(); // the renderer's onKeyEvent returns true
}
}
}
KeyEvent exposes key(), shift_key(),
ctrl_key(), alt_key(), meta_key(), plus
consume() and consumed(). The Key enum currently has a
single variant, Key::Enter, the only key the M0 schema carries. More keys are a
schema addition, not a code change in your app.
While an IME is composing, the renderer does not send key events to the Host at all. An Enter pressed mid-composition means "commit this syllable", not "submit". If that rule were broken, pressing Enter while typing Korean would submit the message with the composing syllable missing. Your handler never sees those keystrokes, which is why the simple condition above is correct rather than merely convenient.
The uncontrolled TextField
TextField has no value property. The edit buffer, the selection and
the composition state belong to the renderer. Rust learns about changes through events:
on_value_change, a notification, debounced, of the current text.on_submit, a committed value.on_focus_lost, the field lost focus.
This is not a simplification; it is the central design decision. Round-tripping in-progress text through the Host means that every keystroke of a Korean syllable would leave the renderer, be echoed back, and reset the composition. Hangul is assembled from jamo as you type, so a reset mid-syllable destroys what you were typing. The same applies to Japanese and Chinese input. Keeping the buffer in Kotlin makes the failure impossible rather than rare.
When the Host genuinely has to change the text, clearing an input after send, restoring a
draft, it uses an explicit SetText(node_id, text, selection) command, and the
renderer defers applying it until any composition finishes. On the Rust side that is
Host::set_text, part of the boundary surface rather than something you reach for
from a component. In component code, the usual way to clear a field is to give it a new
identity, exactly as you would in Dioxus on any other renderer.
Where state lives
| State | Owner | Why |
|---|---|---|
| Application and domain state | Rust (signals) | It is your program. Dioxus reconciles it into mutations. |
| Text being edited, IME composition | Kotlin | Composition must never round-trip (see above). |
| Scroll position | Kotlin | UI-local; it would be a frame behind if Rust owned it. |
| Focus | Kotlin | Same reason, and the platform already owns focus. |
| Animation progress | Kotlin | Runs on the Compose frame clock. |