<< Back / Lesson 15 / Part 1: Don't lose my work / ~35 minutes
Being Asked First
Three stages, your first hand-written enum, and the end of part 1.
Lesson 13 taught the editor to know when a buffer has unsaved edits. Lesson 14 gave you a way to close a buffer. Put those together and you have an editor that knows perfectly well it's about to throw your work away, and does it anyway.
This lesson makes it ask. It's the last of part 1, and at the end of it the bug that opened lesson 13 — close the window, lose the afternoon — is gone by every route.
The fix is not a warning. It's a question, asked before anything happens.
Where you're starting
Lesson 14's editor, with closable tabs. Check againstlesson 14's checkpoint.
adding removing existing code, shown only for context
Ask before closing
Closing a modified buffer puts a question on screen instead.
The close path already knows everything it needs — closing holds the index and buffer.dirty says whether it matters. What's missing is somewhere topark the request while the question is on screen. Five edits.
First — the parking space:
editor/src/main.rsthe Editor struct
struct Editor {
buffers: Vec<Buffer>,
active: usize,
/// A buffer whose closing is held up until the question on screen is answered.
confirm: Option<usize>,
position: String,Second — nothing is pending when the editor starts:
editor/src/main.rsin Editor::open
buffers,
active: 0,
confirm: None,
position: "no cursor".to_owned(),Third — closing now branches on whether there's anything to lose:
editor/src/main.rsin fn ui, where closing is applied
if let Some(index) = closing {
self.close(index);
if self.buffers[index].dirty {
self.confirm = Some(index);
} else {
self.close(index);
}
}A clean buffer still closes instantly. Asking about something with nothing to lose is how confirmation dialogs train people to click through them without reading — the question is only worth asking when the answer could differ.
Fourth — the question itself, as a new impl Editor block just aboveimpl eframe::App for Editor:
editor/src/main.rsabove impl eframe::App for Editor
impl Editor {
/// Draw the question, and act on the answer.
fn ask(&mut self, ui: &egui::Ui, index: usize) {
let question = format!("{} has unsaved changes.", self.buffers[index].name());
egui::Modal::new(egui::Id::new("confirm")).show(ui.ctx(), |ui| {
ui.heading("Unsaved changes");
ui.label(question);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Discard").clicked() {
self.confirm = None;
self.close(index);
}
if ui.button("Cancel").clicked() {
self.confirm = None;
}
});
});
}
}What a modal is, and why it takes a Context
egui::Modal is a centred window with a backdrop that swallows clicks aimed at anything behind it. That backdrop is the whole point: while the question is up, the tab bar and the text box are still drawn, but nothing you click on them reaches them.
Notice it takes ui.ctx() rather than ui. Everything you've drawn so far has gone into a panel — a region with a position in a layout. A modal doesn't belong to any region; it floats above all of them, so it's addressed to thecontext, the thing that owns the whole frame. That's also why it can be called from the end of fn ui, after every panel has closed.
question is built before the closureformat!("{} has unsaved changes.", self.buffers[index].name()) borrowsself. The closure below it needs &mut self to callself.close(index). Building the string first, into aString the closure owns, ends that borrow before the closure starts — otherwise this is E0502, a borrow of self.buffers still alive while self is borrowed mutably.Fifth — draw it, at the very end of fn ui:
editor/src/main.rsin fn ui, just above the frame timer
if let Some(index) = self.confirm {
self.ask(ui, index);
}
self.frame_ms = frame_start.elapsed().as_secs_f32() * 1000.0;printf 'fn a() {}\n' > a.rs; printf 'fn b() {}\n' > b.rs
cargo run -- a.rs b.rs
→ type into a tab so it shows its •, then close it
→ the question appears; Cancel puts it back, Discard closes it
→ close a tab you haven't touched: it goes straight away, no question
Ask before quitting too
The window's close button stops being fatal.
Closing a tab now asks. Closing the window still doesn't — and that's the route that started lesson 13, where the whole afternoon went. It's also the route whereOption<usize> stops being enough: there's no index to put in it. Quitting isn't about a buffer.
You need a type that says "one of these two things", and Rust's word for that isenum. You've used two of them constantly — Option andResult — without writing one. Nine edits, and the file won't compile until they're all in.
First — the type, just above struct Buffer:
editor/src/main.rsabove the Buffer struct
/// Something the editor is waiting for permission to do.
#[derive(Clone, Copy)]
enum Pending {
Close(usize),
Quit,
}An enum is a choice, and it can carry luggage
A Pending is either a Close — which carries ausize, the buffer it means — or a Quit, which carries nothing because there's nothing to say. Not both, never neither.
That's the same shape as the enums you already use.Option::Some carries a value and None doesn't;Result::Ok and Err each carry something different. Rust's enums are not C's numbered constants — each variant is its own little struct, and the compiler makes you say which one you have before you can reach inside.
#[derive(Clone, Copy)] is what lets you writeif let Some(pending) = self.confirm and get a copy rather than a borrow. Without it that line would borrow self.confirm, and the borrow would still be alive when the closure below tried to set it — E0502 again. ACopy enum this small is two machine words; copying it is cheaper than reasoning about who borrowed it.
Second — the field changes type, and a second one joins it:
editor/src/main.rsthe Editor struct
/// A buffer whose closing is held up until the question on screen is answered.
confirm: Option<usize>,
/// An action held up until the question on screen is answered.
confirm: Option<Pending>,
/// Set once the user has answered "quit anyway", so the close we send
/// ourselves isn't mistaken for a fresh request and cancelled again.
quitting: bool,quitting will look like it does nothing until you reach the last edit of this stage, and the reason it exists is two sections below. Type it now so the struct is right; it earns its keep in a few paragraphs.Third — neither field means anything at start-up:
editor/src/main.rsin Editor::open
confirm: None,
quitting: false,
position: "no cursor".to_owned(),Fourth — and so does what gets parked in it:
editor/src/main.rsin fn ui, where closing is applied
if self.buffers[index].dirty {
self.confirm = Some(index);
self.confirm = Some(Pending::Close(index));
} else {Fifth — quitting needs to know whether anything is at stake, which is a count:
editor/src/main.rsin impl Editor, above fn close
/// How many buffers hold edits that aren't on disk?
fn unsaved(&self) -> usize {
self.buffers.iter().filter(|buffer| buffer.dirty).count()
}Sixth — the question now has two forms:
editor/src/main.rsin fn ask, the top
/// Draw the question, and act on the answer.
fn ask(&mut self, ui: &egui::Ui, index: usize) {
let question = format!("{} has unsaved changes.", self.buffers[index].name());
fn ask(&mut self, ui: &egui::Ui, pending: Pending) {
let question = match pending {
Pending::Close(index) => format!("{} has unsaved changes.", self.buffers[index].name()),
Pending::Quit => format!("{} file(s) have unsaved changes.", self.unsaved()),
};That match is exhaustive, and the compiler checks it. Add a third variant to Pending later — say CloseAll — and everymatch on it becomes an error until you've said what the new case does. That is the property that makes enums worth reaching for.
Seventh — Discard stops knowing what it's discarding, and defers to a method that does:
editor/src/main.rsin fn ask, the Discard button
if ui.button("Discard").clicked() {
self.confirm = None;
self.close(index);
self.carry_out(pending, ui);
}Eighth — that method, just after fn ask:
editor/src/main.rsin impl Editor, after fn ask
/// Do the thing that was waiting on an answer.
fn carry_out(&mut self, pending: Pending, ui: &egui::Ui) {
self.confirm = None;
match pending {
Pending::Close(index) => self.close(index),
Pending::Quit => {
self.quitting = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}Ninth — and the quit itself, replacing the block you added in stage 1:
editor/src/main.rsin fn ui, just above the frame timer
if let Some(index) = self.confirm {
self.ask(ui, index);
}
if ui.input(|i| i.viewport().close_requested()) && self.unsaved() > 0 && !self.quitting {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.confirm = Some(Pending::Quit);
}
if let Some(pending) = self.confirm {
self.ask(ui, pending);
}Cancelling a close that has already happened
The window manager doesn't ask permission — it tells you. By the timeclose_requested() is true, the decision has been made and egui will shut the application down at the end of this frame unless you say otherwise.
ViewportCommand::CancelClose is saying otherwise. It's not "please don't close"; it's "revoke the close that's already in flight". Send it, park aPending::Quit, and the window stays up with a question on it. When the answer comes, carry_out sends ViewportCommand::Close.
Why quitting exists
Now the field from the second edit. This is not something you type — it is how egui-winit 0.36.1 carries out the close you just sent, insrc/lib.rs, process_viewport_command:
match command {
ViewportCommand::Close => {
info.events.push(egui::ViewportEvent::Close);
}It pushes the same event that close_requested() reads. Your close comes straight back at you on the next frame, indistinguishable from the one the window manager sent. And Discard discarded nothing — it only stopped asking — soself.unsaved() is still above zero. Without the flag, the check fires again, cancels your own close, and puts the question back up. Forever:
close requested -> confirm=Some(Quit) cmds=["CancelClose"]
Discard clicked -> confirm=None cmds=["Close"]
next frame -> confirm=Some(Quit) cmds=["CancelClose"]
next frame -> confirm=Some(Quit) cmds=["CancelClose"]
next frame -> confirm=Some(Quit) cmds=["CancelClose"]On screen that reads as Discard doing nothing at all: the dialog blinks and is back. quitting is the one bit that tells the two closes apart — a request from outside, which deserves a question, versus the one you sent yourself, which is the answer.
Note that Save, in stage 3, would have hidden this: it writes the files, so unsaved() drops to zero and the check can't fire a second time. A bug that only one of three buttons can reach is exactly the kind a run-through finds and a glance at the code does not.
cargo run -- a.rs b.rs
→ type into a tab, then click the window's close button
→ the window stays, and asks
→ Cancel keeps working; Discard quits
→ with nothing modified, the close button just closes
close_requested() is self.events.contains(&ViewportEvent::Close)and that events vector is public, so the quit path can be driven headlessly: push the event in, run a frame, read the viewport commands back out. That is how the cases above were checked — but the first version of this stage shipped withoutquitting, because the headless harness does not do what egui-winit does.Context::run_ui hands you the commands your app sent; it never feeds aClose command back in as a Close event. So the loop above was impossible in the test and inevitable in the real window. Deryk found it by clicking the button. The lesson now checks the echo explicitly, by pushing the event back in the way winit would.Offer to save, not just to lose
A third button that does the obvious thing.
Right now the question has two answers, and both of them are bad: throw the work away, or don't do the thing you asked for. The answer anyone actually wants is "save it, then carry on". Three edits.
First — the button, ahead of the other two:
editor/src/main.rsin fn ask, the button row
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
self.save_for(pending);
self.carry_out(pending, ui);
}
if ui.button("Discard").clicked() {Save is first because it's the answer that loses nothing. In a dialog about destroying work, the safe option should be the one your hand is already on.
Second — what it calls:
editor/src/main.rsin impl Editor, above fn carry_out
/// Write out whatever the pending action is about to throw away.
fn save_for(&mut self, pending: Pending) {
let indices: Vec<usize> = match pending {
Pending::Close(index) => vec![index],
Pending::Quit => (0..self.buffers.len())
.filter(|index| self.buffers[*index].dirty)
.collect(),
};
for index in indices {
if let Err(error) = self.buffers[index].save() {
self.buffers[index].status = format!("save failed: {error}");
}
}
}Why the indices are collected first
The obvious version of that function iterates the buffers and saves the dirty ones in one pass. It doesn't compile: iterating self.buffers borrows it, andbuffer.save() needs &mut — you'd be reading and writing the same vector at once.
Collecting the indices first ends the read before the writes start. That's the same move as lesson 14'sclosing variable: decide while you're reading, act once you've stopped. The difference is instructive — in lesson 14 the compiler let the bad version through and it panicked at run time, because indices aren't borrows. Here the values are borrows, so it's a compile error instead.
Third — three tests, at the bottom of mod tests:
editor/src/main.rsin mod tests, at the end
#[test]
fn a_fresh_editor_has_nothing_unsaved() {
assert_eq!(editor_with(&["a.rs", "b.rs"]).unsaved(), 0);
}
#[test]
fn unsaved_counts_every_modified_buffer() {
let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
editor.buffers[0].dirty = true;
editor.buffers[2].dirty = true;
assert_eq!(editor.unsaved(), 2);
}
#[test]
fn closing_a_buffer_forgets_its_unsaved_edits() {
let mut editor = editor_with(&["a.rs", "b.rs"]);
editor.buffers[0].dirty = true;
editor.close(0);
assert_eq!(editor.unsaved(), 0);
}
}cargo clippy --all-targets && cargo test
→ silent, test result: ok. 17 passed; 0 failed
cargo run -- a.rs b.rs
→ modify both tabs, then click the window's close button
→ 2 file(s) have unsaved changes. — click Save
cat a.rs b.rs
→ both files hold what you typed, and the editor has quit
Where you ended up
620 lines, seventeen tests green. This lesson touched five places in the file; all five are below, in file order. Everything between them is exactly as lesson 14 left it.
editor/src/main.rsabove the Buffer struct
#[derive(Clone, Copy)]
enum Pending {
Close(usize),
Quit,
}struct Buffer and its impl — open,save, line_col, name — are exactly as lesson 14 left them. The dirty field on Buffer came from lesson 13.editor/src/main.rsthe Editor struct and the impl above fn ask
struct Editor {
buffers: Vec<Buffer>,
active: usize,
/// An action held up until the question on screen is answered.
confirm: Option<Pending>,
/// Set once the user has answered "quit anyway", so the close we send
/// ourselves isn't mistaken for a fresh request and cancelled again.
quitting: bool,
position: String,
frame_ms: f32,
line_col_us: f32,
}
impl Editor {
/// How many buffers hold edits that aren't on disk?
fn unsaved(&self) -> usize {
self.buffers.iter().filter(|buffer| buffer.dirty).count()
}
/// 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);
}
fn open(paths: &[String]) -> Self {
let buffers = paths.iter().map(|p| Buffer::open(p)).collect();
Self {
buffers,
active: 0,
confirm: None,
quitting: false,
position: "no cursor".to_owned(),
frame_ms: 0.0,
line_col_us: 0.0,
}
}
}editor/src/main.rsthe confirmation, above impl eframe::App
impl Editor {
/// Draw the question, and act on the answer.
fn ask(&mut self, ui: &egui::Ui, pending: Pending) {
let question = match pending {
Pending::Close(index) => format!("{} has unsaved changes.", self.buffers[index].name()),
Pending::Quit => format!("{} file(s) have unsaved changes.", self.unsaved()),
};
egui::Modal::new(egui::Id::new("confirm")).show(ui.ctx(), |ui| {
ui.heading("Unsaved changes");
ui.label(question);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
self.save_for(pending);
self.carry_out(pending, ui);
}
if ui.button("Discard").clicked() {
self.carry_out(pending, ui);
}
if ui.button("Cancel").clicked() {
self.confirm = None;
}
})
});
}
/// Write out whatever the pending action is about to throw away.
fn save_for(&mut self, pending: Pending) {
let indices: Vec<usize> = match pending {
Pending::Close(index) => vec![index],
Pending::Quit => (0..self.buffers.len())
.filter(|index| self.buffers[*index].dirty)
.collect(),
};
for index in indices {
if let Err(error) = self.buffers[index].save() {
self.buffers[index].status = format!("save failed: {error}");
}
}
}
/// Do the thing that was waiting on an answer.
fn carry_out(&mut self, pending: Pending, ui: &egui::Ui) {
self.confirm = None;
match pending {
Pending::Close(index) => self.close(index),
Pending::Quit => {
self.quitting = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
}editor/src/main.rsin fn ui — two regions, with the rest elided
impl eframe::App for Editor {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
the status panel and the tab row, unchanged from lesson 14 — about 45 lines
if let Some(index) = closing {
if self.buffers[index].dirty {
self.confirm = Some(Pending::Close(index));
} else {
self.close(index);
}
}
the central panel: the save shortcut, the heading, the scroll area and the TextEdit — about 35 lines
if ui.input(|i| i.viewport().close_requested()) && self.unsaved() > 0 && !self.quitting {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.confirm = Some(Pending::Quit);
}
if let Some(pending) = self.confirm {
self.ask(ui, pending);
}
self.frame_ms = frame_start.elapsed().as_secs_f32() * 1000.0;
}
}editor/src/main.rsin mod tests, at the end
#[test]
fn a_fresh_editor_has_nothing_unsaved() {
assert_eq!(editor_with(&["a.rs", "b.rs"]).unsaved(), 0);
}
#[test]
fn unsaved_counts_every_modified_buffer() {
let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
editor.buffers[0].dirty = true;
editor.buffers[2].dirty = true;
assert_eq!(editor.unsaved(), 2);
}
#[test]
fn closing_a_buffer_forgets_its_unsaved_edits() {
let mut editor = editor_with(&["a.rs", "b.rs"]);
editor.buffers[0].dirty = true;
editor.close(0);
assert_eq!(editor.unsaved(), 0);
}
}Check yourself
Why did Option<usize> stop being enough in stage 2?
The pending action became one of two shapes — close this buffer, or quit, which carries nothing. An enum says "one of these", and each variant carries exactly the luggage its case needs.↑ Review: an enum is a choice (stage 2)
What does ViewportCommand::CancelClose actually do?
By the time close_requested() is true the decision is made and egui will shut down at the end of the frame. CancelClose takes it back; without it, your question would appear for one frame and then vanish with the window.↑ Review: cancelling a close (stage 2)
Drop the quitting flag and click Discard on the quit question. What happens?
egui-winit carries out ViewportCommand::Close by pushing the very event close_requested() reads. Your own close arrives next frame looking like a fresh one, unsaved() is still above zero, and the check cancels it and asks again.↑ Review: why quitting exists (stage 2)
Why is question built before the modal's closure rather than inside it?
Reading self.buffers[index].name() borrowsself. The closure calls self.close(index), which needs a mutable borrow. Producing an owned String first ends the first borrow before the second begins.↑ Review: why question is built first (stage 1)
Why does save_for collect indices before saving anything?
You'd be reading the vector and writing to its elements at once. Collecting ends the read first — the same "decide while reading, act after" move as lesson 14, except here the compiler rejects the bad version instead of letting it panic.↑ Review: why the indices are collected (stage 3)
Part 1 is finished
Three lessons ago the editor would silently eat an afternoon's work. It now knows what you've changed, shows you on the tab, refuses to close a buffer or the window without asking, and offers to save when you say yes. The bug that openedlesson 13 is closed by every route into it.
What you picked up on the way, in Rust terms: change detection against a cheap snapshot, the borrow rules around closures that mutate what they read, deferred mutation as a pattern, index arithmetic that a test caught before you did, and your own enum with data in its variants.
Primary source:The Rust Programming Language, Chapter 6 — Enums and Pattern Matching. You've now written one, so the chapter will read as confirmation rather than introduction — which is the right order. 6.2 on match and 6.3 on if let are the ones worth slowing down for, because you've been using both since lesson 2 without ever seeing them defined.