Your First egui Window — Rust GUI Tutorial (Learn egui in Neovim Ep.1)
Video: Your First egui Window — Rust GUI Tutorial (Learn egui in Neovim Ep.1) by Taught by Celeste AI - AI Coding Coach
Crate:
eframe = "0.31"A native Rust GUI window in 38 lines, no JavaScript, no Electron, no runtime.
There's a moment when you realise GUI programming in Rust does not have to mean wrapping native APIs by hand or shipping a 200MB Electron bundle. The egui crate (and its native runner, eframe) gives you a working desktop window with a single struct and an update method that runs every frame.
This is Episode 1 of Learn egui in Neovim. By the end you will have a window on screen with a heading and two labels, built from an empty Cargo project.
The full code
Cargo.toml:
[package]
name = "my-first-window"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.31"
src/main.rs:
use eframe::egui;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 300.0]),
..Default::default()
};
eframe::run_native(
"My First Window",
options,
Box::new(|_cc| Ok(Box::new(MyApp::default()))),
)
}
struct MyApp {
name: String,
}
impl Default for MyApp {
fn default() -> Self {
Self {
name: "World".to_string(),
}
}
}
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Hello, egui!");
ui.label("Welcome to your first egui window.");
ui.label(format!("Greetings, {}!", self.name));
});
}
}
That is the whole program. cargo run and a window opens.
What the pieces do
fn main() -> eframe::Result — eframe returns a custom Result from its runner, so main propagates it. No need to unwrap; the ? operator works if you are calling eframe::run_native from a fallible function.
NativeOptions { viewport: ... } — the configuration of the OS window. ViewportBuilder is where you set size, position, decorations, transparency, always-on-top, and a dozen other window properties. Here we only set the inner size to 400×300. Defaults handle the rest.
eframe::run_native(title, options, app_creator) — the runner. It opens a real OS window, sets up an OpenGL or wgpu context, and starts an event loop. The third argument is a closure that builds your app — Box::new(|_cc| Ok(Box::new(MyApp::default()))). The _cc is a CreationContext that you would use to install custom fonts, themes, or storage. We ignore it for now.
struct MyApp — your application state. Anything that should persist across frames goes in here. Counters, text input contents, selected indices, loaded data. egui is immediate-mode, which means the UI is rebuilt every frame from this state. The struct is the source of truth.
impl Default for MyApp — initial state when the app launches. You can also build the app explicitly inside the run_native closure if defaults aren't enough.
impl eframe::App for MyApp — one required method, update, called every frame (typically 60 times per second). The signature receives a mutable reference to your state, an egui::Context for talking to the GUI system, and a Frame for window-level operations.
egui::CentralPanel::default().show(ctx, |ui| { ... }) — defines the main content area of the window. The closure receives a Ui reference; widgets are added by calling methods on it. heading() for big text. label() for normal text. The widgets don't return a value here, but most of them return a Response you can chain (.clicked(), .hovered(), etc.).
The mental model
egui is immediate mode. Every frame, you describe the UI from scratch. There is no widget tree to maintain, no diffing, no signal routing. If you want a button to appear, you call ui.button("Click") during the frame you want it visible. If you want it to react, you check .clicked() immediately after.
That sounds wasteful, but it is fast — egui rebuilds about 1000 widgets per millisecond on modern hardware. And it makes the model dramatically simpler: state lives in your struct, the UI is a function of that state, and every frame is a render of state -> pixels.
If you have used React or SwiftUI, the model is similar. If you have used GTK or Qt, this is the opposite: those are retained-mode — you build a tree once and mutate it. Immediate-mode trades a little CPU for enormous simplicity.
Running it
cargo run from the project directory. A window opens. Inside Neovim you can run it with :!cargo run, but for graphical apps it is often easier to use a terminal split: :sp | terminal cargo run. The window appears alongside your editor.
You will see:
- A bar at the top reading "My First Window."
- A large heading "Hello, egui!" inside the window's central area.
- Two normal labels below it.
That is your first egui app. From here, every other widget — buttons, sliders, text inputs, dropdowns, custom paint — slots into the same update method.
What's next
This series builds up egui in small steps. Next episode: labels and headings — the text widgets in detail, including monospace text, separators, and how ui.heading differs from ui.label.
By Episode 32 you will have a working text editor app written entirely in egui — file dialogs, undo, save, the lot.
Common mistakes
Pinning to an older eframe. This series uses eframe = "0.31". Older versions had a different App trait signature (epi::App and a separate name() method). If your Cargo.toml shows 0.13–0.21, you are on the old API and will get compile errors against this code. Use 0.31 or newer.
Forgetting Box::new in run_native. The third argument is a Box<dyn FnOnce(...) -> Result<Box<dyn App>, _>>. Both layers of Box are required; the SDK is explicit about it.
Mutating state outside update. It is tempting to start a background thread that updates self.counter directly. egui does not synchronise that — you would race with the render loop. Use channels (std::sync::mpsc) and read messages inside update.
Recap
A 7-line Cargo file. A 38-line Rust file. One window, one struct, one update method that runs every frame. That is the foundation.
Next episode: labels and headings. See you in the next one.