Text Input
Introduction
WIP. The text-input runtime is wired but several pieces are still missing — macOS IME / CJK support is incomplete and APIs may change.
There are two ways to make text editable:
- The
TextInputwidget: a single-line input field with platform-native styling, placeholder, focus border, and blinking cursor. - The
contenteditableflag on any DOM node: for code-editor lines, multi-line text areas, or rich-text spans.
Both share the same event plumbing. A focused, editable node receives Focus(TextInput) and Focus(VirtualKeyDown) events. IME composition is handled by the platform shell; from your callback's point of view, you receive the produced character once composition commits.
The TextInput widget
TextInput::create() returns a default-styled widget; dom() consumes it into a Dom.
use azul::prelude::*;
let input = TextInput::create()
.with_placeholder("Your name".into())
.with_text("Felix".into())
.dom();
The produced subtree is a focusable container with a placeholder text node, a label text node holding the buffer, and a cursor.
Wiring callbacks
Three optional callbacks fire in addition to the default key/text handlers:
with_on_text_input(data, cb): fires for every accepted character. Signature:extern "C" fn(RefAny, CallbackInfo, TextInputState) -> OnTextInputReturn.with_on_virtual_key_down(data, cb): fires for non-text keys (arrows, backspace). Same signature.with_on_focus_lost(data, cb): fires when focus moves elsewhere. Signature:extern "C" fn(RefAny, CallbackInfo, TextInputState) -> Update.
use azul::prelude::*;
struct Form { name: String }
extern "C" fn on_input(
mut data: RefAny,
_info: CallbackInfo,
new_state: TextInputState,
) -> OnTextInputReturn {
if let Some(mut form) = data.downcast_mut::<Form>() {
form.name = new_state.get_text();
}
OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes }
}
# let form = RefAny::new(Form { name: String::new() });
let dom = azul::widgets::TextInput::create()
.with_on_text_input(form, on_input)
.dom();
OnTextInputReturn::valid is the gate that lets you reject a character (e.g. „only digits“). Returning TextInputValid::No rolls back the edit before mutating the DOM. OnTextInputReturn::update follows the usual Update semantics from Events.
TextInputState
TextInputState carries:
text: U32Vec(characters asu32, FFI-friendly).placeholder: OptionString.max_len: usize.cursor_pos: usize.selection: TextInputSelection.
TextInputState::get_text() reconstructs a normal Rust string from the buffer.
The contenteditable flag
NodeData::set_contenteditable(true) (or Dom::with_contenteditable(true) for builders) marks any node as an editable region:
use azul::prelude::*;
let line = Dom::create_div()
.with_contenteditable(true)
.with_tab_index(TabIndex::Auto);
Once the node has focus and the contenteditable bit is set, every printable key press the OS produces is delivered to that node. The platform shell records the edit, the framework computes the changeset, and the renderer consumes it.
Edits avoid a full re-layout
Text edits run through an incremental display-list path that bypasses the user's layout_callback. The motivation: if every keystroke triggered a full DOM rebuild, the layout callback would return a fresh DOM with the original text and overwrite the edit.
The framework distinguishes three levels of post-event work:
- Redraw only: scroll offsets, GPU transforms. Layout callback doesn't run.
- Display-list update: text edits, incremental relayout. Layout callback doesn't run.
- Full regeneration:
Update::RefreshDom, focus changes that move the DOM. Layout callback runs.
Returning Update::RefreshDom from a text-input callback forces the third path. Do this only when the edit changes something the layout callback needs to see, such as adding a new sibling node or hiding a section.
The double-update pattern
Because the layout callback is bypassed during a text edit, your application model and the live DOM can drift out of sync if you only write to one of them. The double-update pattern keeps both in sync:
- Inside
on_text_input, write the new text to yourRefAnymodel so a future re-layout reads the right value. - Update the node's dataset with
Dom::with_dataset(set during layout) and aDatasetMergeCallbackso the in-place display list patch reflects the edit.
The widget's internal callbacks already do this for you. If you write a custom contenteditable that maintains its own buffer, mirror both.
Reading edits inside a callback
CallbackInfo::get_text_changeset() returns the current PendingTextEdit:
use azul::prelude::*;
extern "C" fn on_key(_data: RefAny, info: CallbackInfo) -> Update {
if let Some(edit) = info.get_text_changeset() {
let _inserted: &str = edit.inserted_text.as_str();
}
Update::DoNothing
}
CallbackInfo::change_node_text(node_id, text) is the corresponding write side. It replaces the text content of a node and queues an incremental display-list update.
Default actions
These keystrokes are handled by the framework after every callback returns, unless a callback called info.prevent_default():
- Backspace: delete the grapheme before the cursor.
- Delete: delete the grapheme after the cursor.
- Left/Right arrow: move the cursor by one grapheme.
- Home/End: move the cursor to line start/end.
- Ctrl+Home / Ctrl+End: move the cursor to document start/end.
- Ctrl+A: select all (scoped to the focused contenteditable).
- Escape: collapse selection.
Suppress with info.prevent_default() to override. The rest of the callback chain still runs (W3C semantics; see Events).
Where it goes wrong
- Edit lost after
RefreshDom. The layout callback rebuilt the DOM with stale text. Either keep the contenteditable subtree out of the rebuild, or apply the double-update pattern so the rebuild reads from the same model the edit wrote to. max_lenis not enforced. Add a length check in youron_text_inputand returnTextInputValid::Nowhen the buffer is full.- First click positions the cursor at the start. The cursor is initialised at end-of-text on focus; the first click can race with focus acquisition. Subsequent clicks behave normally.
TextInputSelection/TextInputSelectionRangeare not yet wired through the default callbacks. Multi-node selection across nodes goes through the cross-DOM selection model; see Text Selection.
Coming Up Next
- Text Selection — Selection ranges, cursors, and copy/paste
- Scrolling — Scroll containers, drag-and-drop, hit testing
- Events — Callbacks, event filters, and how state triggers relayout