Lesson 0001: Bootstrapping Your Rust & eframe Project
Creating the Cargo workspace and native window shell that your PCB Reviewer code will inhabit
The joy of building a custom hardware tool is getting your own PCB viewport up on screen. The first hurdle, though, is getting past a blank terminal and figuring out which crate actually opens the window.
Before we wire up the layer-visibility toggles in Lesson 0002, our code needs a real, runnable Rust project to live in. So, let's set up Cargo, sort out how eframe and egui divide the work, and get fn main() booting a native desktop window.
1. egui vs. eframe: Who Does What?
Turns out, Emil Ernerfeldt's egui crate doesn't actually open OS windows or talk directly to Metal, Vulkan, or OpenGL.Because egui only takes input events and outputs 2D triangles and textures, it can be embedded inside game engines, custom CAD renderers, or headless test runners.
egui(The UI Core): Computes widget layouts, processes pointer/keyboard state, and generates 2D vector shapes for a single frame.eframe(The App Shell): The official framework wrapper (docs.rs/eframe) that creates a native desktop window (or WebAssembly canvas), initialises the GPU painter, and calls your app'sui()method on every repaint.
flowchart LR
Cargo["Cargo.toml<br/>(eframe = 0.37)"] --> Main["fn main()<br/>eframe::run_native(...)"]
Main -->|"Runs once at startup"| Init["PcbReviewerApp::default()<br/>(Initialises struct state)"]
Init --> Loop["eframe Event & Render Loop"]
Loop -->|"Calls every frame (~60 Hz)"| Update["impl eframe::App for PcbReviewerApp<br/>fn ui(&mut self, ui, frame)"]
Update -->|"Emits 2D shapes"| Loop
Figure 1.1: How Cargo and eframe::run_native initialise your state once, then drive the Lesson 0002 ui() loop every frame.
2. Creating the Project & Configuring Cargo.toml
In Rust, cargo new pcb-reviewer generates a binary package (--bin is the default) with two key files: Cargo.toml (the manifest) and src/main.rs (the entry point). We then step into the directory and use cargo add to pull in eframe:Tip: Single Dependency Import. Because eframe re-exports egui as eframe::egui, we only add eframe under [dependencies]. This guarantees your egui types never suffer a version mismatch with eframe.
cargo new pcb-reviewer
cd pcb-reviewer
cargo add eframe
After running cargo add eframe and appending a [profile.dev.package."*"] optimisation block, here is the resulting Cargo.toml for our Schematic & PCB Reviewer:
[package]
name = "pcb-reviewer"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.37"
# Optimise dependencies (like egui/eframe) even in debug builds so our
# 2D PCB canvas renders at 60 FPS while keeping our own code fast to compile.
[profile.dev.package."*"]
opt-level = 2
Now, those last three lines (Cargo Profile Overrides) are doing a lot of heavy lifting. If you run a 2D CAD canvas in a stock Rust debug build (opt-level = 0), software rasterising and tessellation can feel like wading through treacle. By setting opt-level = 2 on package."*", Cargo compiles third-party crates once at near-release speed so our viewport stays locked at 60 FPS, while our own edits in src/main.rs still recompile in under a second. Best of both worlds.
3. Anatomy of src/main.rs: Block by Block
Rather than staring at a 65-line wall of Rust and trying to decode everything at once, let's walk through src/main.rs one logical block at a time. Once we've unpacked all five pieces, we'll put the complete file together at the end of this section.
Block 1: Importing egui & Booting the Native Window (fn main)
use eframe::egui;
fn main() -> eframe::Result {
let mut options = eframe::NativeOptions::default();
options.viewport = egui::ViewportBuilder::default()
.with_inner_size([1024.0, 720.0]);
eframe::run_native(
"Schematic & PCB Design Reviewer",
options,
Box::new(|_cc| Ok(Box::new(PcbReviewerApp::default()))),
)
}
-
use eframe::egui;: Instead of pulling in a separateeguicrate inCargo.toml, we importeframe's re-exportedeguimodule so our UI code and window runner are guaranteed to use the exact sameeguiversion. -
fn main() -> eframe::Result: Every Rust binary starts atfn main(). Returningeframe::Resultallowseframeto report any window-creation or GPU initialisation errors cleanly back to the OS.In Rust,Result<T, E>represents an operation that either succeeds (Ok(T)) or fails (Err(E)).eframe::Resultis a type alias forResult<(), eframe::Error>. -
Configuring
NativeOptions&ViewportBuilder: In Rust, variables are immutable by default, so we writelet mut optionsto make the struct mutable. We then replaceoptions.viewportusing the builder pattern:egui::ViewportBuilder::default().with_inner_size([1024.0, 720.0])starts from standard window defaults and chains.with_inner_size(...)to request a1024×720logical-point canvas. -
Alternative—Keeping
optionsImmutable with Struct Update Syntax (..Default::default()): You might wonder whyoptionscouldn't stay immutable via a.with_viewport(...)chain of its own. Unlikeegui::ViewportBuilder(which defines.with_*builder methods that consumemut selfand returnSelf),eframe::NativeOptionsis a plain configuration struct with public fields and animpl Default. In Rust, you can initialise a struct likeNativeOptionswithoutmutusing struct update syntax:let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1024.0, 720.0]), ..Default::default() };Here,
viewport: ...sets the one field we care about, and..Default::default()(placed last, with no trailing comma) tells Rust to fill every remaining field (vsync,multisampling,renderer, etc.) fromeframe::NativeOptions::default(). Both styles compile to identical code—let mut optionsis handy when you want to conditionally tweak fields across several statements, whereas..Default::default()keepsoptionsimmutable from the moment it's bound. -
eframe::run_native(...): Notice there is no semicolon aftereframe::run_native(...)—in Rust, the final expression of a function without a trailing semicolon is automatically its return value. It takes three arguments:"Schematic & PCB Design Reviewer": The native OS window title.options: TheNativeOptionswe just configured.Box::new(|_cc| Ok(Box::new(PcbReviewerApp::default()))): The app creator closure.eframe::run_nativeinvokes this factory closure once after the GPU and window context (_cc: &CreationContext) are ready. Inside, we constructPcbReviewerApp::default(), allocate it on the heap withBox::new, wrap it inOk(...), and hand ownership toeframe.
Block 2: Defining Application State (pub struct PcbReviewerApp)
pub struct PcbReviewerApp {
pub show_top_copper: bool,
pub show_bottom_copper: bool,
pub show_silkscreen: bool,
pub show_ratsnest: bool,
}
- Data-Only
struct: Rust doesn't have classes or inheritance hierarchies. Instead, you group your application's persistent state into astructand attach behaviour via separateimplblocks. - Why State Lives Here: Because
eguiis an immediate-mode GUI, widgets don't secretly store their own checkbox states between frames.PcbReviewerAppis the single source of truth that survives from frame to frame. pubVisibility: Marking the struct and its fourboolfieldspub(public) allows external modules and test harnesses to inspect or toggle our four PCB layers directly.
Block 3: Initialising Default Layer Visibility (impl Default & reset_layers)
impl Default for PcbReviewerApp {
fn default() -> Self {
let mut app = Self {
show_top_copper: false,
show_bottom_copper: false,
show_silkscreen: false,
show_ratsnest: false,
};
app.reset_layers();
app
}
}
impl PcbReviewerApp {
pub fn reset_layers(&mut self) {
self.show_top_copper = true;
self.show_bottom_copper = true;
self.show_silkscreen = true;
self.show_ratsnest = false;
}
}
- Why Custom
impl DefaultInstead of#[derive(Default)]?: Rust lets you slap#[derive(Default)]above a struct, which happily sets everyboolfield tofalse. Problem is, opening a PCB viewer where every copper and silkscreen layer is switched off looks suspiciously like a broken app! Implementing theDefaulttrait by hand lets us choose sensible startup defaults (Selfis just an alias for the type being implemented,PcbReviewerApp). - Delegating to
reset_layers(&mut self): Insidefn default(), we createlet mut app, callapp.reset_layers(), and returnapp(again, no trailing semicolon). Theimpl PcbReviewerAppblock defines an inherent method where&mut selfgrants mutable borrow access to the struct's fields so it can enable the three physical board layers (show_top_copper,show_bottom_copper,show_silkscreen) while keeping the logicalshow_ratsnestairwires hidden until requested. - Why Extract
reset_layers?: The reason we pullreset_layers(&mut self)out into its own method is also interesting:cargo-mutantsdeliberately skips mutatingimpl Defaultblocks directly (since replacingDefault::default()in generic code can cause infinite recursion). Extracting a helper method lets both our future "Reset Layers" UI button and our mutation runner exercise the exact same initialisation code.
Block 4: Implementing the Frame Loop (impl eframe::App)
impl eframe::App for PcbReviewerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Lesson 0002's Panel and CentralPanel code lives right here!
egui::CentralPanel::default().show(ui, |ui| {
ui.centered_and_justified(|ui| {
ui.heading("PCB Viewport Ready");
});
});
}
}
impl eframe::App for PcbReviewerApp: In Rust, traits work like interfaces. Implementingeframe::Appis the contract that connects ourPcbReviewerAppstruct toeframe's event and render loop.fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame):eframecalls this method on every repaint:&mut self: Gives us mutable access to ourPcbReviewerAppstate so checkboxes and buttons can modify fields in place.ui: &mut egui::Ui: The root UI region handle for the current frame, which tracks layout, input events, styling, and fonts across the window._frame: &mut eframe::Frame: Provides integration hooks into the surroundingeframeshell. Prefixing the parameter name with an underscore (_frame) tells the Rust compiler we intentionally aren't using it yet, keeping our build free of unused-variable warnings.
egui::CentralPanel::default().show(ui, |ui| { ... }): Claims the main canvas area of the window and invokes our closure|ui| { ... }, passing in a scoped&mut egui::Uiregion builder.ui.centered_and_justified(|ui| { ... }): By default,CentralPanellays out widgets from the top-left corner (Layout::top_down(Align::Min)). Wrappingui.heading("PCB Viewport Ready")insideui.centered_and_justifiedexpands the single child region to fill the viewport and centers our startup heading both horizontally and vertically.
Block 5: Inline Unit Tests (#[cfg(test)] mod tests)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_enables_physical_layers_and_hides_ratsnest() {
let app = PcbReviewerApp::default();
assert!(app.show_top_copper);
assert!(app.show_bottom_copper);
assert!(app.show_silkscreen);
assert!(!app.show_ratsnest);
}
}
#[cfg(test)] mod tests: Rust makes unit testing frictionless by letting you place atestssubmodule right at the bottom of the file you are testing. The#[cfg(test)]conditional compilation attribute tellsrustcto compile this block only when you runcargo test(Rust Book §11.1), so zero test code bloats your production binary.use super::*;: Brings everything from the parent file (PcbReviewerAppand its methods) into scope inside thetestssubmodule.#[test]andassert!: Marking a function with#[test]registers it with Cargo's test runner. Inside, we constructPcbReviewerApp::default()and useassert!(...)(andassert!(!...)with the logical NOT operator!) to verify that every physical layer starts enabled and the ratsnest starts hidden.
Complete src/main.rs Listing
Putting all five blocks together, here is the complete, runnable src/main.rs:
use eframe::egui;
fn main() -> eframe::Result {
let mut options = eframe::NativeOptions::default();
options.viewport = egui::ViewportBuilder::default()
.with_inner_size([1024.0, 720.0]);
eframe::run_native(
"Schematic & PCB Design Reviewer",
options,
Box::new(|_cc| Ok(Box::new(PcbReviewerApp::default()))),
)
}
pub struct PcbReviewerApp {
pub show_top_copper: bool,
pub show_bottom_copper: bool,
pub show_silkscreen: bool,
pub show_ratsnest: bool,
}
impl Default for PcbReviewerApp {
fn default() -> Self {
let mut app = Self {
show_top_copper: false,
show_bottom_copper: false,
show_silkscreen: false,
show_ratsnest: false,
};
app.reset_layers();
app
}
}
impl PcbReviewerApp {
pub fn reset_layers(&mut self) {
self.show_top_copper = true;
self.show_bottom_copper = true;
self.show_silkscreen = true;
self.show_ratsnest = false;
}
}
impl eframe::App for PcbReviewerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Lesson 0002's Panel and CentralPanel code lives right here!
egui::CentralPanel::default().show(ui, |ui| {
ui.centered_and_justified(|ui| {
ui.heading("PCB Viewport Ready");
});
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_enables_physical_layers_and_hides_ratsnest() {
let app = PcbReviewerApp::default();
assert!(app.show_top_copper);
assert!(app.show_bottom_copper);
assert!(app.show_silkscreen);
assert!(!app.show_ratsnest);
}
}
4. Sanity-Saving Verification with cargo-mutants
Writing unit tests feels great, right up until you realise a test can execute every line of a function without actually asserting anything useful.
So, from Day 1, we pair cargo test with Martin Pool's cargo-mutants (Testing & Mutation Cheat Sheet).Why mutation testing? Standard line coverage only checks whether a line of code ran during a test. cargo-mutants deliberately hollows out method bodies or flips operators/booleans in your code and verifies that cargo test actually fails (a Caught Mutant) rather than silently passing (a Missed Mutant). It is a fantastic sanity-saver that keeps our tests honest as the reviewer grows.
Look at the #[cfg(test)] mod tests block at the bottom of src/main.rs above. The attribute #[cfg(test)] tells rustc to compile that module only when you run cargo test (Rust Book §11.1). When cargo-mutants replaces the body of PcbReviewerApp::reset_layers with an empty no-op (), assert!(app.show_top_copper) immediately fails and catches the mutant.
Installing cargo-mutants
cargo-mutants is a third-party Cargo subcommand that works directly with standard, stable Rust (no nightly toolchain required). Install it once globally with cargo install, then check it is on your PATH:
# Install the cargo-mutants subcommand once (takes ~30s to compile)
cargo install --locked cargo-mutants
# Verify the subcommand is installed
cargo mutants --version
To configure cargo-mutants so it mutates our application state logic without trying to launch the blocking OS window in fn main(), add a .cargo/mutants.toml file to your project:
# .cargo/mutants.toml
# Reuse the target directory across scratch builds so mutation runs finish quickly.
copy_target = true
# Skip the OS window launcher and raw panel draw loop until Lesson 0007
# introduces headless UI testing with egui_kittest.
exclude_re = [
"replace main",
"<impl eframe::App for PcbReviewerApp>::ui",
]
Running cargo test and cargo mutants
With .cargo/mutants.toml in place, verification is a two-step workflow from your project root (pcb-reviewer/, or ./apps/0001-project-setup-and-eframe-boot if you are running the companion crate in this repository):
- Run the standard unit test suite (
cargo test): First confirm your code compiles and all#[test]functions pass against your unmodified source code. - Run the mutation test suite (
cargo mutants): Next, runcargo mutants(optionally passing-vso caught mutants are printed alongside any missed ones, or--listto preview which mutations will be generated before compiling).
# 1. Run standard Rust unit tests
cargo test
# 2. (Optional) Preview which mutations cargo-mutants will generate
cargo mutants --list
# 3. Run the mutation test suite (-v prints caught mutants as well as missed ones)
cargo mutants -v
When you run cargo mutants -v, it copies your crate into an isolated temporary build directory, verifies that cargo test passes on the unmutated baseline, and then runs cargo test once for each generated mutant:
Found 1 mutant to test
ok Unmutated baseline in 0.4s build + 0.1s test
CAUGHT src/main.rs:36:5: replace PcbReviewerApp::reset_layers with () in 0.3s build + 0.1s test
1 mutants tested in 0.9s: 1 caught
Here is how to read that output:
Unmutated baseline ... ok:cargo-mutantsfirst runscargo testwith zero changes to make sure your test suite isn't already failing.CAUGHT src/main.rs:36:5: replace PcbReviewerApp::reset_layers with ():cargo-mutantshollowed outpub fn reset_layers(&mut self)so it did nothing (()), recompiled, and rancargo test. Because ourdefault_enables_physical_layers_and_hides_ratsnesttest assertedassert!(app.show_top_copper), the test failed—which means the mutant was CAUGHT (exit code0).- What if a mutant is
MISSED?: Ifcargo teststill passes when a function is mutated,cargo-mutantsprintsMISSEDin red and exits with a non-zero status code, pointing you to the exact file and line where a test assertion is missing. (It also writes detailed logs and diffs into amutants.out/directory, which you can add to.gitignore.)
Every lesson includes a Cargo Verification & Mutation Coverage gate. Check off each step in your terminal to establish your baseline:
Fill in the three blanks below so our project re-exports egui from eframe, initialises PcbReviewerApp with its default layer state, and implements the eframe::App trait:
use eframe::;
fn main() -> eframe::Result {
let options = eframe::NativeOptions::default();
eframe::run_native(
"PCB Reviewer",
options,
Box::new(|_cc| Ok(Box::new(PcbReviewerApp::))),
)
}
impl for PcbReviewerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Frame loop body
}
}
Test your retention from memory before moving into the frame loop in Lesson 0002.
Recommended Primary Source
eframe Crate Documentation — "Usage, native".
Compare the minimal eframe::run_native example in the official docs with our PcbReviewerApp skeleton above.
Cargo.toml, Box::new, or how eframe::run_native hands off to ui()? Ask me in chat! Otherwise, click straight through to Lesson 0002: The Immediate-Mode Loop & Mutable State (&mut) to build the interactive PCB Layer Inspector inside ui().