Animated Progress Bar in Rust egui — Download Manager App | Ep 15
Video: Animated Progress Bar in Rust egui — Download Manager App | Ep 15 by Taught by Celeste AI - AI Coding Coach
egui::ProgressBar::new(0.0..=1.0)— a filled bar with optional percentage and animation.
Today's widget: progress bars. Visual feedback for any operation that takes time. Combined with ctx.request_repaint() to drive animation forward and a small state machine for the download lifecycle, you get a working download-manager UI.
What we are building
A simulated download manager. Click "Start Download" — a progress bar fills up over time, with a status line showing MB downloaded and current speed. When complete, the bar reaches 100% and the button changes to "Download Again." During downloading, a "Cancel" button is available.
The download is fake — progress += 0.005 per frame — but the UI mechanics are exactly the same as a real one. Replace the simulation with a real tokio task that updates progress from a download stream and the rest works.
State
pub struct MyApp {
file_name: String,
file_size: f32,
progress: f32, // 0.0 to 1.0
downloading: bool,
completed: bool,
speed: f32,
}
Six fields. The mechanics are: downloading is true while the operation runs, completed is true after it finishes, progress advances from 0.0 to 1.0 in between.
Driving the animation
if self.downloading {
self.speed = 12.5 + (self.progress * 8.0);
self.progress += 0.005;
if self.progress >= 1.0 {
self.progress = 1.0;
self.downloading = false;
self.completed = true;
self.speed = 0.0;
}
ctx.request_repaint();
}
Two key things in this block.
The state machine update. Each frame while downloading, we bump progress by 0.005 and adjust speed. When progress hits 1.0, we transition to the completed state.
ctx.request_repaint(). Critical. By default egui only repaints when something happens — mouse move, keypress, click. Idle frames are skipped to save CPU. But for an animation, we want to repaint continuously — otherwise the progress bar would only advance when the user moved the mouse over the window.
ctx.request_repaint() says "schedule the next frame as soon as possible." Calling it during an animation keeps the loop spinning. When the animation completes (downloading becomes false), we stop calling it, and egui goes idle again.
This is the standard pattern for any time-driven animation in egui.
ProgressBar
let bar = egui::ProgressBar::new(self.progress)
.show_percentage()
.animate(self.downloading);
ui.add(bar);
ProgressBar::new(progress) where progress is an f32 in 0.0..=1.0. Build the widget with options, add it to the UI.
.show_percentage()— display the percentage as text inside the bar..animate(true)— an animated stripe pattern that suggests "in progress." When done, set to false (or omit) for a static bar..text(RichText)— custom label inside the bar..fill(Color32::GREEN)— custom fill color.
For a determinate progress (you know the percentage), pass it in. For an indeterminate progress (you do not know how long it will take), use .animate(true) plus a fake constantly-incrementing value, or use egui::Spinner for a circular indicator.
State-driven button
if self.completed {
if ui.button("Download Again").clicked() {
self.progress = 0.0;
self.completed = false;
self.downloading = true;
}
} else if self.downloading {
if ui.button("Cancel").clicked() {
self.downloading = false;
self.progress = 0.0;
}
} else {
if ui.button("Start Download").clicked() {
self.downloading = true;
self.progress = 0.0;
self.completed = false;
}
}
The button's label and behaviour depend on which phase we are in. Three branches, each producing a different button. Same Rust if/else if/else you would write anywhere.
This is the immediate-mode pattern again: state determines what gets rendered. There is no "swap one button for another" call to make. The button you add this frame is the button the user sees.
Status line
let status = if self.completed {
String::from("Download complete!")
} else if self.downloading {
let downloaded = self.file_size * self.progress;
format!(
"Downloading... {:.1} / {:.1} MB ({:.1} MB/s)",
downloaded, self.file_size, self.speed,
)
} else {
String::from("Ready to download")
};
ui.label(&status);
Same pattern. The status string is computed once per frame from the current state. Three possible messages depending on the phase.
Running it
cargo run. The window opens with a "Ready to download" message and a "Start Download" button. Click the button — the progress bar starts filling, the status line updates with MB/sec, the bar's animation suggests "in progress."
While it is running, click "Cancel" — the operation stops, the bar resets, the button reverts to "Start Download." Or let it finish — the bar reaches 100%, the status reads "Download complete!", and the button changes to "Download Again."
In a real app
Replace the simulated update block with a channel that receives progress reports from a background task:
if let Ok(progress) = self.rx.try_recv() {
self.progress = progress;
if progress >= 1.0 {
self.completed = true;
self.downloading = false;
}
ctx.request_repaint();
}
Where self.rx is the receiving end of a std::sync::mpsc channel that the download task sends to. The pattern is exactly the same; only the source of progress changes.
For real network downloads, use a runtime like tokio and reqwest for the HTTP, then forward progress through a channel. egui doesn't run async tasks itself — it runs synchronously per frame, and async work happens in dedicated threads or runtimes.
Common mistakes
Forgetting ctx.request_repaint(). The animation freezes whenever the mouse stops moving. Always call it during animations.
Calling request_repaint() after the animation finishes. Wastes CPU in an idle loop. Only call it while there is reason to repaint.
Updating progress from a worker thread directly. Race conditions with the render. Always send progress through a channel and consume it inside update.
Storing duration as Instant and recomputing progress every frame. Works, but re-deriving progress is more error-prone than incrementing a stored f32. Store the value you care about.
What's next
Next episode: rich text styles. A demo of every RichText styling option — size, color, bold, italic, underline, strikethrough, monospace — combined with checkboxes that toggle sections of the demo on and off.
Recap
egui::ProgressBar::new(0..=1.0) for a filled bar; .show_percentage() for a label, .animate(true) for the in-progress shimmer. Drive animation with ctx.request_repaint(). Use if/else if/else to render different buttons and status text per phase. Replace the simulation with a channel from a worker thread for real operations.
Next episode: rich text styles. See you in the next one.