Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 13 / Part 1: Don't lose my work / ~30 minutes

Knowing What Changed

Four stages — and the first one is your editor quietly eating an afternoon's work.

Twelve lessons in, the editor opens files, highlights them, holds several at once and keeps its text in a rope you can justify. It also loses your work without a word, and has done since lesson 3.

This is the first lesson of part 1, and the part exists because that's not a missing feature — it's a bug, in the one program where it's least forgivable.

An editor may do almost anything badly and still be useful.
Losing what you typed is the exception.

Where you're starting

Lesson 12's editor. Check againstlesson 12's checkpoint.

If your indentation doesn't match the listings, don't fight itTwelve lessons of typing means small structural differences are normal — a block nested one level deeper or shallower than mine. The code in these listings is what matters, not the leading spaces: paste it where it belongs and run cargo fmt, which re-indents to whatever nesting your file actually has.

adding   removing   existing code, shown only for context

Watch it lose your work

Reproduce the bug deliberately, so you know exactly what you're fixing.

No code this stage. Do this:

cd editor
printf 'fn main() {}\n' > scratch.md
cargo run -- scratch.md
→ type a few lines into the window
→ close the window with the titlebar button — not Ctrl+S first
cat scratch.md
→ fn main() {}

Everything you typed is gone. No prompt, no warning, no "you have unsaved changes", no trace of it on disk. The window closed and took the work with it.

Why nothing happened

Look at what Buffer actually knows about itself:

editor/src/main.rsthe Buffer struct, as it stands

/// One open file.
struct Buffer {
    path: String,
    text: Text,
    status: String,
}

A path, some text, and a status message. Nothing anywhere records whether that text has ever differed from the file it came from. save() writes it out when you press Ctrl+S, and that's the entire relationship this program has with the disk.

So the editor cannot warn you, because it does not know. It isn't that the warning was skipped — there is no fact in the program to warn about. That's what this lesson adds: one honest bit of information, and the two places you'll see it.

This is the shape of most real bugsNot a wrong calculation — a missing fact. The program behaves correctly given what it knows, and what it knows is incomplete. Fixing it means adding state, not fixing logic, which is why "where does this fact live and who updates it" is the whole design question.

A flag for "something changed"

The buffer knows it has edits that aren't on disk.

egui already knows when you've typed into a TextEdit — it just hasn't been telling anyone. Five edits.

First — the fact itself:

editor/src/main.rsthe Buffer struct

    status: String,
    /// Does this buffer hold edits that aren't on disk?
    dirty: bool,
}

Second — a file just read from disk matches the disk:

editor/src/main.rsin Buffer::open

            text: Text::new(&text),
            status,
            dirty: false,
        }

Third — and writing it out makes them match again:

editor/src/main.rsin Buffer::save

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

Fourth — the part that sets it. TextEdit returns a response, and a response knows whether the widget changed the data behind it:

editor/src/main.rsin fn ui, just after the text box

                let output = egui::TextEdit::multiline(&mut buffer.text)
                    .desired_width(f32::INFINITY)
                    .layouter(&mut layouter)
                    .show(ui);

                if output.response.changed() {
                    buffer.dirty = true;
                }

Where changed() comes from

Every egui widget returns a Response — you've used one before, inlesson 9, where.clicked() on a tab told you it had been pressed. changed() is its sibling: it means "this widget edited the data it was given during this frame".

It isn't guesswork on egui's part. Inside TextEdit there is literally this, at src/widgets/text_edit/builder.rs:810:

egui-0.36.1/src/widgets/text_edit/builder.rsline 810

        if text_changed {
            response.mark_changed();
        }

output.response is an AtomLayoutResponserather than a plain Response, but it implements Deref to one — so .changed() reaches straight through and you never notice. That's the newtype pattern from lesson 11, used by egui for the same reason you used it.

Fifth — the test helper builds a Buffer too, andcargo test won't compile until it does:

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

            text: Text::new(text),
            status: String::new(),
            dirty: false,
        }

cargo clippy --all-targets && cargo test && cargo run -- scratch.md
→ silent, ten tests green, and the editor behaves exactly as before
The fact exists now. Nothing shows it yet, which is the next stage.

Put it where you'll see it

A dot on the tab, and words in the status bar.

A fact nobody can see is not a fix. Three edits.

First — the tab grows a marker:

editor/src/main.rsin fn ui, the tab loop

                for index in 0..self.buffers.len() {
                    let selected = index == self.active;
                    let buffer = &self.buffers[index];
                    let label = if buffer.dirty {
                        format!("• {}", buffer.name())
                    } else {
                        buffer.name().to_owned()
                    };
                    if ui
                        .selectable_label(selected, self.buffers[index].name())
                        .clicked()
                    {
                    if ui.selectable_label(selected, label).clicked() {
                        self.active = index;
                    }
                }

The allocation lesson 9 warned about

Lesson 9 made a point ofname() returning &str rather than String — "no allocation per tab per frame". This stage allocates a String per tab per frame anyway. That is not an oversight, and it's worth being clear about why.

The lesson-9 point was about a hot path. This isn't one: a handful of tabs, thirty-odd bytes each, on frames that only happen when something moves. Measured against a frame that costs milliseconds, it is not detectable. Correctness that costs nothing you can measure wins.

The habit worth taking from lesson 9 isn't "never allocate" — it's "know when you're allocating, and know what it costs". Here you know both, and the answer is: fine.

Second — the status bar says it in words, for the buffer you're actually looking at:

editor/src/main.rsin fn ui, in the bottom panel

                "highlighting on"
            };

            let saved = if self.buffers[self.active].dirty {
                "unsaved changes"
            } else {
                "saved"
            };

Third — and it goes in the readout:

editor/src/main.rsin fn ui, the status label

            ui.label(format!(
                "{}  ·  {} bytes  ·  {highlighting}  ·  line_col {:.0} µs  ·  {:.2} ms/frame",
                "{}  ·  {} bytes  ·  {saved}  ·  {highlighting}  ·  line_col {:.0} µs  ·  {:.2} ms/frame",
                self.position,

Both branches are &'static str again — the same trick as highlighting in lesson 8. Two fixed messages baked into the binary, borrowed, never allocated.

cargo run -- scratch.md
→ type a character: the tab reads • scratch.md and the bar says unsaved changes
→ press Ctrl+S: the dot disappears and it says saved

Now try this, and watch it get it wrongType a single character. Then press Backspace, putting the file back exactly as it was.

The dot stays. The bar still says unsaved changes. There is nothing to save — the text on screen is identical to the text on disk — and the editor is telling you otherwise.

The flag lies, and the truth is nearly free

The dot means what it says: this differs from the file.

The flag records something happened, not it differs. Those are different questions, and you've just watched the difference matter. Editing back to where you started is not exotic — it's a typo, a Ctrl+Z, an experiment you thought better of.

The exact question is "does the text differ from what's on disk?", which needs a copy of what's on disk to compare against. On a 2.3 MB file that sounds expensive. Measure it before believing that:

On a 2.3 MB fileCost
Rope::clone — take a snapshot0.006 µs
Compare, when the text differs0.06 µs
Compare, when the text is identical64 µs

The snapshot is free, because lesson 11's rope shares its chunks between clones until one side is written to — cloning it copies a handful of pointers, not two megabytes. Comparing is free too when they differ, because the comparison stops at the first difference. Only the identical case costs anything, and 64 µs is 0.4% of a frame.

Better still: you only need to ask when changed() fires, which is once per keystroke rather than once per frame. Five edits.

First — Text owns the rope, so Text holds the snapshot:

editor/src/main.rsthe Text struct

    rope: Rope,
    /// The text as it was last read from or written to disk. A rope clone shares
    /// its chunks until one side is written to, so keeping this costs 0.006 µs
    /// on a 2.3 MB file — near enough nothing.
    saved: 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
    flat: String,

Second — a file just read from disk is its own saved version:

editor/src/main.rsin Text::new

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

Third — two methods, one to ask and one to tell:

editor/src/main.rsin impl Text, above fn sync

    /// Does the buffer differ from what's on disk?
    ///
    /// Only worth asking when something has changed: comparing two identical
    /// 2.3 MB ropes costs 64 µs, while comparing two that differ costs 0.06 µs.
    fn is_modified(&self) -> bool {
        self.rope != self.saved
    }

    /// Remember the current text as the version on disk.
    fn mark_saved(&mut self) {
        self.saved = self.rope.clone();
    }

Fourth — the flag stops guessing and starts checking:

editor/src/main.rsin fn ui, just after the text box

                if output.response.changed() {
                    buffer.dirty = true;
                    buffer.dirty = buffer.text.is_modified();
                }

Fifth — saving moves the snapshot forward:

editor/src/main.rsin Buffer::save

        fs::write(&self.path, self.text.as_str())?;
        self.text.mark_saved();
        self.dirty = false;
Two mechanisms, each covering the other's weaknesschanged() is cheap but imprecise — it knows something happened, not what. The comparison is precise but you wouldn't want it every frame. Using the cheap one to decide when to run the precise one gives you both: exact answers, at a cost you can't measure. Keeping dirty as a stored answer rather than asking every time is the same idea as frame_ms from lesson 6 — compute on the event, read on the frame.

cargo clippy --all-targets && cargo test && cargo run -- scratch.md
→ type a character: • scratch.md, unsaved changes
→ press Backspace: the dot goes, and it says saved again
→ type, then press Ctrl+Z: the dot goes too

That last one is worth pausing on. Undo is egui's, not yours — you never wrote it, andlesson 10 chose not to build one becauseTextEdit already had it. It still clears the dot, because undo edits the buffer like anything else, which fires changed(), which re-runs the comparison. A correct answer to the right question keeps working in cases you never considered. A flag set to true would not have.

Where you ended up

444 lines, ten tests green. Two regions changed: Text gained a snapshot and two methods, and Buffer gained a bit.

editor/src/main.rsthe Text struct and the new methods

/// 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,
    /// The text as it was last read from or written to disk. A rope clone shares
    /// its chunks until one side is written to, so keeping this costs 0.006 µs
    /// on a 2.3 MB file — near enough nothing.
    saved: 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),
            saved: 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)
    }

    /// Does the buffer differ from what's on disk?
    ///
    /// Only worth asking when something has changed: comparing two identical
    /// 2.3 MB ropes costs 64 µs, while comparing two that differ costs 0.06 µs.
    fn is_modified(&self) -> bool {
        self.rope != self.saved
    }

    /// Remember the current text as the version on disk.
    fn mark_saved(&mut self) {
        self.saved = self.rope.clone();
    }
Between these two: the rest of impl Text, unchangedas_str, len, line_col, sync and the whole impl egui::TextBuffer for Text are exactly aslesson 11 left them.

editor/src/main.rsthe Buffer struct

/// One open file.
struct Buffer {
    path: String,
    text: Text,
    status: String,
    /// Does this buffer hold edits that aren't on disk?
    dirty: bool,
}

Check yourself

Why couldn't the editor warn you about unsaved changes before this lesson?

  • Nothing in the program recorded that fact
  • The close button bypasses the update loop
  • egui gives no notification when a window closes
  • The rope discards its history when the app exits

Buffer held a path, some text and a status message. There was no fact about whether the text differed from disk, so there was nothing to warn about — the bug was missing state, not missing logic.↑ Review: why nothing happened (stage 1)

What does Response::changed() actually report?

  • This widget edited its data during this frame
  • The widget's value differs from its initial value
  • The user interacted with the widget somehow
  • The widget needs to be repainted next frame

TextEdit calls response.mark_changed() when the text was edited this frame. It says something happened — not that the result differs from anything, which is exactly why the stage-2 flag got it wrong.↑ Review: where changed() comes from (stage 2)

Why is keeping a snapshot of the saved text affordable?

  • Rope clones share their chunks until one is written to
  • Only the first chunk of the rope is copied
  • The snapshot stores a hash rather than the text
  • It is taken once at startup and never refreshed

Cloning a rope copies a handful of pointers — 0.006 µs on a 2.3 MB file — because the two ropes share structure until one of them is edited. The snapshot is refreshed on every save, and it's still free.↑ Review: the measurements (stage 4)

Why does the dot clear when you press Ctrl+Z, which you never wrote?

  • Undo edits the buffer, which fires changed()
  • egui's undoer resets the dirty flag directly
  • The snapshot is restored along with the text
  • Ctrl+Z is handled before the flag is read

Undo is an edit like any other, so it marks the response changed, so the comparison re-runs and finds the text matches disk. Asking the right question makes cases you never considered work by themselves.↑ Review: the flag lies (stage 4)

Read this before lesson 14

Primary source:egui::Response— API documentation. You've now used three of its methods across three lessons:clicked() in lesson 9, has_focus() in passing, andchanged() here. The list is short and most of an egui app's interactivity is somewhere in it.

Worth knowing it exists: Response::on_hover_text, which is how the dot could explain itself on hover — a one-line improvement you could make without a lesson.