Calculator App in Rust egui — Grid Layout & Operator Logic | Learn egui Ep28
Video: Calculator App in Rust egui — Grid Layout & Operator Logic | Learn egui Ep28 by Taught by Celeste AI - AI Coding Coach
Five rows of buttons, four operators, one display. The calculator we have all written half a dozen times — now in egui.
Today is a longer episode that combines patterns from across the series. A calculator is small in scope but rich in detail: it has a state machine (digit input vs operator pending vs error), a custom button style per category (digits / operators / functions), grid-like layout that handles the wide "0" button, and live evaluation.
It is also a good test of how well you have absorbed everything so far. Buttons (Ep 3), grid layout (Ep 8), state mutation, custom button styling (with egui::Button directly), and a tiny state machine — all in one project.
What we are building
A calculator that looks roughly like the iOS calculator. A dark display at the top showing the current number. Five rows of buttons below: top row (C / +/- / % / ÷), three rows of digits, bottom row with "0" stretched across two cells, decimal, and =.
Operators: +, -, *, /, =, plus C (clear), +/− (negate), and % (percent).
State machine
pub struct MyApp {
display: String,
first: Option<f64>,
operator: Option<char>,
reset_next: bool,
}
Four fields:
display: the string currently shown. Driven by digit and operator presses.first: the first operand of a pending operation.Noneuntil the user presses an operator.operator: the pending operator.Noneuntil pressed.reset_next: a flag that says "the next digit press should clear the display first." Set after pressing an operator or =.
When the user types 5 + 3 =:
- Press 5 →
display = "5".firstandoperatorstill None. - Press + →
first = Some(5.0),operator = Some('+'),reset_next = true. - Press 3 →
reset_nextis true, sodisplayclears, thendisplay = "3".reset_next = false. - Press = → evaluate
5 + 3 = 8, setdisplay = "8". Clearfirst,operator, setreset_next = true.
That is the whole engine.
Digit handling
fn press_digit(&mut self, digit: &str) {
if self.reset_next {
self.display = String::new();
self.reset_next = false;
}
if self.display == "0" && digit != "." {
self.display = digit.to_string();
} else if digit == "." && self.display.contains(".") {
return;
} else {
self.display.push_str(digit);
}
}
Three checks:
- If
reset_nextis set, clear the display before appending. - If the display is just "0" and we're not adding a decimal point, replace rather than append (otherwise "01" looks wrong).
- If the user presses "." and the number already has one, ignore it (no double decimals).
Otherwise, append the digit.
Operator handling
fn press_operator(&mut self, op: char) {
self.evaluate();
self.first = self.display.parse::<f64>().ok();
self.operator = Some(op);
self.reset_next = true;
}
When the user presses an operator:
- Evaluate any pending operation first. Lets the user type
5 + 3 + 2and have the second+evaluate5+3before storing 8 as the new first operand. - Store the current display value as
first. - Store the new operator.
- Set
reset_nextso the next digit replaces the display.
Evaluate
fn evaluate(&mut self) {
if let (Some(first), Some(op)) = (self.first, self.operator) {
if let Ok(second) = self.display.parse::<f64>() {
let result = match op {
'+' => first + second,
'-' => first - second,
'*' => first * second,
'/' => {
if second == 0.0 {
self.display = "Error".to_string();
// ... reset state
return;
}
first / second
}
_ => return,
};
// format result and write to display
}
}
self.first = None;
self.operator = None;
self.reset_next = true;
}
if let (Some(first), Some(op)) = (self.first, self.operator) — destructure both options together. Both must be Some to proceed.
The match op performs the operation. Division by zero short-circuits to "Error" instead of producing infinity.
After computing, format the result smartly: if it's an integer value, render without decimals; otherwise render with up to 6 decimal places, trimming trailing zeros. The result of 1/3 shows as 0.333333; the result of 4*5 shows as 20, not 20.000000.
Custom button styling
let button = if is_op {
egui::Button::new(
egui::RichText::new(label).size(20.0).color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(255, 149, 0))
} else if is_top {
egui::Button::new(egui::RichText::new(label).size(20.0))
.fill(egui::Color32::from_rgb(80, 80, 80))
} else {
egui::Button::new(egui::RichText::new(label).size(20.0))
.fill(egui::Color32::from_rgb(50, 50, 55))
};
Three button categories with three different styles. egui::Button::new(text) returns a builder we can configure with .fill(color). Using RichText for the label gives us per-text font sizing and color.
Operators get the iOS-orange treatment. Top row functions (C, +/-, %) are mid-grey. Digits are dark grey. Three colors, immediately recognisable.
Layout: rows of buttons
let rows = [
["C", "+/-", "%", "/"],
["7", "8", "9", "*"],
["4", "5", "6", "-"],
["1", "2", "3", "+"],
["0", ".", "=", ""],
];
for row in &rows {
ui.horizontal(|ui| {
for &label in row {
if label.is_empty() { continue; }
// ... custom button styling
let size = if label == "0" {
egui::vec2(btn_size.x * 2.0 + 4.0, btn_size.y)
} else {
btn_size
};
if ui.add_sized(size, button).clicked() {
// dispatch
}
}
});
}
Five rows, four cells each. The "" placeholder in the last row is skipped (the "0" button is double-wide, so the next cell is consumed by it).
For the "0" button specifically, we use a double-width size. Otherwise every button is the standard size.
ui.add_sized([w, h], widget) adds the widget at exactly that size. The button stretches or shrinks to fit.
The display
egui::TopBottomPanel::top("display")
.exact_height(60.0)
.show(ctx, |ui| {
egui::Frame::new()
.fill(egui::Color32::from_rgb(30, 30, 40))
.corner_radius(8.0)
.inner_margin(12.0)
.show(ui, |ui| {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
egui::RichText::new(&self.display)
.size(28.0)
.color(egui::Color32::WHITE)
.monospace(),
);
},
);
});
});
The display lives in a top panel of fixed height. Inside, an egui::Frame::new() provides a darker background with rounded corners and padding. A right-to-left layout pushes the number to the right edge — the conventional calculator look.
monospace() keeps digits aligned. Crucial for a number display.
Running it
cargo run. A small calculator window opens. Click 7 + 2 = — the display reads "9". Click C — clears to 0. Type 1 0 0 / 4 = — "25". Type 1 / 0 = — "Error".
The whole interaction feels like a calculator app because we got the small details — display alignment, button colors, the "0" width — right.
Common mistakes
Mixing the display and the parsed value. Keep display: String for what's on screen, parse to f64 only when computing. Don't try to keep them in sync as numbers; the string can hold "0.", which parse::<f64> handles, but other partial states (e.g., "-") would not.
No reset_next. Without it, pressing 5 + 3 makes display read "53" because the 3 appends to the 5. The reset_next flag is the bridge.
Hardcoded button widths. Computing button size from ui.available_width() makes the calculator scale with the window. Hardcoded values look broken on resize.
Floating-point display. format!("{}", result) for 1.0 / 3.0 gives 0.3333333333333333 — too many digits. Round to 6 places and trim trailing zeros for a clean look.
What's next
Next episode: a habit tracker. A grid-based weekly habit tracker with checkboxes, multiple habits, and a streak counter. Combines Grid, Vec<T> CRUD, and persistence.
Recap
A calculator is a state machine: display, first, operator, reset_next. Three button categories with three styles. Five rows of buttons in a for loop. Custom-styled egui::Button with .fill(color) and RichText labels. Smart number formatting (trim trailing zeros, special-case integers).
Next episode: habit tracker. See you in the next one.