<< 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.
Losing what you typed is the exception.
Where you're starting
Lesson 12's editor. Check againstlesson 12's checkpoint.
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.
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
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 file | Cost |
|---|---|
Rope::clone — take a snapshot | 0.006 µs |
| Compare, when the text differs | 0.06 µs |
| Compare, when the text is identical | 64 µ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;changed() 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();
}as_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?
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?
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?
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 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.