<< Back / Lesson 01 / Text Editor GUI in Rust / ~30 minutes
Your Editor's First Window
Six stages, from an empty file to a window you can type code into.
You'll build this in six stages. Every one of them compiles and runs — you'll see the program change on screen each time — and every one introduces exactly one new idea. One of the stages fails on purpose, because the failure is the most useful thing on this page.
You have never written Rust. That's assumed throughout. Some lines will contain machinery you can't fully explain yet; where that happens I'll say so out loud and tell you which lesson comes back for it. "Don't worry about this yet" is a promise, not a dodge.
The GUI holds no state of its own — it draws that struct, and hands you a mutable borrow so you can change it.
That sentence won't mean much yet. It should mean everything by stage 5.
Before you start
Your toolchain is installed: rustc 1.97.1 via rustup, in ~/.cargo. If a terminal says "command not found: cargo", run source ~/.cargo/env.
The project lives at editor/ in this workspace. It was created with cargo new editor --bin and cargo add eframe, which wrote eframe = "0.36.1" into Cargo.toml and pulled in 414 supporting crates — windowing, GPU access, font shaping, clipboard. That's the pile of work you arenot doing.
adding removing existing code, shown only for context
Prove the toolchain works
Text appears in your terminal.
Open editor/src/main.rs. It contains what cargo newgenerated:
editor/src/main.rsthe whole file
fn main() {
println!("Hello, world!");
}fn declares a function. main is where every Rust program starts. println! ends in ! because it's amacro, not a function — it's rewritten into other code at compile time, which is how it can accept any number of arguments of any type. You'll meet a few macros; the ! is always the tell.
cd editor && cargo run
→ Hello, world!
An empty window
A blank 800×600 window opens on your desktop.
Replace the entire file with this. It's the smallest possible eframeapplication:
editor/src/main.rsreplacing the whole file
use eframe::egui;
struct Editor;
impl eframe::App for Editor {
fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut eframe::Frame) {}
}
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))))
}Three things are worth understanding here, and one is not.
struct Editor; — this declares a type calledEditor that currently holds no data at all. This is your application. Not a window, not a widget: a plain data type that you will hang everything else off. Right now it's empty, which is honest — your editor knows nothing yet.
impl eframe::App for Editor — a trait implementation. A trait is a contract: a list of functions a type must provide in order to be used somewhere. eframe::App is the contract for "something eframe can run as an application," and its one requirement is a function called ui. By writing this block you're saying: Editor can behave as an App, and here's the proof. If you get the signature wrong, the compiler rejects it — you cannot half-implement a trait.
fn ui(&mut self, …) — the function eframe calls to draw a frame. The body is empty {}, so nothing is drawn, so the window is blank. Note &mut self. Ignore it for now; stage 5 is entirely about that one token.
The underscores in _ui and _frame tell the compiler "I know I'm not using this parameter, don't warn me." Drop the underscore on an unused variable and you'll get a warning — a small example of how much Rust nags you, and how much of that nagging turns out to be useful.
main: NativeOptions, the..Default::default() syntax, Box::new, and the|_cc| … closure. This is setup code you write once and never think about again. It uses four distinct language features, none of which are useful to you today, and all of which are genuinely interesting once you have ownership under your belt. Type it, run it, move on.cargo run
→ a blank window titled "editor"
First build takes ~30 seconds — it's compiling all 414 crates. Later builds are 1–2 seconds.
Draw something
The word "editor" appears in the window.
Fill in that empty ui body:
_ui becomes ui. The leading underscore was you telling the compiler "I know this is unused, don't warn me" — and now you are using it. Leave it as _ui and the next line can't see it:error[E0425]: cannot find value `ui` in this scope. Rust even spells out the fix for you: "the leading underscore in _uimarks it as unused, consider renaming it to ui."editor/src/main.rsin impl eframe::App for Editor
impl eframe::App for Editor {
fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut eframe::Frame) {}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading("editor");
});
}
}A panel is a region of the window. CentralPanel takes whatever space is left after other panels claim theirs; later you'll add a top panel for tabs and a bottom one for a status bar, and the central panel will shrink to fit around them automatically.
.show(ui, |ui| { … }) takes a closure — an anonymous function, written between the pipes. Read it as "inside this panel, do the following." Everything you call on the inner ui gets laid out top to bottom, in the order you write it. There is no layout file and no positioning: the order of your statementsis the layout.
cargo run
→ the window now says editor in large text
Give it something to remember
A line of text you chose appears below the heading.
This stage takes three separate edits, in three different parts of the file. They're below one at a time — do them in order, and don't run anything until all three are in, because the file won't compile in between.
First edit. An editor needs a buffer, so give Editor a field.Replace the empty struct Editor; from stage 2 — don't add alongside it, or you'll define the same name twice and the compiler will say so:
editor/src/main.rstop of the file
use eframe::egui;
struct Editor;
struct Editor {
text: String,
}
impl Default for Editor {
fn default() -> Self {
Self {
text: "Hello, editor.\n".to_owned(),
}
}
}Second edit — now draw that text. One line, under the heading:
editor/src/main.rsin fn ui, under the heading
egui::CentralPanel::default().show(ui, |ui| {
ui.heading("editor");
ui.label(&self.text);
});Third edit, and a different part of the file. Down in main,Box::new(Editor) no longer works: Editor has a field now, so the bare name is just a type, not a value you can put in a box. Miss this one and you get error[E0423]: expected value, found struct `Editor`.
editor/src/main.rsat the bottom of main
eframe::run_native("editor", options, Box::new(|_cc| Ok(Box::new(Editor))))
eframe::run_native(
"editor",
options,
Box::new(|_cc| Ok(Box::new(Editor::default()))),
)It grew from one line to five only because::default() pushed it past rustfmt's line width, socargo fmt breaks the arguments out. Nothing semantic changed — the same call, wrapped.
String vs &str. Rust has two string types and the difference matters constantly. "Hello, editor.\n" written literally in your source is a &str — a borrowed view of text baked into your compiled binary. It's fixed size and you cannot grow it. AString is owned, heap-allocated, and can grow. A text buffer must grow, so the field is a String, and .to_owned() is what copies the literal into one.
You'll see .to_string(), .to_owned(), andString::from() used for this. They're equivalent here..to_owned() is the most precise about intent: I am taking ownership of a copy.
impl Default for Editor — another trait, this one from Rust's standard library. It means "there is a sensible starting value for this type."Self is shorthand for the type you're implementing on, soSelf { … } constructs an Editor. That's why maincan now say Editor::default().
ui.label(&self.text) — draw the string. The& means borrow it: let the label read your text without taking it away from you. If you wrote self.text without the &, you'd be handing the String over permanently and the compiler would stop you. Ownership is coming into focus.
cargo run
→ "Hello, editor." under the heading. You can't type in it. It's a label.
Make it editable — the stage that fails first
Understand &mut by getting it wrong, on purpose.
A label can't be edited, so swap it for a text box. Try the obvious thing — keep the& you already know:
editor/src/main.rsin fn ui, replacing the label
ui.heading("editor");
ui.label(&self.text);
ui.add(egui::TextEdit::multiline(&self.text));cargo check
— faster than cargo run, and it won't produce a binary. Use it whenever you're arguing with the compiler.
It doesn't compile. Here's the actual output, verbatim:
error[E0308]: mismatched types
--> src/main.rs:19:46
|
19 | ui.add(egui::TextEdit::multiline(&self.text));
| ------------------------- ^^^^^^^^^^ types differ in mutability
| |
| arguments to this function are incorrect
|
= note: expected mutable reference `&mut dyn TextBuffer`
found reference `&String`Before reading on: types differ in mutability. What is the text box asking for that a label didn't need, and why?
A label only ever reads your text. A text box has to write into it — when you press a key, that character has to end up in your String. So it needs a stronger kind of access than & can give.
This is the heart of Rust. Every value has one owner, and access is granted throughborrows, of which there are exactly two kinds:
| Borrow | Written | How many at once | What you may do |
|---|---|---|---|
| Shared | & | Any number | Read only. Never modify. |
| Exclusive | &mut | Exactly one, ever | Modify freely. Nobody else can even look while you hold it. |
The rule underneath: many readers, or one writer — never both at once.That single rule is what lets Rust eliminate data races at compile time, and it's the rule you'll spend the next month arguing with. Make the fix:
editor/src/main.rssame line, in fn ui
ui.add(egui::TextEdit::multiline(&mut self.text));Read that as: here is my buffer — you may write directly into it. When the user presses a key, egui pushes that character into your String. There is no copy, no synchronisation step, no "read the value back out afterwards."
And now &mut self from stage 2 makes sense. You can only hand out&mut self.text if you hold exclusive access to self in the first place — a shared borrow can't be upgraded into an exclusive one. That token in the signature is a permission slip, and without it typing would be impossible.
Worth doing: change &mut self to &selfin the ui signature and run cargo check again. You'll get two errors, not one — the borrow error, and a complaint that your ui no longer matches the signatureeframe::App requires. Two different lessons in one mistake. Put it back after.
While you're here, make it look like a code editor — monospaced, full width:
editor/src/main.rsin fn ui, expanding the TextEdit
ui.add(
egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY),
);cargo run
→ Type in it. This is a text editor now, for a generous definition of "editor".
Prove there's only one source of truth
A live byte count that you never wired up to anything.
One more line, below the text box:
editor/src/main.rsin fn ui, below the TextEdit
.desired_width(f32::INFINITY),
);
ui.label(format!("{} bytes", self.text.len()));cargo run
→ a byte counter that moves as you type
onChange. The label is simply recomputed from self.text on the next frame — and a frame happens because you pressed a key.This is immediate mode, and it's why we chose egui. In aretained mode toolkit (GTK, Qt, the DOM) you'd create a text box object, register a callback, and keep your data in sync with the widget's copy. Two sources of truth, and most GUI bugs living in the gap. The egui README puts it directly:
"In a retained GUI you create a button, add it to some UI and install some on-click handler (callback)… By contrast, in immediate mode you show the button and interact with it immediately, and you do so every frame."
"You don't have to worry about app state and GUI state being out-of-sync… because the GUI isn't storing any state — it is showing the latest state immediately."
"Every frame" is the model you code against, not what actually happens. egui "only repaints when there is interaction (e.g. mouse movement) or an animation, so if your app is idle, no CPU is wasted" (README FAQ).
Where you ended up
Forty lines. Check yours against it — and run cargo fmt, which formats to the community standard with no configuration and no debate.
editor/src/main.rs
use eframe::egui;
struct Editor {
text: String,
}
impl Default for Editor {
fn default() -> Self {
Self {
text: "Hello, editor.\n".to_owned(),
}
}
}
impl eframe::App for Editor {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading("editor");
ui.add(
egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY),
);
ui.label(format!("{} bytes", self.text.len()));
});
}
}
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::default()))),
)
}Check yourself
Don't skip these. Recalling an answer builds far more durable memory than re-reading the paragraph it came from — and you need to still have this in six months, in an interview. Each explanation links back to where the idea was taught, so a wrong answer is one click from a re-read — and your browser's Back button returns you here.
You type "fn" into the window. Between that frame and the next, where does that text physically live?
Immediate-mode widgets are transient — that TextEdit is constructed and thrown away every single frame. Your struct is the only thing that survives between frames, which is exactly why nothing can fall out of sync.↑ Review: one source of truth (stage 6)
Stage 4's ui.label(&self.text) compiled fine. Why did the same & fail for TextEdit?
Exactly what E0308: types differ in mutability was telling you. A shared borrow grants read access; typing is a write, so TextEditdemands an exclusive &mut borrow instead.↑ Review: the two kinds of borrow (stage 5)
Your app sits open and untouched for a minute. Roughly how many times does ui run?
"Every frame" is the mental model you code against, but egui only repaints on interaction or animation, so an idle app costs no CPU. You get immediate mode's simplicity without the battery drain you'd expect.↑ Review: when egui actually repaints (stage 6)
Read this before lesson 2
Primary source:The Rust Programming Language, Chapter 4 — Understanding Ownership(Brown University interactive edition). Read 4.1 and 4.2; 4.3 can wait.
Use this edition, not the official one. Same book, plus inline quizzes you retry to mastery and diagrams that animate memory as the code runs. Its ownership chapter was rewritten around research into what learners actually get wrong. Stage 5 gave you the rule; chapter 4 gives you the reasons.