<< Back / Lesson 12 / Text Editor GUI in Rust / ~35 minutes
Making It a Work Sample
Four stages, three of them outside main.rs — and the last lesson of the tutorial.
The editor works. That was never the whole mission: it says this repo is something a hiring manager can open, run, and judge in sixty seconds, and that "legibility counts as much as features".
So this lesson changes almost no behaviour. It takes out a line that has been lying to you since lesson 6, moves a promise from your head into the build, makes the binary worth downloading, and writes the file a reviewer actually reads first.
Cargo.toml is a build error.Where you're starting
Lesson 11's 415 lines, with the rope. Check againstlesson 11's checkpoint.
adding removing existing code, shown only for context
Stop burning a core
The editor goes back to sleep when you stop typing.
Lesson 1 made a claim about immediate-mode GUIs that has been false in your editor since lesson 6: that egui only repaints when something happens, so an idle editor costs nothing. Lesson 6 needed a live frame counter, so it added this — andsaid at the time that it was exactly what you'd take out afterwards. This is afterwards. One edit.
editor/src/main.rsat the top of fn ui
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let frame_start = Instant::now();
ui.ctx().request_repaint();
egui::Panel::bottom("status").show(ui, |ui| {What that line was actually doing
Every frame, egui reports how long the host should wait before drawing again. I measured it with the widget idle and nothing happening:
| Idle frame | Next repaint requested in |
|---|---|
with request_repaint() | 0ns — immediately, forever |
| without it | never — wait for input |
Not "sooner". Zero. The window was asking to be redrawn as fast as the display could take it, for as long as it was open, whether or not anything had changed — which on a laptop is a fan and an hour of battery. Removing one line turns a busy loop into a program that sleeps.
frame_ms and line_col now update only on frames that actually happen, which is to say the frames you caused. Type, and the numbers move. Stop, and they hold the cost of the last real frame. That's more useful than the flicker, and it's the number you wanted all along: what a keystroke costs.cargo run -- src/main.rs
→ type, and ms/frame moves; stop, and it holds
→ the process goes idle instead of spinning
If you have a system monitor open, this is the most visible change in the lesson.
Move a promise into the build
cargo clippy fails if anyone adds an unwrap().
Your mission says "no unwrap() in the hot path, errors handled deliberately". You've kept that since lesson 2 — there is not one unwrap() in the program outside the tests. But nothing enforces it, and a promise nothing enforces is a promise until the first hurried afternoon. Two edits.
First — say it in the manifest:
editor/Cargo.tomlat the bottom
# The mission says "no unwrap() in the hot path". This is that promise, enforced.
[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"A [lints] table applies to the whole crate and travels with the repo, so it holds for anyone who clones it — unlike a flag you remember to type.
cargo clippy
→ silent
Silent — and that is the trap. Run it the other way:
cargo clippy --all-targets
→ error: used `unwrap()` on an `Option` value
→ --> src/main.rs:406:20
Clippy has been skipping your tests this whole time
By default cargo clippy checks the binary and nothing else.--all-targets adds tests, benches and examples. Everycargo clippy you've run since lesson 1 has been ignoring the test module — which is not a small thing, given lesson 10 put a tenth of the program in there.
The error it finds is real, and the right fix is not to remove theunwrap(). In a test, a panic is the failure report: that's whatassert_eq! does. The lint is right about production code and wrong about tests, so say so, in the one place it applies:
editor/src/main.rsabove mod tests
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "a panic is how a test reports failure")]
mod tests {The reason = "…" is not decoration. An#[allow] without one is indistinguishable from an #[allow] that somebody added to make a warning go away, and reviewers treat them the same.
indexing_slicing = "warn" flags every &rest[..end] in the scanner and every self.buffers[self.active] — five sites, all safe by construction, none of which I'd change. A lint that produces warnings you intend to ignore trains you to ignore warnings, so it's worse than no lint. Enable the ones you will actually obey.cargo clippy --all-targets
→ silent
Use this form from now on. It is the one that checks everything.
Ship a binary worth downloading
The release build is 40% smaller.
Cargo's default release profile optimises for a reasonable build time, because that's what you want a hundred times a day. A binary someone downloads once is the other case.One edit.
editor/Cargo.tomlabove the lints table
# A binary someone downloads is worth 50 seconds of build time.
[profile.release]
lto = "fat"
codegen-units = 1
strip = trueWhat you're buying, and what it costs
Measured on this machine, building the editor from clean:
| Profile | Binary | Clean release build |
|---|---|---|
| Cargo's default | 26.0 MB | 39.0 s |
strip = true alone | 19.3 MB | 37.4 s |
lto = "fat" alone | 25.4 MB (thin) | 37.8 s |
| all three together | 15.5 MB | 89.1 s |
strip = true throws away debug symbols. It is 6.7 MB for free — no build cost at all — and it is the one to take if you take only one. You lose readable backtraces from a release binary, which is a real cost if you ever debug one in the field, and not one that matters for a work sample.
lto = "fat" is link-time optimisation: the compiler optimises across crate boundaries instead of one crate at a time, so unused code fromeframe and ropey can be discarded and small functions inlined.codegen-units = 1 compiles the crate as one unit rather than sixteen parallel ones, which loses build parallelism and gains optimisation scope. They work together, and together they cost you 50 seconds.
codegen-units = 1 hurts more the more cores you have, because it's the setting that gives up parallelism. The shape of the result will hold; the seconds won't.cargo build --release
ls -lh target/release/editor
→ about 15 MB, and it took roughly twice as long
Write the file they read first
A README that answers "should I keep reading?" in twenty seconds.
This is the deliverable the mission has been pointing at since lesson 1. Nobody reviewing your repo starts at main.rs. They read the README, decide whether the person who wrote it thinks clearly, and then either open the code or close the tab.
Most portfolio READMEs describe features. Yours has something better to say: it can show measurements, and the decisions those measurements settled — including the ones that went against you. One edit, a new file:
editor/README.mdnew file
# editor
A small code editor in Rust, written to be read.
It opens files, edits them, saves them, highlights Rust syntax, and switches between
several open buffers. It is about 400 lines. Every decision in it was measured before it
was made, and the ones that were measured and *rejected* are written down too.
```
cargo run -- src/main.rs
```
## What it does
- Opens any file named on the command line; several at once become tabs
- Edits and saves with `Ctrl+S`, without corrupting UTF-8
- Highlights Rust keywords, strings and comments with a hand-written scanner
- Reports cursor position, file size and frame time in a status bar
- Falls back to plain text above 512 KB, and says so
## What it does not do
No LSP, no plugins, no modal editing, no search, no line numbers. The scope is
deliberately small: an editor you can read in a sitting is more useful as a work sample
than a half-finished IDE.
## Decisions worth reading
**The buffer is a rope, and the reason is not the one you'd expect.**
`String::insert` on a 2.3 MB file costs 12 µs — the textbook argument for a rope does not
apply here. What does cost is egui's character-index-to-byte-index conversion, which runs
three times per keystroke and walks the buffer. That is 3.05 ms of a 13 ms frame, and a
rope answers it in O(log n). The full argument, including the cheaper option I rejected
and what the rope costs in memory, is in [docs/why-a-rope.md](docs/why-a-rope.md).
**Syntax highlighting stops at 512 KB.** Measured: highlighting costs 4–5× plain layout
and crosses the 16.7 ms frame budget between 10,000 and 25,000 lines. Above the limit the
editor draws plain text and puts "highlighting off (file too large)" in the status bar,
because an editor that silently degrades looks broken.
**There is no highlight cache.** The obvious one — compare the text, keep the last galley
— took idle frames from 26.8 ms to 5.4 ms and typing from 47.1 ms to 186.2 ms. The parts I
measured account for 0.15 ms of that 139 ms regression, so I do not know what causes it.
Shipping an optimisation I cannot explain seemed worse than shipping none.
**`active: usize`, not `&mut Buffer`.** A struct cannot own a `Vec` and hold a reference
into it at the same time; that is a self-referential struct, and safe Rust rejects it.
Owning the collection and referring to a member by index is the shape that survives being
moved, which is why arenas and ECS libraries all look like this.
## Layout
`src/main.rs` is the whole program, in reading order: the highlighter, then `Text` (the
rope and egui's `TextBuffer` implementation), then `Buffer` (one open file), then `Editor`
(the app), then `main`, then the tests.
## Testing
```
cargo test
cargo clippy --all-targets
```
Ten unit tests. `--all-targets` matters: without it, clippy skips the test module
entirely. `Cargo.toml` denies `unwrap` and `expect` outside tests, so the mission's "no
`unwrap()` in the hot path" is enforced by the build rather than by good intentions.
## Built with
[`eframe`/`egui`](https://github.com/emilk/egui) 0.36 for the window and immediate-mode
UI, and [`ropey`](https://github.com/cessen/ropey) 1.6 for the text buffer.Why this reads differently from most
Three things, and none of them are about the editor:
It says what the project does not do. A scope you chose is a decision; a scope you drifted into is an accident, and a reviewer can tell the difference instantly. "No LSP, no plugins, no search" reads as confidence when the reasons are there.
It leads with the decision that went against the obvious. "The buffer is a rope, and the reason is not the one you'd expect" invites the reader to test you. Every strong technical README has a paragraph a knowledgeable reader wants to argue with, because that is the paragraph that proves a person was thinking.
It includes an optimisation that isn't there. The highlight cache section says what was tried, what it measured, and that the regression is still unexplained. That is the hardest thing to write and the most convincing thing to read: it is evidence of a person who measures, and who won't ship what they can't explain.
cd editor
cargo fmt && cargo clippy --all-targets && cargo test
→ silent, silent, ten green
Where you ended up
415 lines of Rust, ten tests, a README, and a justification document. Nothing inmain.rs changed except the line that came out and the attribute above the tests — for the body of the file, thelesson 11 checkpoint is still the one to diff against:
editor/src/main.rsthe top of fn ui
impl eframe::App for Editor {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let frame_start = Instant::now();
both panels, unchanged — exactly as lesson 11 left them
self.frame_ms = frame_start.elapsed().as_secs_f32() * 1000.0;
}
}And the manifest, which is now four lines longer and says two things it didn't:
editor/Cargo.tomlthe whole file
[package]
name = "editor"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.36.1"
ropey = "1.6.1"
# A binary someone downloads is worth 50 seconds of build time.
[profile.release]
lto = "fat"
codegen-units = 1
strip = true
# The mission says "no unwrap() in the hot path". This is that promise, enforced.
[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"What you built
Twelve lessons ago you had never written a line of Rust. The editor you now have opens files, edits them without corrupting UTF-8, saves them, highlights Rust with a scanner you wrote, holds several buffers behind tabs, degrades honestly on files too large to highlight, keeps its text in a rope you can justify, and tests the parts that are worth testing.
Along the way, the language: ownership and borrowing, Result andOption, match and its guards, traits and the newtype pattern, lifetimes by way of the struct that couldn't hold a reference, iterators and their combinators, and the difference between a byte and a character.
The habit is worth more than any of it. Roughly one premise in three, checked against a compiler or a benchmark, turned out to be false — including the one this tutorial was originally built around. Lesson 6 was going to introduce the rope by making you feel String hurt; it doesn't hurt, so lesson 6 became a profiling lesson and the rope waited five lessons for a measurement that actually justified it. Lesson 10 was going to be undo, until reading egui's source showed it already had one.
and you took every one of those measurements yourself.
Check yourself
What did request_repaint() ask the host to do on an idle frame?
Measured, the requested delay was 0ns — as fast as the display can take it, forever. Without it, egui asks to wait for input instead, which is what lesson 1 promised immediate mode would do.↑ Review: what that line was doing (stage 1)
Why does cargo clippy alone miss the unwrap() in your tests?
By default clippy checks the default target only.--all-targets adds tests, benches and examples — which means every clippy run before this lesson had been ignoring a tenth of the program.↑ Review: clippy has been skipping your tests (stage 2)
Which release setting gives the biggest size win for no build cost?
Stripping debug symbols took 26.0 MB to 19.3 MB with build time unchanged. LTO and codegen-units = 1 buy a further 3.8 MB but cost about 50 seconds, and opt-level = 3 is already the release default.↑ Review: what you're buying (stage 3)
Why does the README describe an optimisation that isn't in the code?
The naive highlight cache made typing four times worse and the parts measured don't explain it. Writing down what was tried, what it cost, and that the cause is still unknown is stronger evidence of judgement than the optimisation would have been.↑ Review: why this reads differently (stage 4)
Where to go next
Primary source:The Cargo Book — Profiles, andthe[lints] section of the manifest reference. Both are short, and both are the kind of thing worth knowing exists rather than memorising.
Four things this editor would want next, roughly in order of how much you'd learn:
- Find the highlight cache regression. The measurement is written down and the cause is not. This is a real, open profiling problem in code you understand completely — which is a rare and excellent thing to practise on.
- Search. The most-missed feature, and it puts the rope's line index to work for something other than a status bar.
- A second language for the highlighter. The scanner is forty lines and hard-codes Rust. Making it table-driven is a design exercise with an obvious test.
- Line numbers in the gutter. Harmless-looking, and the first thing that will make you want to paint text yourself rather than hand it to
TextEdit— which is the forklesson 7 chose not to take.