Skip to content

Text Editing

TextEditor and TextInput are the text buffer models that power TextArea and Input widgets respectively. They manage cursor position, selection, undo/redo history, and keystroke handling. Use them in component state to maintain editing context across renders.

rust
use tui_lipan::prelude::*;

TextEditor (multi-line)

A multi-line text editor with selection support. The cursor is stored as a byte index into the UTF-8 string and is always kept on a character boundary. Selection is represented by an optional anchor position.

rust
let mut editor = TextEditor::new("Hello\nWorld");
// Cursor starts at position 0 (beginning of text)

// Also available via Default
let editor = TextEditor::default(); // empty text, cursor at 0

Accessors

MethodReturnsDescription
text()&strCurrent text content
cursor()usizeCursor byte position
anchor()Option<usize>Selection anchor (if active)
selection()Option<(usize, usize)>Ordered (start, end) range if selection exists
selected_text()Option<&str>Text within the selection

Editing

MethodDescription
insert_char(ch)Insert a character at cursor (replaces selection if active)
insert_str(s)Insert a string at cursor (replaces selection if active)
backspace()Delete character before cursor or delete selection
delete()Delete character after cursor or delete selection
delete_word_left()Delete word before cursor
delete_word_right()Delete word after cursor
clear()Clear the full buffer as an undoable replace edit; returns true when text, cursor, or selection changed

Cursor movement

MethodDescription
move_left() / move_right()Move cursor by one character, clears selection
move_up() / move_down()Move cursor by one line (grapheme-column aware)
move_word_left() / move_word_right()Move cursor by one word
move_home() / move_end()Move to start/end of current line

Selection

MethodDescription
select_left() / select_right()Extend selection by one character
select_up() / select_down()Extend selection by one line
select_word_left() / select_word_right()Extend selection by one word
select_home() / select_end()Extend selection to start/end of current line
select_all()Select all text
clear_selection()Remove selection (keep cursor position)

Sync and setters

MethodDescription
set_text(s)Replace entire text content (clears history)
set_cursor(pos)Set cursor position (clears selection)
set_cursor_keep_anchor(pos)Set cursor position (preserves anchor for selection)
set_anchor(pos)Set anchor position directly

Undo / Redo

TextEditor maintains an undo history (up to 1000 entries by default). Consecutive edits of the same kind are merged into logical groups for natural undo behavior. clear() is recorded as a replace edit, so undo() restores the previous text, cursor, and selection.

MethodDescription
can_undo()Whether undo is available
can_redo()Whether redo is available
undo()Undo last edit group
redo()Redo last undone edit group
clear_history()Clear all undo/redo history

Keystroke handling

handle_key processes a KeyEvent using the default keymap and returns whether the editor state changed:

rust
let changed = editor.handle_key(key_event);
if changed {
    // editor state was modified
}

Supported keys include character insertion, arrow keys, word movement (Ctrl+Left/Right), Home/End, Backspace/Delete, word deletion (Ctrl+Backspace/Ctrl+Delete), Enter (newline), and undo/redo (Ctrl+Z/Ctrl+Shift+Z).

Clipboard operations (copy/cut/paste) are handled at the widget layer, not by handle_key. TextEditor itself remains plain and keymap-driven; opt-in Vim motions are a TextArea widget-layer behavior enabled with TextArea::vim_motions(true), including Visual-mode cursor/anchor updates for selections.


TextInput (single-line)

A single-line text input model. Same selection and undo/redo model as TextEditor, but constrains input to a single line.

rust
let mut input = TextInput::new("initial value");
// Cursor starts at end of text (unlike TextEditor which starts at 0)

let input = TextInput::default(); // empty text, cursor at 0

Differences from TextEditor

BehaviorTextInputTextEditor
Initial cursorEnd of textStart of text
NewlinesReplaced with space on insertPreserved
Home / EndMove to start/end of entire stringMove to start/end of current line
Vertical movementNot supportedmove_up / move_down

Additional methods

MethodDescription
clear()Clear all text content
delete_to_start()Delete from cursor to start of text (or delete selection)
delete_to_end()Delete from cursor to end of text (or delete selection)

All methods from TextEditor (accessors, cursor movement, selection, undo/redo) are available on TextInput as well, except for vertical movement methods.


Integration with widgets

TextEditor and TextInput are typically stored in component state and passed to TextArea and Input widgets using bound constructors that preserve cursor position and selection across rerenders:

TextArea + TextEditor

rust
struct State {
    editor: TextEditor,
}

fn create_state(&self, _props: &Self::Properties) -> Self::State {
    State {
        editor: TextEditor::new(""),
    }
}

fn view(&self, ctx: &Context<Self>) -> Element {
    TextArea::bound(&ctx.state.editor)
        .on_change(ctx.link().callback(|ev: TextAreaEvent| Msg::EditorChanged(ev)))
}

fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
    match msg {
        Msg::EditorChanged(ev) => {
            ctx.state.editor.set_text(ev.value.to_string());
            ctx.state.editor.set_cursor(ev.cursor);
            ctx.state.editor.set_anchor(ev.anchor);
            Update::full()
        }
    }
}

Selections remain visible by default when a TextArea loses focus through keyboard or programmatic focus changes, matching DocumentView. Use show_selection_when_unfocused(false) to opt out, and set unfocused_selection_style(...) or inherit_unfocused_selection_style() to tune that inactive highlight. Inherited/default TextArea selection styles resolve against theme.text_selection, not the list/item theme.selection role.

Input + TextInput

rust
struct State {
    input: TextInput,
}

fn create_state(&self, _props: &Self::Properties) -> Self::State {
    State {
        input: TextInput::new(""),
    }
}

fn view(&self, ctx: &Context<Self>) -> Element {
    Input::bound(&ctx.state.input)
        .placeholder("Type here...")
        .on_change(ctx.link().callback(|ev: InputEvent| Msg::InputChanged(ev)))
}

fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
    match msg {
        Msg::InputChanged(ev) => {
            ev.apply_to(&mut ctx.state.input);
            Update::full()
        }
    }
}

Input::bound(&state) reads the value, cursor, and anchor from the TextInput state bundle, so cursor position and text selection are preserved across rerenders. The InputEvent::apply_to helper writes all three fields back in one call.

Avoid Input::new(value) for editable inputs — it always places the cursor at the end, so left/right arrow keys and selection will appear broken after each keystroke.

TextArea clear shortcuts use the widget's internal editor and emit the normal change/edit callbacks. Prefer TextArea::clear_bindings(bindings) or the keymap clear action over replacing the controlled value externally when you want undo to restore the pre-clear buffer.

TextArea::vim_motions(true) adds TextArea-only Normal/Insert/Visual/VisualLine modal editing on top of the widget's internal editor state. Vim-enabled TextAreas start in Normal mode. Visual-mode motions update the cursor and anchor through normal TextAreaEvent state sync; VisualLine mode keeps selections expanded to whole logical lines while drawing the caret on the active selected line. Mouse double/triple-click and drag selections enter Visual mode automatically. Vim undo/redo uses Normal u and ctrl+r. The Vim layer also handles delete/yank/change operators, lowercase word motions, uppercase WORD motions (W / B / E) over non-whitespace runs, x/X, o/O, p/P, registers, search, marks, text objects, and repeat (.) through the same TextAreaEvent/TextEditEvent emission path. Searches render a bottom search bar on the focused TextArea, move the cursor into that bar while typing, right-align the current match count, underline visible matches, and give the active target a distinct background highlight as Enter, n, and N navigate results. After Enter, the bottom bar disappears and the same [current/total] count is mirrored after the text on the visible row containing the current match. Normal Esc hides the visible search feedback without forgetting the stored query. Observe mode changes with TextArea::on_vim_mode_change; store the emitted TextAreaVimMode only when your app wants a status indicator or mode-aware styling.

TextEditEvent

Both Input and TextArea emit TextEditEvent through their on_edit callback for structured edit tracking:

rust
pub struct TextEditEvent {
    pub start: usize,                // Byte offset where the edit began
    pub deleted: Arc<str>,           // Text that was removed
    pub inserted: Arc<str>,          // Text that was inserted
    pub cursor_before: usize,        // Cursor position before the edit
    pub anchor_before: Option<usize>,// Anchor position before the edit
    pub cursor_after: usize,         // Cursor position after the edit
    pub anchor_after: Option<usize>, // Anchor position after the edit
    pub kind: TextEditKind,          // Type of edit
}

pub enum TextEditKind {
    Insert,
    DeleteBackspace,
    DeleteForward,
    Replace,
}

Clear operations are reported as TextEditKind::Replace through the same TextAreaEvent / TextEditEvent path as other text edits.

Wire it up via on_edit:

rust
TextArea {
    editor: ctx.state.editor.clone(),
    on_edit: ctx.link().callback(|ev: TextEditEvent| Msg::OnEdit(ev)),
}

LineIndex and text coordinates

LineIndex converts canonical UTF-8 byte offsets into snapshot line/column coordinates for editor integrations. Build it from the current text and rebuild it whenever the text changes:

rust
let index = LineIndex::new(editor.text());
let pos = index.byte_to_position(editor.text(), editor.cursor());
let byte = index.position_to_byte(editor.text(), pos);

Columns are Unicode scalar counts by default. Use byte_to_position_with_encoding / position_to_byte_with_encoding with TextEncoding::Utf8 or TextEncoding::Utf16 when talking to protocols that use byte or UTF-16 columns. Empty text has one logical line, trailing \n creates a final empty line, and only \n is treated as a line break.


Examples

  • examples/text_area.rs - Two TextEditor instances driving TextArea widgets
  • examples/text_area_sentinels.rs - TextEditor with inline sentinels and snapshots
  • examples/todo.rs - TextInput for new item entry
  • examples/inline.rs - TextInput with insert mode
  • examples/opencode_home.rs - TextEditor in a multi-panel layout
  • examples/search_lists.rs - TextInput for filter-as-you-type

Imports

TextEditor, TextEditEvent, TextEditKind, LineIndex, TextPosition, TextRange, and TextEncoding are available from both the prelude and the crate root:

rust
use tui_lipan::TextEditor;
use tui_lipan::TextEditEvent;
use tui_lipan::{LineIndex, TextEncoding, TextPosition, TextRange};

TextInput is available from the prelude only:

rust
use tui_lipan::prelude::TextInput;
// or
use tui_lipan::prelude::*;

TextArea metrics, decorations, and state callbacks

For editor integrations, keep byte offsets as the source of truth and use LineIndex/TextPosition only as projections. A keyed TextArea can be read on the next frame with Context::text_area_metrics(key), which includes viewport, gutter, scrollbar, logical/visual line ranges, and cursor rectangles without backend-specific types.

External highlights can be supplied as byte-range TextAreaDecorations. Range, WholeLine, and Underline styles render before selection so the selected text style remains highest priority. Underline enables the underline modifier automatically. TextAreaDecorationKind::VirtualText is deprecated as a reserved no-op; virtual content uses TextAreaVirtualText instead.

TextAreaVirtualText::inline(anchor, spans) renders styled inlay text before the anchor byte. It occupies terminal columns, affects wrapping/cursor rectangles and mouse hit-testing, but never enters value, edits, undo/redo, or selection byte ranges. A cursor at the anchor is drawn after the virtual text. Use TextAreaVirtualText::eol(anchor, spans) for end-of-line diagnostics; EOL text is clipped at the viewport edge and does not reflow the line.

Use on_editor_state_change when one coherent cursor/selection/edit callback is preferable to multiple widget-specific callbacks. Existing on_change, on_edit, on_scroll, and on_vim_mode_change behavior is unchanged.

MIT OR Apache-2.0