Skip to Content
ftui-textEditor

Editor

ftui_text::Editor is the editable state machine on top of a Rope. It owns the text, the cursor, the selection, and the undo/redo stacks. Every interactive text widget in FrankenTUI — single-line input, multi-line textarea, the command palette’s query field — runs on this one type.

The pieces, at a glance

┌──────────────────────── Editor ───────────────────────┐ │ │ │ rope: Rope ← the text itself │ │ cursor: CursorPosition ← where edits happen │ │ selection: Option<Selection> ← optional highlight │ │ undo_stack: Vec<...> ← bounded history │ │ redo_stack: Vec<...> ← cleared on new edit │ │ │ └───────────────────────────────────────────────────────┘

CursorPosition — visual column vs. byte offset

The cursor is not just a byte offset. An editor cursor has to know:

  • Byte offset — where it sits in the rope.
  • Visual column — the target column the user is trying to maintain when moving up/down across lines of different lengths.

The visual column is “sticky”: if you’re at column 40 and press Down onto a 20-character line, the cursor visits column 20; press Down again onto an 80-character line and the cursor returns to column 40. This mirrors vim and every modern code editor.

Selection — anchor and head

pub struct Selection { pub anchor: CursorPosition, // fixed end — where the selection started pub head: CursorPosition, // moving end — follows the cursor }

head == cursor while the selection is active. The anchor stays put, so shift+click, shift+arrow, and drag selections all reuse the same model.

let range = selection.byte_range(&navigator); // (min, max) in byte space let empty = selection.is_empty(); // anchor == head

Construction

pub fn new() -> Self; // empty pub fn with_text(text: &str) -> Self; // preload content pub fn set_text(&mut self, text: &str); // replace pub fn clear(&mut self);

Cursor movement

Grapheme-aware, UTF-8-correct, always.

ed.move_left(); ed.move_right(); ed.move_up(); ed.move_down(); ed.move_word_left(); ed.move_word_right(); ed.move_to_line_start(); ed.move_to_line_end(); ed.move_to_document_start(); ed.move_to_document_end();

Word boundaries use Unicode UAX #29 word-break rules — the same ones you’d get from double_click_selects_word in a browser. Line boundaries respect the rope’s line counting (see the rope docs for the terminator quirk).

Editing

ed.insert_char('a'); ed.insert_text("hello"); ed.insert_newline(); ed.delete_backward(); ed.delete_forward(); ed.delete_word_backward(); ed.delete_word_forward(); ed.delete_to_end_of_line();

All deletions return bool (true iff something was actually deleted). All insertions coalesce into the active undo group when they happen in rapid succession at the same boundary — see below.

Selection controls

ed.select_left(); ed.select_right(); ed.select_up(); ed.select_down(); ed.select_word_left(); ed.select_word_right(); ed.select_all(); ed.clear_selection(); ed.selected_text(); // Option<String> ed.extend_selection_to(new_head); // used by drag selection

Undo coalescing — “hello” is one step

Typing h, e, l, l, o should be one undo unit. Backspacing the word hello should also be one undo unit. The editor achieves this by coalescing consecutive inserts or deletes that happen at the same boundary within a small time window:

keystroke stream: h e l l o · w o r l d undo group: [───── hello ─────] ↕ [───── world ────] boundary broken (cursor jump, timeout, new kind)

A group is committed (and a new one started) when:

  • The cursor moves away from the active group’s boundary,
  • A coalescing time window elapses,
  • The kind of edit changes (insert → delete),
  • undo() or redo() is called.

The result: ed.undo() is one user-visible step, not one keystroke.

History bounds

ed.set_max_history(100); // max number of undo entries ed.set_max_undo_size(10 * 1024 * 1024); // max total bytes (default 10 MB)

When either limit is hit, the oldest entries are dropped. There is no branching redo; redo clears on every new edit (standard linear history).

Clipboard integration

ftui-text does not ship a clipboard backend. Clipboard integration lives at the widget layer, which wires the editor’s selected_text() and insert_text(pasted) into the host clipboard via ftui-core’s paste events or the web backend’s clipboard API. The editor stays platform-agnostic.

// Copy if let Some(text) = ed.selected_text() { host_clipboard_set(text); } // Paste if let Some(text) = host_clipboard_get() { ed.insert_text(&text); }

Worked example — a small edit session

edit_session.rs
use ftui_text::Editor; let mut ed = Editor::with_text("The quick brown fox"); ed.move_to_line_end(); ed.insert_text(" jumps over the lazy dog"); // One undo step for the whole insert. ed.move_to_document_start(); ed.select_word_right(); // selects "The" let selected = ed.selected_text().unwrap(); assert_eq!(selected, "The"); ed.insert_text("A"); // replace selection ed.undo(); // restore "The" ed.undo(); // remove the long insert assert_eq!(ed.text(), "The quick brown fox");

Pitfalls

Visual column vs. byte offset. Don’t assume cursor.byte_offset is what you want to render. For display you almost always want the visual column derived from the rope’s byte_to_line_col — byte offsets change size when the preceding line grows/shrinks.

set_text nukes history. It’s replace_all semantics. If you want to replace content while preserving undo, do select_all() + insert_text(new) instead.

Coalescing is time-based. If you drive the editor from a scripted test that doesn’t advance the clock, rapid inserts can coalesce beyond what a human user would experience. Use the test harness’s explicit “flush undo group” hook if you need fine-grained steps.

Where to go next