Deryks _dev

Full-Stack Web Developer & Technical Leader

<< 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.

A 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.

Do this before you write any save codeRight now your editor opens 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 value
Before 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 longWritten 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)

Give yourself write permission backchmod 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.md

The 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")))),
    )
}
ConstructWhat it is
-> eframe::ResultThe 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?

  • A failed save would look exactly like a good one
  • The function must be called with an explicit type
  • Saving inside a closure needs an exclusive borrow
  • Every method returning a value must be assigned

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?

  • Returns it to the caller straight away
  • Panics with the message inside it
  • Converts it into a default success value
  • Retries the operation once, then gives up

? 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?

  • The editor must own it, outliving the call
  • Borrowed strings cannot be printed to screen
  • Only owned strings can be compared for equality
  • The filesystem functions require owned strings

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.