Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 11 / Text Editor GUI in Rust / ~40 minutes

Earning the Rope

Five stages — and the reason every tutorial gives for a rope turns out to be the wrong one.

Your mission asks for "a text buffer backed by a rope, with a written justification of why not String". The justification is the deliverable. A rope you can't defend is worth less than a String you can.

So this lesson goes in the order that makes the justification real: build a seam, measure through it, and only then decide. If the number had come back the other way this lesson would have been called Declining the Rope, and I'd have written that instead. It nearly was.

The textbook argument for a rope is that String insertion is O(n).
For this editor that argument is false — and there is a true one hiding underneath it.

Where you're starting

Lesson 10's 331 lines, with ten passing tests. Check againstlesson 10's checkpoint. Those tests are about to earn their keep: you're going to replace the data structure underneath them without changing a single one.

adding   removing   existing code, shown only for context

Give the text its own type

Everything behaves exactly as before — but now there is one place to change.

Today Buffer.text is a String, andTextEdit::multiline(&mut buffer.text) works because egui ships an implementation of its TextBuffer trait for String. To put anything else in there, you need that trait implemented for a type of your own.

So this stage swaps in a wrapper that does nothing. That's the point: make the change that can't break anything before the change that can. Seven edits, and the file won't compile until all seven are in.

First — two imports:

editor/src/main.rstop of the file

use eframe::egui;
use egui::text::LayoutJob;
use egui::text::{CharIndex, LayoutJob};
use std::env;
use std::fs;
use std::io;
use std::ops::Range;
use std::sync::Arc;

Second — the type, and the trait implementation that lets egui edit it. It goes directly above struct Buffer:

editor/src/main.rsabove the Buffer struct

/// The text of one buffer.
///
/// A wrapper around `String` that does nothing yet. It exists so there is one
/// place to change when the thing inside stops being a `String`.
struct Text(String);

impl Text {
    fn new(text: &str) -> Self {
        Self(text.to_owned())
    }

    fn as_str(&self) -> &str {
        &self.0
    }

    fn len(&self) -> usize {
        self.0.len()
    }
}

/// Let egui's `TextEdit` edit a `Text` instead of a bare `String`.
impl egui::TextBuffer for Text {
    fn type_id(&self) -> std::any::TypeId {
        std::any::TypeId::of::<Self>()
    }

    fn is_mutable(&self) -> bool {
        true
    }

    fn as_str(&self) -> &str {
        &self.0
    }

    fn insert_text(&mut self, text: &str, char_index: CharIndex) -> usize {
        self.0.insert_text(text, char_index)
    }

    fn delete_char_range(&mut self, char_range: Range<CharIndex>) {
        self.0.delete_char_range(char_range);
    }
}

What the wrapper buys you

struct Text(String); is a tuple struct — one unnamed field, reached as self.0. Wrapping a type you didn't write in a type you did is common enough in Rust to have a name, the newtype pattern, and it exists mostly to get around one rule.

The rule: you may implement a trait for a type only if you own the trait or you own the type. You own neither egui::TextBuffer nor String, soimpl TextBuffer for String is forbidden in your crate — egui already wrote that one, and two competing implementations would be ambiguous. Wrap theString in a type you do own and the restriction lifts.

Notice how thin the implementation is: self.0.insert_text(…) calls egui's own String implementation. Behaviour is unchanged by construction, which is what you want from a stage whose only job is to open a seam.

Two as_str methods, and that's deliberateOne is an inherent method on Text, for your code to call. The other is the trait's required method, for egui to call. Identical bodies today — but they answer to different owners, and in stage 3 only one of them stays simple. Rust resolves the inherent one first, so self.text.as_str() in your code never goes near the trait.

Third — the field changes type:

editor/src/main.rsthe Buffer struct

struct Buffer {
    path: String,
    text: String,
    text: Text,
    status: String,
}

Fourth — Buffer::open builds one:

editor/src/main.rsin Buffer::open

        Self {
            path: path.to_owned(),
            text,
            text: Text::new(&text),
            status,
        }

Fifth — save reached into the String directly, and now goes through as_str():

The & goes too — this is not just adding .as_str()The natural way to make this edit is to type .as_str() on the end of what's already there, which leaves you with&self.text.as_str(). That compiles and works — a&&str still satisfies fs::write — so nothing goes red. It just leaves a borrow of a borrow behind:

warning: the borrowed expression implements the required traits
help: change this to: self.text.as_str()


Delete the whole line and type the green one, rather than editing in place.

editor/src/main.rsin Buffer::save

        fs::write(&self.path, &self.text)?;
        fs::write(&self.path, self.text.as_str())?;
        self.status = format!("saved {} bytes to {}", self.text.len(), self.path);

The self.text.len() on the line below it doesn't change, because Text has a len of its own that forwards to theString. That's the newtype earning its keep already — call sites that only need the interface don't care what's behind it.

Sixth — and line_col, which walks the characters:

editor/src/main.rsin Buffer::line_col

        for ch in self.text.chars().take(char_index) {
        for ch in self.text.as_str().chars().take(char_index) {

Seventh — and one the compiler won't mention until you run the tests, becausecargo build doesn't compile the test module:

editor/src/main.rsin mod tests, fn buffer_with

        Buffer {
            path: "test.rs".to_owned(),
            text: text.to_owned(),
            text: Text::new(text),
            status: String::new(),
        }

cargo clippy && cargo test && cargo run
→ silent, test result: ok. 10 passed; 0 failed
→ the editor is indistinguishable from lesson 10
Nothing observable changed. That is the success condition.

Run cargo clippy at the end of every stage from here on, not just at the end of the lesson. Six of these seven edits change a line rather than add one, and a leftover & or a stray &mut is a warning, not an error — it will sit there quietly until something makes you look.

Find out what egui actually asks for

A number that says where a 13 ms frame goes — and it isn't where you'd guess.

No code this stage. Rebuild the big files fromlesson 8 and read the frame counter you already have:

cd editor
for n in 10000 50000; do
  seq 1 $n | awk '{print " let value_" $1 " = compute(" $1 "); // note"}' > big-$n.rs
done
cargo run --release -- big-50000.rs
→ press Ctrl+End, then hold a key down
→ … ~13 ms/frame

Thirteen milliseconds is most of a 60fps frame budget of 16.7 ms. Lesson 6 told you egui's layout was 87% of the cost at this size — but that was before the highlighter, and this file is over the 512 KB limit so highlighting is off. So what is it now?

The obvious suspect, again

The obvious suspect is the same one lesson 6 cleared: String::insert has to shift everything after the cursor, so typing near the top of a 2.3 MB file copies megabytes. That's the argument every rope tutorial opens with.

It's still wrong. Measured on this machine, mid-file insertion into a 2.3 MBString is 12 µs — 0.07% of the frame. Copying contiguous memory remains one of the things hardware is fastest at.

What the trait told me

The seam you built in stage 1 is what made the next part findable. Every call egui makes on the buffer now goes through your code, so you can count them. I put a timer in each method of TextBuffer and typed for forty frames. Per keystroke, on the 2.3 MB file:

What egui calledTimes per keystrokeCost per frame
as_str7free — it's already a &str
insert_text11.05 ms
delete_char_range12.01 ms
byte_index_from_char_index33.05 ms

The insert isn't 12 µs after all — it's 1.05 ms. And the reason is the row below it. Three times per keystroke, egui converts a character index into a byte index, and the insert and delete each pay for one of those conversions internally. Here is egui's implementation, fromsrc/text_selection/text_cursor_state.rs:

egui-0.36.1/src/text_selection/text_cursor_state.rsline 276

pub fn byte_index_from_char_index(s: &str, char_index: CharIndex) -> ByteIndex {
    for (ci, (bi, _)) in s.char_indices().enumerate() {
        if ci == char_index.0 {
            return ByteIndex(bi);
        }
    }
    ByteIndex(s.len())
}

It walks the text from the beginning, one character at a time, until it reaches the index you asked for. That is O(n) in the size of the file, and it runs three times for every character you type.

This is the lessonThe expensive thing was not the operation everyone warns about. It was a helper, in a library, called on your behalf, three times per keystroke — and it only became visible because stage 1 put a seam where you could see through it.

You could not have reasoned your way here. UTF-8 means char index ≠ byte index is a fact you already knew fromlesson 4. That converting between them costs 3 ms is a measurement.

Why three? One to place the caret, and one each for the delete and insert that a keystroke turns into — egui replaces the selection even when the selection is empty.

Put a rope inside

The same editor, with the 3 ms gone.

A rope stores text as a balanced tree of small chunks instead of one flat array. Each node knows how many bytes, characters and lines are beneath it, so converting between those three counts is a walk down the tree — O(log n) — rather than a walk through the text. That is precisely the operation that just cost you 3 ms.

cd editor
cargo add ropey
→ Adding ropey v1.6.1 to dependencies

Three edits, all inside the seam. Nothing outside Textchanges — that was the whole point of stage 1.

First — two more imports:

editor/src/main.rstop of the file

use eframe::egui;
use egui::text::{CharIndex, LayoutJob};
use egui::text::{ByteIndex, CharIndex, LayoutJob};
use ropey::Rope;
use std::env;

ropey sorts above std becauserustfmt groups external crates before the standard library. Runcargo fmt if you put it elsewhere and it will move.

Second — the type gains a rope, and the wrapper stops being a wrapper:

This replaces stage 1's version — it doesn't sit beside itDelete struct Text(String); and the whole impl Text block you wrote in stage 1, and put this in their place. Everything is tinted green because every line of it is new, not because you're adding it to what's already there.

editor/src/main.rsthe Text struct and its impl

/// The text of one buffer, backed by a rope.
///
/// A rope stores text as a tree of small chunks, so an edit rewrites one chunk
/// and an index lookup walks the tree instead of the text.
struct Text {
    rope: Rope,
    /// egui's `TextBuffer::as_str` must return a *borrowed contiguous* `&str`,
    /// and a rope has no such slice to lend. So we keep a flattened copy here
    /// and rebuild it on every edit.
    flat: String,
}

impl Text {
    fn new(text: &str) -> Self {
        Self {
            rope: Rope::from_str(text),
            flat: text.to_owned(),
        }
    }

    fn as_str(&self) -> &str {
        &self.flat
    }

    fn len(&self) -> usize {
        self.flat.len()
    }

    fn sync(&mut self) {
        self.flat = self.rope.to_string();
    }
}

The price of the trait

That second field is the honest cost of this whole approach, and it's worth understanding before you accept it. Look again at what the trait demands:

egui-0.36.1/src/widgets/text_edit/text_buffer.rsline 29

    /// Returns this buffer as a `str`.
    fn as_str(&self) -> &str;

A &str is a pointer and a length: one contiguous run of bytes. A rope is deliberately not contiguous — that's how it makes edits cheap. And the signature borrows from &self, so you can't build a temporary string and return a reference to it; it would be dead the moment the function returned.

So there is no clever way out. Keeping TextEdit means keeping a flattened copy of the whole document beside the rope and rebuilding it on every edit. That costs0.08 ms per keystroke on a 2.3 MB file and doubles the memory. You are buying a 3 ms saving for 0.08 ms and some RAM — a good trade, but a trade, and the sort of thing that belongs in a comment rather than in your head.

Third — the trait implementation stops delegating and starts using the rope. Same again: this is a replacement for the whole impl egui::TextBuffer for Textblock, not an addition to it.

editor/src/main.rsimpl egui::TextBuffer for Text

impl egui::TextBuffer for Text {
    fn type_id(&self) -> std::any::TypeId {
        std::any::TypeId::of::<Self>()
    }

    fn is_mutable(&self) -> bool {
        true
    }

    fn as_str(&self) -> &str {
        &self.flat
    }

    fn insert_text(&mut self, text: &str, char_index: CharIndex) -> usize {
        self.rope
            .insert(char_index.0.min(self.rope.len_chars()), text);
        self.sync();
        text.chars().count()
    }

    fn delete_char_range(&mut self, char_range: Range<CharIndex>) {
        let end = self.rope.len_chars();
        self.rope
            .remove(char_range.start.0.min(end)..char_range.end.0.min(end));
        self.sync();
    }

    fn byte_index_from_char_index(&self, char_index: CharIndex) -> ByteIndex {
        ByteIndex(
            self.rope
                .char_to_byte(char_index.0.min(self.rope.len_chars())),
        )
    }

    fn char_index_from_byte_index(&self, byte_index: ByteIndex) -> CharIndex {
        CharIndex(
            self.rope
                .byte_to_char(byte_index.0.min(self.rope.len_bytes())),
        )
    }
}

Overriding a default you were given

byte_index_from_char_index and char_index_from_byte_index aredefault methods — the trait supplies a body, so an implementation that ignores them still compiles. That's why stage 1 worked without them, and it's why the 3 ms was invisible: you were inheriting a linear scan you never wrote.

A default method is a library author saying "here's a correct answer; replace it if you can do better." The rope can do better, so it does. This is the same shape asIterator giving you count() for free whileExactSizeIterator answers it instantly.

The .min(self.rope.len_chars()) calls are not paranoia. egui may hand you an index one past the end while a selection collapses, andropey panics on an out-of-range index where a String slice would too. Clamping is the same instinct as line_col's "past the end is the end" test from lesson 10.

cargo test && cargo run --release -- big-50000.rs
→ test result: ok. 10 passed; 0 failed
→ press Ctrl+End and type: ~8 ms/frame, down from ~13
Ten tests you wrote in lesson 10, over a data structure that did not exist when you wrote them, still green.

Let the rope count the lines too

The Ln/Col readout stops walking the file.

You now own a data structure that indexes lines, and there is code in this editor that has been counting them the hard way since lesson 4. line_col walks the text from the start on every frame — lesson 6 measured it at875 µs and shrugged, because it was an eighth of the cost of something bigger. That something bigger is now gone, which promotes it. Two edits.

First — Text learns to do it, because Text is what has the rope:

editor/src/main.rsin impl Text, after len

    /// Turn a character offset into a 1-based line and column.
    ///
    /// The rope indexes its own lines, so this is a walk down a tree rather
    /// than a walk through the text: 0.23 µs where lesson 6 measured 875 µs.
    fn line_col(&self, char_index: usize) -> (usize, usize) {
        let char_index = char_index.min(self.rope.len_chars());
        let line = self.rope.char_to_line(char_index);
        (line + 1, char_index - self.rope.line_to_char(line) + 1)
    }

Second — Buffer::line_col loses its loop and forwards instead:

This one is a deletion, mostlyThe whole let mut line / for ch in … body goes. Delete the eleven lines between the signature and the closing brace, and put the single forwarding line in their place. The method keeps its name and signature, which is why nothing that calls it — including four of your tests — has to change.

editor/src/main.rsin impl Buffer, replacing line_col's body

    /// Turn a character offset into a 1-based line and column.
    fn line_col(&self, char_index: usize) -> (usize, usize) {
        let mut line = 1;
        let mut column = 1;
        for ch in self.text.as_str().chars().take(char_index) {
            if ch == '\n' {
                line += 1;
                column = 1;
            } else {
                column += 1;
            }
        }
        (line, column)
        self.text.line_col(char_index)
    }

Why a rope knows this for free

Every node of the tree stores how many lines are in the subtree below it. Finding the line containing character 1,200,000 means descending from the root, at each step subtracting the counts of the subtrees you skip — a handful of comparisons for a file of any size. char_to_line then line_to_char gives you the line number and where that line began, and the column is the difference.

Measured on the 2.3 MB file: the walk was 1,000 µs, the rope is 0.23 µs. Four thousand times, on a readout that updates every frame.

Watch the µs readout, not the ms oneYou built the line_col timer inlesson 6 and it has been sitting in your status bar ever since. On big-50000.rs it read about 875 µs. It now reads 0, because it rounds to whole microseconds and the answer is a quarter of one.

cargo test && cargo run --release -- big-50000.rs
→ test result: ok. 10 passed; 0 failed
→ Ln 50000, Col 1 · 2400000 bytes · highlighting off (file too large) · line_col 0 µs · ~8 ms/frame

Write down why

A document that defends the decision — the thing the mission actually asked for.

The rope is in. The mission's success criterion is not "a rope"; it is "a rope, with a written justification of why not String". Nobody reading your repo in an interview can see the measurements you just took. One edit — a new file:

editor/docs/why-a-rope.mdnew file

# Why this editor uses a rope

`Buffer.text` is a `ropey::Rope` behind a `Text` wrapper, not a `String`. This is the
reasoning, with the numbers it came from. Measured on a 2.3 MB / 50,000-line Rust file,
release build, median of 40 frames while typing.

## The reason it is *not*

The usual argument is that `String::insert` is O(n) — inserting mid-file shifts everything
after it. That is true and it does not matter here: **12 µs**, or 0.07% of a 16.7 ms frame.
Copying contiguous memory is fast. If this were the only consideration, a `String` would
win on simplicity.

## The reason it is

egui asks the buffer to convert a character index into a byte index **three times per
keystroke**, and its default implementation walks the text from the start
(`egui-0.36.1/src/text_selection/text_cursor_state.rs:276`).

| Buffer | ms/frame while typing | of which char->byte |
| --- | --- | --- |
| `String` | 12.21 | 3.05 |
| `Rope` | 7.74 | 0.00 |

A rope's nodes carry byte, char and line counts, so the conversion is a descent down a
tree instead of a scan of the text. The same property makes `line_col` — which runs every
frame to draw the Ln/Col readout — go from 1000 µs to 0.23 µs.

## The option I rejected

If the text is pure ASCII, char index and byte index are equal, so both conversions become
`i.min(len)`. Three lines, no dependency, and it measures *better* than the rope: **7.33
ms**. It is also fragile in a way that matters for a code editor:

| 2.3 MB file | pure ASCII | with one `é` in it |
| --- | --- | --- |
| `String` | 12.21 ms | 10.70 ms |
| `String` + ASCII fast path | **7.33 ms** | 10.39 ms |
| `Rope` | 7.74 ms | **7.57 ms** |

One accented character anywhere in the file and every keystroke pays the linear walk
again. The rope has no fast path to fall off, and that predictability is what it is
actually being bought for.

## What it costs

Stated plainly, because it is not free:

- **Double the memory.** `TextBuffer::as_str` must return a borrowed contiguous `&str`,
  which a chunked rope cannot produce, so `Text` keeps a flattened `String` beside the
  rope and rebuilds it on every edit: 0.08 ms per keystroke at 2.3 MB.
- **Slower open.** `Rope::from_str` is 647 µs on 2.3 MB against roughly nothing for a
  `String`.
- **A dependency**, and a data structure a reader has to know to follow the code.

## When this would change

If the editor ever stopped using egui's `TextEdit` and painted text itself, the flattened
copy would go and the rope would get cheaper. If it only ever opened files under ~100 KB,
none of this would be measurable and a `String` would be the better answer.

The alternative I owe you

There is a cheaper fix than a rope, and leaving it out of that document would make the document dishonest. If the text is pure ASCII then character index and byte index are the same number, so both conversions become i.min(len) — three lines, no dependency, no second copy of the file in memory.

It measures better than the rope: 7.33 ms against 7.74. Most source code is ASCII, so most of the time it wins.

Then someone types café in a comment:

2.3 MB filepure ASCIIwith one é in it
String12.21 ms10.70 ms
String + ASCII fast path7.33 ms10.39 ms
Rope7.74 ms7.57 ms

One accented character anywhere in the file and the fast path is gone — back to the linear walk, for every keystroke, for the rest of the session. The rope doesn't have a fast path to fall off. That is the argument, and it is not "ropes are faster": it isa rope's cost doesn't depend on what's in the file.

This is what the justification is forA reviewer who knows this territory will ask "why not just index bytes and be done with it?" — and it is a good question with a real answer. Having the numbers, including the numbers for the option you rejected, is the difference between a decision and a preference.

cd editor && rm big-*.rs
cargo fmt && cargo clippy && cargo test
→ both silent, ten tests green

Where you ended up

415 lines, ten tests green, cargo clippy silent. One region changed: the new Text type and its trait implementation, plus a two-lineline_col where eleven used to be.

editor/src/main.rsthe Text type, above struct Buffer

/// The text of one buffer, backed by a rope.
///
/// A rope stores text as a tree of small chunks, so an edit touches one chunk
/// and an index lookup walks the tree instead of the text. See
/// `docs/why-a-rope.md` for the measurements that chose it.
struct Text {
    rope: Rope,
    /// egui's `TextBuffer::as_str` must return a *borrowed contiguous* `&str`,
    /// and a rope has no such slice to lend. So we keep a flattened copy here
    /// and rebuild it on every edit: 0.08 ms on a 2.3 MB file, against the
    /// 3.0 ms the rope saves. It also doubles the memory, which is the honest
    /// cost of staying inside egui's `TextEdit`.
    flat: String,
}

impl Text {
    fn new(text: &str) -> Self {
        Self {
            rope: Rope::from_str(text),
            flat: text.to_owned(),
        }
    }

    fn as_str(&self) -> &str {
        &self.flat
    }

    fn len(&self) -> usize {
        self.flat.len()
    }

    /// Turn a character offset into a 1-based line and column.
    ///
    /// The rope indexes its own lines, so this is a walk down a tree rather
    /// than a walk through the text: 0.23 µs where lesson 6 measured 875 µs.
    fn line_col(&self, char_index: usize) -> (usize, usize) {
        let char_index = char_index.min(self.rope.len_chars());
        let line = self.rope.char_to_line(char_index);
        (line + 1, char_index - self.rope.line_to_char(line) + 1)
    }

    fn sync(&mut self) {
        self.flat = self.rope.to_string();
    }
}

/// Let egui's `TextEdit` edit a rope directly.
///
/// The two index conversions are the whole point: egui asks for them three
/// times per keystroke, and its default walks the text from the start.
impl egui::TextBuffer for Text {
    fn type_id(&self) -> std::any::TypeId {
        std::any::TypeId::of::<Self>()
    }

    fn is_mutable(&self) -> bool {
        true
    }

    fn as_str(&self) -> &str {
        &self.flat
    }

    fn insert_text(&mut self, text: &str, char_index: CharIndex) -> usize {
        self.rope
            .insert(char_index.0.min(self.rope.len_chars()), text);
        self.sync();
        text.chars().count()
    }

    fn delete_char_range(&mut self, char_range: Range<CharIndex>) {
        let end = self.rope.len_chars();
        self.rope
            .remove(char_range.start.0.min(end)..char_range.end.0.min(end));
        self.sync();
    }

    fn byte_index_from_char_index(&self, char_index: CharIndex) -> ByteIndex {
        ByteIndex(
            self.rope
                .char_to_byte(char_index.0.min(self.rope.len_chars())),
        )
    }

    fn char_index_from_byte_index(&self, byte_index: ByteIndex) -> CharIndex {
        CharIndex(
            self.rope
                .byte_to_char(byte_index.0.min(self.rope.len_bytes())),
        )
    }
}
Everything else is as lesson 10 left itThe constants, scan, highlight, struct Buffer,struct Editor, the whole of fn ui, fn main and the test module are unchanged — except Buffer::line_col, which now reads:

editor/src/main.rsin impl Buffer

    /// Turn a character offset into a 1-based line and column.
    fn line_col(&self, char_index: usize) -> (usize, usize) {
        self.text.line_col(char_index)
    }

Check yourself

What made String expensive here at 2.3 MB while typing?

  • Converting a character index to a byte index
  • Shifting the tail of the buffer on insert
  • Reallocating whenever the capacity was exceeded
  • Copying the whole buffer for the undo history

The insert itself is 12 µs. egui converts a char index to a byte index three times per keystroke and walks the text to do it, which came to 3.05 ms of a 13 ms frame.↑ Review: what the trait told me (stage 2)

Why does Text keep a String beside the rope?

  • TextBuffer::as_str must return contiguous bytes
  • Saving to disk needs the text in one piece
  • The rope cannot store text that isn't ASCII
  • Rebuilding it is cheaper than editing the rope

A &str is a pointer and a length, and the signature borrows from &self, so no temporary can be returned. A rope is chunked by design, so a flattened copy is the only way to satisfy that method.↑ Review: the price of the trait (stage 3)

Why implement the trait for Text rather than for String?

  • You own neither the trait nor String
  • Traits cannot be implemented for standard library types
  • String is not allowed to have extra methods
  • Implementing it twice would make compilation slower

A trait implementation needs you to own the trait or the type. egui ownsTextBuffer and already implements it for String, so the wrapper is how you get a type of your own to attach to.↑ Review: what the wrapper buys you (stage 1)

Why did stage 1 exist, when it changed no behaviour at all?

  • Every call egui makes now passes through your code
  • It removed a dependency the rope would have conflicted with
  • Tuple structs compile faster than a bare String field
  • The tests could not have been written without it

The seam is what made stage 2 possible: you cannot time a call you never see. It also meant the rope went in behind an interface that already existed, so nothing outside Text had to change.↑ Review: what egui actually asks for (stage 2)

Read this before lesson 12

Primary source:ropey — API documentation, and in particular itsdesign document, which explains the chunk sizes and the tree invariants rather than just listing the methods.

You used four of its methods today: insert, remove,char_to_byte and char_to_line. The one worth reading about next is Rope::clone, which is nearly free because clones share their chunks until one of them is written to. That is what would make an undo history cheap — and it's the first thing to reach for if you ever want one that egui's own Ctrl+Z doesn't give you.