<< Back / Lesson 03 / Text Editor GUI in Rust / ~30 minutes
Saving Your Work
Four stages to Ctrl+S — and the boilerplate lesson 1 owed you.
Reading was the easy direction. Writing is where an editor can do damage: it can put the wrong bytes in the right file, or the right bytes in the wrong one, and either way somebody's work is gone.
So this lesson is about the unhappy path again — but from the other side. Lesson 2 was "you must handle the failure." This one is "you must not silently handle it," and Rust has a specific mechanism to stop you.
Result you ignore is a bug you've already written.Rust marks it
#[must_use], so throwing one away isn't a decision you can make by accident — only on purpose, in writing.Where you're starting
Your editor/src/main.rs should be lesson 2's 45 lines, ending withEditor::open("src/main.rs"). Check it againstlesson 2's checkpoint if you're unsure.
adding removing existing code, shown only for context
Point it somewhere safe
The editor opens a scratch file instead of its own source.
src/main.rs. In two stages it will startwriting to whatever it opened. Aim that at your own source and the first mistyped keystroke overwrites the program you're building — with a version that no longer compiles, while you're running it. Point it at a throwaway file first. This is a habit, not a one-off: when you give a tool the power to destroy something, take the valuable thing out of range before you test it.cd editor
printf 'scratch file\n\nedit me freely\n' > scratch.md
One edit — open that instead:
editor/src/main.rsat the bottom of main
Box::new(|_cc| Ok(Box::new(Editor::open("src/main.rs")))),
Box::new(|_cc| Ok(Box::new(Editor::open("scratch.md")))),cargo run
→ a window showing scratch file / edit me freely
Remember which file
The window shows the name of the file you're editing.
open takes a path, uses it, and forgets it. To save, the editor has to still know where the text came from — so the path has to become part of the editor's state, not just an argument that passes through. Three edits.
First — give the struct somewhere to keep it:
editor/src/main.rsthe Editor struct
struct Editor {
path: String,
text: String,
}String, not &str. The editor has to own its path — it must stay alive for as long as the editor does, and a borrowed&str would be tied to whatever it was borrowed from.
Second — fill it in:
editor/src/main.rsin Editor::open
};
Self { text }
Self {
path: path.to_owned(),
text,
}
}The parameter is still &str — borrowed, so callers can pass a literal — and to_owned() makes the editor's own copy. Note the two styles side by side: path: path.to_owned() spells out the field, while textuses the shorthand from lesson 2 because the variable already has the field's name.
Third — show it, replacing the hardcoded heading:
editor/src/main.rsin fn ui
ui.heading("editor");
ui.heading(&self.path);cargo run
→ the window is now headed scratch.md
Write it back — and get told off
Ctrl+S changes the file on disk. The compiler is not happy about how.
Three edits. First, one more module:
editor/src/main.rstop of the file
use eframe::egui;
use std::fs;
use std::io;Second — teach the editor to write. Put this inside your existingimpl Editor block, just after open:
editor/src/main.rsin impl Editor, after open
}
/// Write the buffer back to the file it came from.
fn save(&self) -> io::Result<()> {
fs::write(&self.path, &self.text)
}
}io::Result<()> is the same Result from lesson 2 with the error type already filled in — io::Result<T> is just a shorthand for Result<T, io::Error>. The () is theunit type: "succeeded, and there's no value to give you." A write either worked or it didn't; there's nothing to hand back.
There's no match here and no semicolon on that last line. The function just returns whatever fs::write returned — success or failure, untouched, straight up to whoever called it.
Third — trigger it. Add this as the first thing inside the panel closure:
editor/src/main.rsin fn ui, top of the panel closure
egui::CentralPanel::default().show(ui, |ui| {
let save_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S));
if save_requested {
self.save();
}
ui.heading(&self.path);ui.input(…) hands you this frame's input state to ask questions of.modifiers.command is Ctrl on Linux and Windows, ⌘ on macOS — one check for both. key_pressed is true only on the frame the key went down, not while it's held, which is what stops one keystroke from saving sixty times a second.
cargo run
→ type something, press Ctrl+S, then in another terminal:
cat editor/scratch.md
Your edits are there. It genuinely saved.
It works. It also produces this:
warning: unused `std::result::Result` that must be used
--> src/main.rs:39:17
|
39 | self.save();
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting valueBefore reading on: the program runs and the file saves. So what exactly is the compiler worried about?
The save that doesn't work. self.save(); throws away the answer to "did that succeed?", so a failed write looks exactly like a successful one — from inside the program, and from where you're sitting. The bug isn't visible today because the write happened to succeed.
Result is marked #[must_use], an attribute that tells the compiler to complain if a value is dropped on the floor. Very few types carry it. This one does because a discarded Result is almost always a mistake — it's an error that was reported to you and that you didn't read.
Note the help: line: let _ = … silences it. That's the escape hatch for when ignoring really is right, and it's deliberately something you have to type. The point isn't that you can never discard aResult — it's that you can't do it silently.
Say what happened
A status line reports every save — including the ones that fail.
Five edits, and the file won't compile until all five are in.
First and second — the editor needs somewhere to put a message, andopen has to start it empty:
editor/src/main.rsthe Editor struct
struct Editor {
path: String,
text: String,
status: String,
}editor/src/main.rsin Editor::open
Self {
path: path.to_owned(),
text,
status: String::new(),
}Third — save gains a second job, and that's where ?arrives:
editor/src/main.rsin impl Editor
fn save(&self) -> io::Result<()> {
fs::write(&self.path, &self.text)
fn save(&mut self) -> io::Result<()> {
fs::write(&self.path, &self.text)?;
self.status = format!("saved {} bytes to {}", self.text.len(), self.path);
Ok(())
}That ? is the whole idea of this lesson. Read it as: if this is anErr, stop here and return it to my caller; otherwise unwrap theOk and carry on. It's the same match you wrote in lesson 2, compressed into one character:
| Written long | Written with ? |
|---|---|
match fs::write(…) { Ok(v) => v, Err(e) => return Err(e), } | fs::write(…)? |
The point isn't brevity — it's who decides. save has no idea whether a failed write should show a message, retry, or quit; it isn't the part of the program that knows. So it doesn't decide. It hands the failure upward and lets the caller, which has the context, do something sensible. That's propagation, and it's why the status line only gets set on the success path — a failed write returns before it ever gets there.
Also note &self became &mut self:save now modifies status, and bylesson 1's rule that needs an exclusive borrow. Miss this edit and the compiler will tell you, in almost the same words as lesson 1.
Fourth — the caller, which does know what to do:
editor/src/main.rsin fn ui, top of the panel closure
let save_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S));
if save_requested {
self.save();
}
if save_requested && let Err(error) = self.save() {
self.status = format!("save failed: {error}");
}Fifth — somewhere to show it. This is an extra label, added below the byte counter; the byte counter stays.
editor/src/main.rsin fn ui, below the byte counter
ui.label(format!("{} bytes", self.text.len()));
ui.label(&self.status);if … let Err(error) = self.save() is a let-chain: run the save only if a save was asked for, and take the branch only if the result matchedErr. The let Err(error) = … half is if let — amatch when you only care about one shape and want to ignore the rest. You could write it as a two-armed match with an empty Ok arm;if let says the same thing without the ceremony.
I didn't choose this shape — clippy did. The obvious version nests an if let inside an if, and clippy answers"this if statement can be collapsed". Run cargo clippyoften enough and it will teach you the idioms without you going looking for them.
cargo run
→ press Ctrl+S: saved 31 bytes to scratch.md
And the warning is gone — cargo clippy should now be silent.
Now make it fail
A status line that only ever says nice things proves nothing. Take away your own permission to write the file:
chmod 444 editor/scratch.md
→ run again, edit, Ctrl+S: save failed: Permission denied (os error 13)
chmod 444 made the file read-only, and it will stay that way. Every save from here on fails until you undo it — including in later lessons, where the failure would look like a bug you'd just introduced.Run:
chmod 644 editor/scratch.mdThe editor stayed open, kept your text, and told you what went wrong. Compare that with the two alternatives you've now seen: unwrap() would have killed the process mid-edit and lost the buffer, and the ignored Result from stage 3 would have told you it saved when it hadn't — which is worse than crashing, because you'd have closed the window believing your work was on disk.
The boilerplate lesson 1 deferred
Lesson 1 said main's setup used four language features that wouldn't be useful to you yet, and promised to come back. You now know enough for it to be interesting rather than intimidating. Here it is, token by token:
editor/src/main.rsthe whole of main
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]),
..Default::default()
};
eframe::run_native(
"editor",
options,
Box::new(|_cc| Ok(Box::new(Editor::open("scratch.md")))),
)
}| Construct | What it is |
|---|---|
-> eframe::Result | The same Result you've been handling all lesson. mainis allowed to return one: if it ends up Err, Rust prints it and exits non-zero. That's why there's no semicolon on the run_native call — itis the return value, propagating failure exactly like ? does. |
..Default::default() | Struct update syntax. "Set viewport as written; take every other field from the default." NativeOptions has dozens of fields and you care about one. Same Default trait your Editorimplemented until lesson 2. |
|_cc| … | A closure — the same anonymous-function syntax as|ui| in the panel and |i| in the input check. It takes a creation context you don't need, so the name is underscored. It's a closure rather than a value because eframe has to build your app after it has a window and a GPU, not before. |
Box::new(…) | Puts a value on the heap and hands you a pointer to it. Needed here because eframe accepts any type implementing App, and different types have different sizes — so it can't hold one directly. Boxing makes every app the same size: one pointer. The inner Box wraps yourEditor; the outer one wraps the closure, for the same reason. |
Ok(…) | The closure returns a Result too, so an app that fails to start can say so instead of panicking. Yours can't fail, so it's always Ok. |
Nothing there is exotic. It's Result, Default, closures, and one allocation — and you'd already met three of the four by the end of lesson 1. That's usually how deferred complexity turns out.
Where you ended up
65 lines, after cargo fmt.
editor/src/main.rs
use eframe::egui;
use std::fs;
use std::io;
struct Editor {
path: String,
text: String,
status: String,
}
impl Editor {
/// Open a file into a new editor. A failure becomes the buffer contents,
/// so the window always opens.
fn open(path: &str) -> Self {
let text = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) => format!("could not open {path}\n\n{error}"),
};
Self {
path: path.to_owned(),
text,
status: String::new(),
}
}
/// Write the buffer back to the file it came from.
fn save(&mut self) -> io::Result<()> {
fs::write(&self.path, &self.text)?;
self.status = format!("saved {} bytes to {}", self.text.len(), self.path);
Ok(())
}
}
impl eframe::App for Editor {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
let save_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S));
if save_requested && let Err(error) = self.save() {
self.status = format!("save failed: {error}");
}
ui.heading(&self.path);
ui.add(
egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY),
);
ui.label(format!("{} bytes", self.text.len()));
ui.label(&self.status);
});
}
}
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]),
..Default::default()
};
eframe::run_native(
"editor",
options,
Box::new(|_cc| Ok(Box::new(Editor::open("scratch.md")))),
)
}Check yourself
Two on today, one reaching back to lesson 2. Each explanation links to where the idea was taught.
Why does the compiler warn about self.save(); when the program runs fine?
Result is #[must_use]. Discarding it throws away the only report of whether the write worked, so a failure becomes silent — the worst kind, because you'd close the window believing your work was saved.↑ Review: the must_use warning (stage 3)
What does ? do when fs::write returns an Err?
? is early return. save doesn't know whether a failed write deserves a message or a retry, so it propagates the error to the caller, which does. That's also why the status line never gets set on a failed save.↑ Review: what ? expands to (stage 4)
From lesson 2: why is path stored as String when open takes &str?
A &str is borrowed from something else and lives only as long as that thing does. The field has to survive for the editor's whole life, so it owns a copy — while the parameter stays borrowed, so callers can pass a literal.↑ Review: &str vs String (lesson 2, stage 4)
Read this before lesson 4
Primary source:The Rust Programming Language, Chapter 6 — Enums and Pattern Matching(Brown University interactive edition).
You've now used enums three lessons running without meeting them properly:Result is one, match exists to take them apart, andif let is the shorthand you just wrote. Chapter 6 covers all three from the ground up, and introduces Option — the enum for "there might not be one here", which is the first thing lesson 4 needs when we ask where the cursor is.