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 == headConstruction
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 selectionUndo 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()orredo()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
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.