Build a Playlist Manager with egui — Vec State & CRUD | Rust GUI Ep 27
Video: Build a Playlist Manager with egui — Vec State & CRUD | Rust GUI Ep 27 by Taught by Celeste AI - AI Coding Coach
A
Vec<Song>plus the deferred-removal pattern. Add, list, favorite, and delete entries — the universal CRUD shape.
By Episode 26 we have built a lot of single-record apps — one counter, one form, one preview. Real apps deal with collections: contacts, posts, tasks, songs. The simple state for that is a Vec<T>, and the operations on it (Create, Read, Update, Delete) are the bread and butter of every app.
We have done bits of CRUD already (the post manager in Ep 22, the color palette in Ep 12). Today we focus on it directly: the patterns for editing a Vec in egui's borrow-checker-friendly way.
What we are building
A playlist manager. Top header. Bottom-docked input row with Title, Artist, Add Song button. Central scrollable list of songs, each with a star (favorite toggle), title, artist, and a remove button.
Operations: - Create: type title and artist, click Add Song. - Read: the list is the read view. - Update: click the star to toggle favorite. - Delete: click Remove to drop a song.
The state
struct Song {
title: String,
artist: String,
favorite: bool,
}
pub struct MyApp {
songs: Vec<Song>,
new_title: String,
new_artist: String,
}
songs holds the list. new_title and new_artist hold the in-progress new entry's fields. After an Add, we clear() the new fields so the form is ready for the next song.
Add: gated by validation
let can_add = !self.new_title.is_empty() && !self.new_artist.is_empty();
if ui
.add_enabled(can_add, egui::Button::new("Add Song"))
.clicked()
{
self.songs.push(Song {
title: self.new_title.clone(),
artist: self.new_artist.clone(),
favorite: false,
});
self.new_title.clear();
self.new_artist.clear();
}
Two patterns to notice.
ui.add_enabled(condition, widget) — adds the widget, but if condition is false, the widget is rendered greyed-out and click events are suppressed. Cleaner than wrapping in an if condition { ui.button(...) } block, because the button is visible (just disabled) so the user knows it's there but unusable.
String::clone() on push. The new song needs to own its title and artist strings. The form fields are &mut String — we clone for the song. Then clear() the form fields so the next entry starts blank.
Update: in-place mutation via iter_mut
for (i, song) in self.songs.iter_mut().enumerate() {
ui.horizontal(|ui| {
let star = if song.favorite { "★" } else { "☆" };
if ui.button(star).clicked() {
song.favorite = !song.favorite;
}
// ...
});
}
iter_mut gives mutable references to each element. We can mutate song.favorite directly. Each iteration borrows the current song mutably; the borrow ends when the loop body returns to the next iteration.
This is the canonical way to update items in a Vec. Per-item mutation, no index dance.
Delete: deferred-removal pattern
let mut to_remove: Option<usize> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
for (i, song) in self.songs.iter_mut().enumerate() {
ui.horizontal(|ui| {
// ... star, title, artist
if ui.button("Remove").clicked() {
to_remove = Some(i);
}
});
}
});
if let Some(i) = to_remove {
self.songs.remove(i);
}
We can't call self.songs.remove(i) inside the loop — we are iterating over self.songs, and Rust forbids mutating a Vec while iterating it.
The pattern: track the index in a mutable Option<usize>. After the loop, if the option is Some, remove the indicated entry.
For multiple potential removals per frame, use Vec<usize> and remove in reverse order:
to_remove.sort();
for &i in to_remove.iter().rev() {
self.songs.remove(i);
}
In reverse order so earlier indices stay valid.
Right-aligned remove button
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.button("Remove").clicked() {
to_remove = Some(i);
}
},
);
ui.with_layout lets you change the layout direction for a sub-region. Layout::right_to_left(Align::Center) flows widgets right-to-left, vertically centred. Used inside a horizontal block (the row), it pushes the Remove button to the far right.
The result: title and artist on the left, Remove on the right, like a typical list-item layout.
TextEdit::singleline with sized layout
ui.add_sized(
[150.0, 20.0],
egui::TextEdit::singleline(&mut self.new_title),
);
ui.add_sized([w, h], widget) forces the widget to a specific size. We use it for the form inputs to keep them at a consistent width regardless of content.
Without add_sized, the inputs would grow with the text inside them — fine for natural sizing, distracting in a fixed form layout.
Empty-state handling
if self.songs.is_empty() {
ui.label("No songs yet. Add one below!");
return;
}
When songs is empty, show a friendly placeholder instead of nothing. The early return from the closure (egui's central-panel show accepts a closure that returns (), so return exits the closure) skips the rest of the rendering.
Empty states are a small but valuable UX detail. Without them, the user sees a blank window and wonders if the app is working.
Star icon
let star = if song.favorite { "★" } else { "☆" };
if ui.button(star).clicked() {
song.favorite = !song.favorite;
}
Two Unicode glyphs, swapped based on the bool. Cheap, effective. egui renders Unicode characters that exist in the loaded font — and the default font supports common symbols like stars, arrows, and emojis.
For richer icons, look at libraries like egui-phosphor or load custom fonts in your CreationContext.
Running it
cargo run. The window shows three starter songs. Click the star next to "Hotel California" — it fills in. Click again — empties.
Type a title and artist in the bottom row. The Add Song button enables when both fields are non-empty. Click — the new song appears in the list, and the form clears.
Click Remove on any song — it disappears.
Common mistakes
Mutating self.songs inside iter or iter_mut. Use the deferred-removal pattern.
Using indices instead of iter_mut for updates. for i in 0..self.songs.len() { self.songs[i].favorite = ... } works but the iter_mut version is more idiomatic and slightly faster.
Forgetting to clear form fields after Add. Old text persists; the user has to delete it manually.
No empty-state message. Empty list, no UI feedback. Confusing for first-time users.
Cloning more than necessary. self.new_title.clone() is needed when pushing into the Vec. But don't clone strings just to read them; pass &str references where possible.
What's next
Next episode: a calculator app. Combining a state machine, custom button styling, and grid layout into a working pocket calculator. A bigger app to put all the patterns together.
Recap
Vec<T> for collections, plus three CRUD patterns: iter_mut for in-place updates, Option<usize> plus post-loop remove for deletion, clone() plus clear() for additions. ui.add_enabled(cond, widget) for gated buttons. with_layout(Layout::right_to_left(...)) for right-aligned children. Always handle the empty state.
Next episode: calculator app. See you in the next one.