Deryks _dev

Full-Stack Web Developer & Technical Leader

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

Your editor is a struct.
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.

Type it, don't paste itYour mission says you can explain every line of this repo in an interview. That starts here. Typing forces you to look at each token; pasting lets your eyes slide over it. It's forty lines total — the cost is about four minutes and it's the highest-leverage four minutes in this lesson.

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.

Deferred on purpose — we return in lesson 3Everything inside 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:

Easy to missThe first parameter loses its underscore: _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:

BorrowWrittenHow many at onceWhat you may do
Shared&Any numberRead only. Never modify.
Exclusive&mutExactly one, everModify 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

This is the whole lessonNothing connects that label to that text box. No event, no listener, no data binding, noonChange. 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."

— emilk/egui README

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

The bill comes due laterImmediate mode redoes the full layout every frame, so per the same README, "the layout code needs to be quick… having a very large UI in a scroll area (with very long scrollback) can be slow." Consider that a promise that we will fight this crate around lesson 6, when a 50,000-line file meets a naive redraw.

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?

  • In the Editor struct's field
  • In the TextEdit widget's object
  • In the egui::Ui layout tree
  • In the eframe window's handle

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?

  • A text box writes; a label only reads
  • Labels copy the string; text boxes borrow it
  • Text boxes need owned data, not borrowed
  • Labels take a str, text boxes a String

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?

  • Almost none — input drives repaints
  • About 3,600 — sixty frames per second
  • Exactly one — the initial layout pass
  • Thousands — capped by the display's refresh

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