<< Back / Lesson 14 / Part 1: Don't lose my work / ~35 minutes
The Index That Outlived Its Buffer
Four stages — and the first one compiles perfectly and then crashes.
You can open several files. You cannot close any of them. That's the gap this lesson fills, and closing a buffer turns out to be the most interesting small feature in the editor — because it's where a warning fromlesson 9 finally comes true.
Lesson 9 stored the current file as active: usize rather than a reference, and said this about it: "nothing stops you using a stale one". Nothing has, so far, because nothing has ever removed a buffer. This lesson removes one.
It has never had an opinion about whether your indices are.
Where you're starting
Lesson 13's editor, with the dirty dot. Check againstlesson 13's checkpoint.
adding removing existing code, shown only for context
A button that closes a tab
An × on every tab. It works once, then the editor dies.
The obvious implementation, written the obvious way. One edit — inside the tab loop, just after the label:
editor/src/main.rsin fn ui, the tab loop
if ui.selectable_label(selected, label).clicked() {
self.active = index;
}
if ui.small_button("×").clicked() {
self.buffers.remove(index);
}
}printf 'fn a() {}\n' > a.rs; printf 'fn b() {}\n' > b.rs; printf 'fn c() {}\n' > c.rs
cargo run -- a.rs b.rs c.rs
→ three tabs, each with an ×
→ click the × on the first tab
thread 'main' panicked at src/main.rs:297:34: index out of bounds: the len is 2 but the index is 2
Why the compiler let you do that
Read the loop again with that error in mind:
for index in 0..self.buffers.len() { // evaluated once: 0..3
let buffer = &self.buffers[index]; // panics when index is 2 and len is 2
// …
self.buffers.remove(index); // the Vec is now one shorter
}0..self.buffers.len() is evaluated once, before the first iteration. It becomes the range 0..3 and stays that way. Remove an element on the first pass and the loop still faithfully visits indices 1 and 2, of a vector that now ends at 1.
active: usize over &mut Buffer because a struct can't own a Vec and hold a reference into it — safe Rust rejects that outright, with E0106 and then E0505. The cost of the index, stated at the time: "nothing stops you using a stale one".Here's the bill. A reference into a
Vec would have made this a compile error — the borrow checker will not let you call remove while a reference into it is alive. An index is just a number, and numbers don't know what they point at.Rust moved the failure from compile time to run time, and made it loud rather than silent: a bounds check, a panic, a line number. In C this would have read whatever memory happened to be there. That's the trade the index bought, and it's a good one — but it is a trade, not a free pass.
Decide in the loop, act after it
Closing a tab works, and the editor stays up.
You can't safely change the vector while walking it, so don't. Note what you want to happen, finish the walk, and do it afterwards — when nothing is iterating any more.Four edits.
First — somewhere to note it, just above the tab row:
editor/src/main.rsin fn ui, directly above the tab row
let mut closing = None;
ui.horizontal(|ui| {
for index in 0..self.buffers.len() {closing is written inside the loop and read after it, so it has to be declared in the block that contains ui.horizontal — wherever that is in your file. Put it one scope too deep and you getcannot find value `closing` in this scope, twice.Second — the button records the request instead of acting on it:
editor/src/main.rsin fn ui, the tab loop
if ui.small_button("×").clicked() {
self.buffers.remove(index);
closing = Some(index);
}Third — and it's carried out after the loop has finished:
editor/src/main.rsin fn ui, just after the tab row closes
});
if let Some(index) = closing {
self.close(index);
}
let buffer = &mut self.buffers[self.active];Fourth — and the method it calls. It goes in impl Editor, above fn open — note that impl Buffer has an fn opentoo, and putting close above the wrong one gives you a method that can't seeself.buffers or self.active at all:
editor/src/main.rsin impl Editor, above fn open
/// Close a buffer, keeping `active` pointing at the file it was on.
fn close(&mut self, index: usize) {
if self.buffers.len() == 1 {
return;
}
self.buffers.remove(index);
if index < self.active {
self.active -= 1;
}
}Collect, then act
Option<usize> is doing real work here. It holds two facts at once —whether anything was clicked and which tab — in a way that makes it impossible to read one without the other. That's the sameOption from lesson 4, used for a different kind of absence.
The pattern generalises well beyond egui: when a loop discovers it wants to change the thing it's looping over, record the intent and apply it afterwards.You'll reach for it again in the file tree, where clicking a folder changes the tree you're in the middle of drawing.
The two guards in close are worth reading. Refusing to close the last buffer isn't politeness — self.buffers[self.active] runs on every frame, and an empty Vec makes that a panic. Until there's a way to create a new empty buffer, which is part 2, one file must always be open.
cargo run -- a.rs b.rs c.rs
→ close the first tab: the editor survives, two tabs remain
→ close tabs until one is left: the last × does nothing
The editor panics again — another
index out of bounds, this time fromlet buffer = &mut self.buffers[self.active];. closeshortened the vector, and self.active is still pointing at where the last buffer used to be.Prove the arithmetic
Four tests. One of them is red.
close does index arithmetic, and index arithmetic is the kind of thing that's right for the case you thought about and wrong for the case you didn't. It's alsopure — no egui, no window, no frame — which sincelesson 10 you know means testable. Two edits — and the second only makes sense once you've run the first. Here's the first, at the bottom of mod tests:
editor/src/main.rsin mod tests, at the end
fn editor_with(names: &[&str]) -> Editor {
let paths: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
Editor::open(&paths)
}
#[test]
fn closing_a_tab_before_the_active_one_keeps_the_same_file() {
let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
editor.active = 2;
editor.close(0);
assert_eq!(editor.buffers[editor.active].name(), "c.rs");
}
#[test]
fn closing_the_active_tab_selects_the_one_that_took_its_place() {
let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
editor.active = 1;
editor.close(1);
assert_eq!(editor.buffers[editor.active].name(), "c.rs");
}
#[test]
fn closing_the_active_last_tab_leaves_the_index_in_range() {
let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
editor.active = 2;
editor.close(2);
assert!(editor.active < editor.buffers.len());
assert_eq!(editor.buffers[editor.active].name(), "b.rs");
}
#[test]
fn the_only_tab_cannot_be_closed() {
let mut editor = editor_with(&["a.rs"]);
editor.close(0);
assert_eq!(editor.buffers.len(), 1);
}
}Editor::open on files that don't exist gives you three perfectly good empty buffers — that's lesson 5's decision that a missing file is a new file, not an error, quietly making these tests possible without touching the disk.
cargo test
test tests::closing_a_tab_before_the_active_one_keeps_the_same_file ... ok test tests::closing_the_active_tab_selects_the_one_that_took_its_place ... ok test tests::the_only_tab_cannot_be_closed ... ok test tests::closing_the_active_last_tab_leaves_the_index_in_range ... FAILED ---- tests::closing_the_active_last_tab_leaves_the_index_in_range stdout ---- assertion failed: editor.active < editor.buffers.len() test result: FAILED. 13 passed; 1 failed
The case the arithmetic missed
close handles one case: a tab before the active one goes away, so everything after it shifts down by one and active shifts with it. That's the case you picture when you write it, and it's correct.
It says nothing about closing the tab at the end while you're on it. Nothing shifts — index < self.active is false when they're equal — soactive stays at 2, and the vector now ends at 1. The index didn't move; the vector moved out from under it.
The second edit — clamp the index to what's actually there:
editor/src/main.rsin Editor::close, at the end
if index < self.active {
self.active -= 1;
}
self.active = self.active.min(self.buffers.len() - 1);
}min is doing the work of an if without the branch:active becomes whichever is smaller, itself or the last valid index. In every case the first rule already handled, it changes nothing. In the case it didn't, it pulls the index back onto the end of the vector.
len() - 1 is safe here, and only hereSubtracting from a usize that's already zero doesn't give you −1 — it panics in a debug build and wraps to a colossal number in release, which is worse. It's safe on this line only because of the guard four lines above: the function returned early if there was one buffer, so by the time you reach this line there are at least two, andlen() - 1 is at least 1. Delete that guard and this line becomes a bug.cargo test
→ test result: ok. 14 passed; 0 failed
cargo run -- a.rs b.rs c.rs
→ select the last tab, close it: you land on the one before it
→ close tabs in any order you like; the editor stays up
name() in lesson 10.Close it from the keyboard
Ctrl+W closes the file you're looking at.
Nobody reaches for the mouse to close a tab. The work is already done — there's a method that closes a buffer by index, and self.active is an index.One edit, just above the block that applies closing:
editor/src/main.rsin fn ui, after the tab row
});
let close_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::W));
if close_requested {
closing = Some(self.active);
}
if let Some(index) = closing {
self.close(index);
}That's the same shape as Ctrl+S from lesson 3, and it feeds the same closing variable the button does. Two ways to ask for the same thing, one place that carries it out — which is why the mouse path and the keyboard path can't drift apart.
modifiers.command rather than ctrl is deliberate, and it's been that way since lesson 3: egui maps it to Ctrl on Linux and Windows and to ⌘ on macOS, so the binding is right on all three without acfg.
cargo run -- a.rs b.rs c.rs
→ press Ctrl+W: the current tab closes and the next one takes over
→ keep pressing: the last one stays put
That's lesson 15, and it's the last one of this part.
Where you ended up
508 lines, fourteen tests green. Two regions changed: Editor gained a method, and the tab row learned to ask for things.
editor/src/main.rsthe top of impl Editor
impl Editor {
/// Close a buffer, keeping `active` pointing at the file it was on.
fn close(&mut self, index: usize) {
if self.buffers.len() == 1 {
return;
}
self.buffers.remove(index);
if index < self.active {
self.active -= 1;
}
self.active = self.active.min(self.buffers.len() - 1);
}Editor::open, the whole of Text and Buffer, the scanner, the status panel and main are exactly aslesson 13 left them.editor/src/main.rsin fn ui, the central panel down to the text box
let mut closing = None;
ui.horizontal(|ui| {
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, label).clicked() {
self.active = index;
}
if ui.small_button("×").clicked() {
closing = Some(index);
}
}
});
let close_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::W));
if close_requested {
closing = Some(self.active);
}
if let Some(index) = closing {
self.close(index);
}
let buffer = &mut self.buffers[self.active];Check yourself
Why did removing a buffer inside the tab loop compile?
The borrow checker tracks references, and there was no live reference into the vector at that point. 0..len() was evaluated once, so the loop kept visiting indices that no longer existed — a logic error, caught at run time by a bounds check rather than at compile time.↑ Review: why the compiler let you (stage 1)
What does Option<usize> buy over a usize and a bool?
The two facts — whether anything was requested and which tab — are welded together, so there's no way to use a stale index by forgetting to check the flag. SameOption as lesson 4's cursor, a different kind of absence.↑ Review: collect, then act (stage 2)
Which case did if index < self.active alone fail to handle?
When index == self.active nothing shifts, soactive stayed where it was — pointing one past the end of a vector that had just shrunk. The clamp pulls it back onto the last valid index.↑ Review: the case the arithmetic missed (stage 3)
Why is self.buffers.len() - 1 safe on that line?
Underflowing a usize panics in debug and wraps to an enormous number in release. The line is only reachable when at least two buffers remain, because the function returned early otherwise — remove that guard and this becomes a bug.↑ Review: why len() − 1 is safe (stage 3)
Read this before lesson 15
Primary source:Vec::retainand its neighbours in the Vec docs. close usesremove, which is right for one known index — but the family around it (retain, swap_remove, drain) is worth ten minutes, because each one exists to avoid exactly the bug stage 1 hit.
swap_remove is the interesting one: it's O(1) instead of O(n) because it moves the last element into the hole rather than shifting everything down. It would be wrong here — it would shuffle your tabs — and knowing why it's wrong is worth more than knowing it exists.