Back to Blog

Calculator App in Rust egui — Grid Layout & Operator Logic | Learn egui Ep28

Celest KimCelest Kim

Video: Calculator App in Rust egui — Grid Layout & Operator Logic | Learn egui Ep28 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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. None until the user presses an operator.
  • operator: the pending operator. None until 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 =:

  1. Press 5 → display = "5". first and operator still None.
  2. Press + → first = Some(5.0), operator = Some('+'), reset_next = true.
  3. Press 3 → reset_next is true, so display clears, then display = "3". reset_next = false.
  4. Press = → evaluate 5 + 3 = 8, set display = "8". Clear first, operator, set reset_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_next is 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:

  1. Evaluate any pending operation first. Lets the user type 5 + 3 + 2 and have the second + evaluate 5+3 before storing 8 as the new first operand.
  2. Store the current display value as first.
  3. Store the new operator.
  4. Set reset_next so 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.