Habit Tracker App in Rust egui — Grid, Checkboxes & CRUD | Learn egui Ep29
Video: Habit Tracker App in Rust egui — Grid, Checkboxes & CRUD | Learn egui Ep29 by Taught by Celeste AI - AI Coding Coach
A weekly grid of checkboxes, one row per habit, with streak counters and a daily-completion summary.
In Episode 28 we built a calculator. Today's app is similar in scope but very different in shape: a habit tracker. A grid where rows are habits, columns are days of the week, and each cell is a clickable checkbox-like button. Plus computed values per row (streak, total) and a global summary (X out of Y completed).
The new lessons here are mostly about the calculations — streak(), completed() — and how the grid composes with iter_mut over a Vec<Habit> where each habit owns a [bool; 7] array.
What we are building
Top header showing total completion. Bottom panel for adding new habits. Central panel: a striped grid with one row per habit, columns for each day, and Done / Streak / Delete columns at the end.
The data
const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
struct Habit {
name: String,
days: [bool; 7],
}
impl Habit {
fn new(name: &str) -> Self {
Self { name: name.to_string(), days: [false; 7] }
}
fn streak(&self) -> usize {
self.days.iter().rev().take_while(|&&d| d).count()
}
fn completed(&self) -> usize {
self.days.iter().filter(|&&d| d).count()
}
}
A habit owns a 7-element bool array — one cell per day of the week. Two derived values:
streak()counts the number of consecutivetrues from the end of the array. Iterate in reverse,take_while(|&&d| d)keeps days as long as they are completed,countreturns how many.completed()counts all thetrues in the array.
These are pure functions. They read from self.days and return a number; they never mutate. Cheap to call every frame.
Grid + iter_mut for editing
egui::Grid::new("habit_grid")
.striped(true)
.min_col_width(40.0)
.show(ui, |ui| {
// header row
ui.label(RichText::new("Habit").strong());
for day in &DAYS { ui.label(RichText::new(*day).strong()); }
ui.label(RichText::new("Done").strong());
ui.label(RichText::new("Streak").strong());
ui.label(RichText::new("").strong());
ui.end_row();
// habit rows
for (i, habit) in self.habits.iter_mut().enumerate() {
ui.label(&habit.name);
for day in habit.days.iter_mut() {
// checkbox-like button that toggles *day
}
ui.label(format!("{}/7", habit.completed()));
ui.label(format!("{}", habit.streak()));
// delete button
ui.end_row();
}
});
Grid::striped(true) adds alternating row backgrounds for readability. The header row uses RichText::strong() for emphasis. Each habit row has 11 cells: name, 7 days, Done, Streak, Delete.
iter_mut gives a mutable reference to each habit. Inside, habit.days.iter_mut() lets us mutate each individual day cell. Inner-loop mutation is fine because the inner iterator borrows from habit.days, which we have a mutable reference to via habit.
The day cells
for day in habit.days.iter_mut() {
let color = if *day {
egui::Color32::from_rgb(100, 200, 100) // green when done
} else {
egui::Color32::from_rgb(80, 80, 80) // grey when not
};
let symbol = if *day { "X" } else { "." };
if ui.add(
egui::Button::new(RichText::new(symbol).color(Color32::WHITE)).fill(color),
).clicked() {
*day = !*day;
}
}
Each day cell is a small custom-styled button. Green if done, grey if not. The label is "X" or "." — minimal but readable. Click toggles *day.
We could have used ui.checkbox but a button with custom fill is more visually distinct in a grid context. Each cell is the same size (set by min_col_width) so the row reads as a horizontal strip of binary states.
Streak coloring
let streak_color = if streak >= 5 {
egui::Color32::from_rgb(255, 200, 50) // gold for 5+
} else if streak >= 3 {
egui::Color32::from_rgb(100, 200, 100) // green for 3+
} else {
egui::Color32::GRAY
};
ui.label(RichText::new(format!("{}", streak)).color(streak_color));
Three tiers based on the streak length. Gives users a visual reward for longer streaks. The colors are arbitrary — the principle is graded feedback: small wins look small, big wins look big.
For more elaborate apps you might use a fire emoji 🔥 for streak >= 7, or animate the cell when a streak threshold is hit. The pattern is the same — read the value, branch the styling.
Global completion summary
let total: usize = self.habits.iter().map(|h| h.completed()).sum();
let possible = self.habits.len() * 7;
if possible > 0 {
let pct = total as f32 / possible as f32;
ui.label(format!("{}/{} ({:.0}%)", total, possible, pct * 100.0));
}
Sum each habit's completed(), divide by total possible (habits × 7). Display as ratio plus percentage.
This is computed every frame. For a few habits with seven days each, it is essentially free. For a thousand habits across a year, you would cache. But until then, recompute on each render.
Validation on Add
let can_add = !self.new_habit.trim().is_empty();
if ui.add_enabled(can_add, egui::Button::new("Add")).clicked() {
self.habits.push(Habit::new(self.new_habit.trim()));
self.new_habit.clear();
}
trim() first to ignore whitespace-only input. add_enabled greys out the button when there is nothing to add. Push the new habit, clear the form.
Running it
cargo run. Three starter habits visible. Click cells to mark days as done. Click "Del" to remove a habit. Type a name in the bottom row and Add to create a new one. The header counter updates live; the streak column highlights yellow when you string together 5+ consecutive completed days.
Common mistakes
Mutating self.habits inside the iter_mut loop. Same deferred-removal pattern as Episode 27 — track Option<usize> then remove after the loop.
Streak counting from the wrong end. streak counts recent successes (from the end of the array). If your model uses the start of the array as "most recent," reverse the iteration order.
Forgetting .strong() on the header row. Without visual emphasis, headers blur into data rows.
Not using min_col_width. Each column otherwise sizes to its content, making the day columns visually inconsistent.
What's next
Next episode: drawing app. A canvas with mouse-drag freehand drawing, color picker, brush width, undo, and clear. Combines allocate_painter, dragged_by, and stroke storage.
Recap
Habit { name, days: [bool; 7] } plus pure helper methods (streak, completed). Grid with striped rows. Custom-styled day buttons that toggle a bool. Tiered color feedback for streaks. Live total computed across all habits.
Next episode: drawing app. See you in the next one.