Rope
ftui_text::Rope is a thin, opinionated wrapper around the ropey
crate’s rope implementation. A rope is a balanced tree of string chunks
— small enough to edit cheaply, large enough to avoid tree overhead on
iteration. It’s the storage substrate every editable text surface in
FrankenTUI sits on top of.
Why a rope and not a String
For strings with occasional edits, String is fine. As soon as edits
become frequent (per-keystroke editor typing, streaming log insertion,
incremental search-and-replace), String is quadratic: every
insert(idx, s) is O(n) because the tail shifts.
A rope makes every core operation O(log n):
insert / delete / slice
┌─ String ─────────────────────────────── O(n) ─▶
│
└─ Rope ──────────────────────────── O(log n) ─▶The crossover is around a few thousand characters with high edit frequency. For a terminal editor, a rope is the obvious choice.
Chunking
Ropey stores ~1 KiB of characters per leaf chunk (typically 512–2048 UTF-8 bytes). Insertions split or grow a chunk; deletions merge. Crucially, indices remain valid after edits as long as you use the rope’s coordinate system rather than raw byte offsets into a slice.
The API
pub struct Rope { /* ... */ }
impl Rope {
// Construction
pub fn new() -> Self;
pub fn from_text(s: &str) -> Self;
// Metadata — all O(1) or O(log n)
pub fn len_bytes(&self) -> usize;
pub fn len_chars(&self) -> usize;
pub fn len_lines(&self) -> usize;
pub fn is_empty(&self) -> bool;
pub fn grapheme_count(&self) -> usize;
// Query
pub fn line(&self, idx: usize) -> Option<Cow<'_, str>>;
pub fn lines(&self) -> impl Iterator<Item = Cow<'_, str>> + '_;
pub fn slice<R>(&self, range: R) -> Cow<'_, str>
where R: std::ops::RangeBounds<usize>;
// Edit
pub fn insert(&mut self, char_idx: usize, text: &str);
pub fn insert_grapheme(&mut self, grapheme_idx: usize, text: &str);
pub fn remove<R>(&mut self, range: R)
where R: std::ops::RangeBounds<usize>;
pub fn remove_grapheme_range<R>(&mut self, range: R);
pub fn replace(&mut self, text: &str);
pub fn append(&mut self, text: &str);
pub fn clear(&mut self);
// Coordinate conversions
pub fn char_to_byte(&self, char_idx: usize) -> usize;
pub fn byte_to_char(&self, byte_idx: usize) -> usize;
pub fn char_to_line(&self, char_idx: usize) -> usize;
pub fn line_to_char(&self, line_idx: usize) -> usize;
pub fn byte_to_line_col(&self, byte_idx: usize) -> (usize, usize);
pub fn line_col_to_byte(&self, line_idx: usize, col: usize) -> usize;
// Iterators
pub fn chars(&self) -> impl Iterator<Item = char> + '_;
pub fn graphemes(&self) -> Vec<String>;
}The four coordinate spaces
┌──────── bytes ─────────┐ raw UTF-8 offsets
│ │
┌──── chars ─────┐ scalar values (char boundaries)
│ │
┌─ graphemes ─┐ user-perceived characters
│ │
┌─ lines ─┐ newline-separated- Bytes are UTF-8 offsets. Cheap, but
insert(byte, "é")is a trap: inserting at a non-char-boundary corrupts the string. - Chars are Unicode scalar values. Safe for
insert(char_idx, …)and slicing. What the rope’s default edit API uses. - Graphemes are user-perceived characters:
👨👩👧👦is one grapheme made of seven chars. Useinsert_grapheme/remove_grapheme_rangewhen you want “one backspace removes one thing the user typed.” - Lines are newline-separated slices.
line(idx)andline_to_char(idx)bridge between the other three.
Slicing without allocating
rope.slice(a..b) returns a Cow<'_, str>. When the range lives
inside a single chunk, the Cow borrows; otherwise it owns a freshly
built string.
let mut rope = Rope::from_text("hello, world");
let s = rope.slice(7..12); // "world"
// For a small rope entirely in one chunk, this is zero-copy.Worked example — a tiny edit log
use ftui_text::Rope;
let mut rope = Rope::from_text("The quick brown fox");
rope.insert(10, "super "); // char index
assert_eq!(rope.slice(..).as_ref(), "The quick super brown fox");
rope.remove(4..10); // "quick "
assert_eq!(rope.slice(..).as_ref(), "The super brown fox");
rope.append("\njumps over\n");
assert_eq!(rope.len_lines(), 3);Grapheme indices for user-perceived editing
use ftui_text::Rope;
let mut rope = Rope::from_text("family 👨👩👧👦!");
assert!(rope.grapheme_count() < rope.len_chars());
// One grapheme backspace:
let last = rope.grapheme_count() - 1;
rope.remove_grapheme_range(last..last + 1); // removes "!"Without grapheme indices, a backspace might eat only a single combining codepoint and leave a half-formed emoji cluster in the buffer. The grapheme API prevents that class of bug.
Pitfalls
Don’t mix byte and char indices. insert and remove take char
indices by default. If you accumulate byte offsets from parsing and
then call rope.insert(byte_offset, …), you will produce invalid
UTF-8 in pathological cases. Convert with byte_to_char first.
len_lines() counts the terminator. An empty rope has
len_lines() == 1 (one empty line), and a rope ending in \n has
len_lines() one higher than the visible line count. This is the
convention every line-based editor uses; learn to love it.
slice borrows sometimes. If you need to hold the slice across
an edit, clone it into a String — the borrowed Cow::Borrowed
variant points into chunk memory that may move when you insert.