Drawing App in Rust egui — Painter, Strokes & Undo | Learn egui Ep30
Video: Drawing App in Rust egui — Painter, Strokes & Undo | Learn egui Ep30 by Taught by Celeste AI - AI Coding Coach
ui.allocate_painter,dragged_by(Primary), aVec<Stroke>with one entry per pen-down-pen-up. Free-hand drawing in egui.
A drawing app is a fun small project that pulls together a lot of egui ideas. Mouse-drag input, custom painting with a Painter, state for the in-progress stroke, plus a list of completed strokes for redrawing each frame. Add color, brush width, undo, and clear — and you have a real micro-app.
What we are building
A canvas covering most of the window. A toolbar at the top with color picker, brush width slider, stroke counter, Undo, and Clear. Drag with the left mouse button to draw. Release to finish a stroke. Drag again to start another. Pick a different color and brush — they apply to subsequent strokes. Undo removes the last stroke; Clear removes everything.
The state
struct Stroke {
points: Vec<egui::Pos2>,
color: egui::Color32,
width: f32,
}
pub struct MyApp {
strokes: Vec<Stroke>, // completed strokes
current_stroke: Vec<egui::Pos2>, // points being added right now
color: [f32; 3],
width: f32,
is_drawing: bool,
}
Two collections: strokes (completed) and current_stroke (in progress). When the user releases the mouse, current_stroke is moved into a new Stroke entry in strokes.
is_drawing is the state-machine flag. It tells us whether to interpret the current frame's input as continuing a stroke or as the start of a new one.
Allocating the canvas
let (response, painter) = ui.allocate_painter(
ui.available_size(),
egui::Sense::click_and_drag(),
);
let rect = response.rect;
allocate_painter(size, sense) is the more-convenient form of allocate_exact_size + ui.painter(). It returns both at once: the Response (for events) and a Painter already scoped to the allocated rectangle.
ui.available_size() — claim everything left in the central panel after the toolbar. The canvas sizes to fill the window.
Drawing input
if response.dragged_by(egui::PointerButton::Primary) {
if let Some(pos) = response.interact_pointer_pos() {
if rect.contains(pos) {
if !self.is_drawing {
self.is_drawing = true;
self.current_stroke.clear();
}
self.current_stroke.push(pos);
}
}
ctx.request_repaint();
}
While the user is dragging the canvas:
- Get the cursor position via
interact_pointer_pos(). - Make sure it's inside the canvas rectangle (in case the cursor went outside).
- If we weren't drawing yet, start a new stroke (
is_drawing = true, clear the current points buffer). - Append the current cursor position to
current_stroke.
request_repaint() keeps the loop spinning so we capture every frame's drag delta.
Releasing the mouse
} else if self.is_drawing {
self.is_drawing = false;
if self.current_stroke.len() >= 2 {
let color = egui::Color32::from_rgb(/* convert from f32 */);
self.strokes.push(Stroke {
points: self.current_stroke.clone(),
color,
width: self.width,
});
}
self.current_stroke.clear();
}
When dragged_by(Primary) returns false but is_drawing is true, the user just released. We:
- Reset
is_drawingto false. - If the stroke has at least 2 points, store it (single-point strokes are dots — you could store them as circles, but we discard them here).
- Clear
current_strokeso the next pen-down starts fresh.
The stroke captures the current color and width at completion time. Changing the color picker later does not retroactively recolor old strokes.
Drawing the strokes
for stroke in &self.strokes {
for pair in stroke.points.windows(2) {
painter.line_segment(
[pair[0], pair[1]],
egui::Stroke::new(stroke.width, stroke.color),
);
}
}
Every frame we redraw every stroke. For each stroke, we walk consecutive pairs of points (windows(2) from the slice API) and draw a line segment between them. With enough points captured per second of drag, the segments appear as a smooth line.
Plus the in-progress stroke:
if self.current_stroke.len() >= 2 {
for pair in self.current_stroke.windows(2) {
painter.line_segment(
[pair[0], pair[1]],
egui::Stroke::new(self.width, color),
);
}
}
Same loop, different source. The current stroke shows up as the user drags, before being committed to the strokes vector.
Undo and clear
if ui.add_enabled(!self.strokes.is_empty(), egui::Button::new("Undo")).clicked() {
self.strokes.pop();
}
if ui.add_enabled(!self.strokes.is_empty(), egui::Button::new("Clear")).clicked() {
self.strokes.clear();
}
pop() removes the last Stroke. clear() empties the vector. Both buttons are disabled when there's nothing to remove, so the UI doesn't lie about what is possible.
For a more elaborate undo, you would keep a redo stack — each Undo pops to a redo stack, each Redo pushes back. The plumbing is mechanical; the architecture is the same.
Smoothing strokes
The stroke as captured is a polyline through the cursor positions. At slow drag speeds, you get many close points; at fast speeds, fewer points farther apart. Lines look slightly jagged.
For smoother strokes, sample more often (cursor position is captured per frame, so 60Hz at most), or apply Catmull-Rom spline interpolation between captured points before drawing. Both are small additions; neither is strictly necessary.
Running it
cargo run. The dark canvas fills most of the window. Drag to draw. Release. Pick a different color. Drag again — different color. Adjust the width slider — wider strokes for the next drag.
Click Undo to remove the last stroke. Click Clear to wipe everything.
Persistence (an exercise)
Combine with Episode 23's persistence: derive Serialize, Deserialize on Stroke and MyApp, and your drawings survive restarts. Color32 and Pos2 already implement Serialize and Deserialize via egui's serde features.
Common mistakes
Forgetting to handle the cursor leaving the canvas. rect.contains(pos) filters out drag events outside the canvas — without it, you can draw outside the visible region and the strokes overflow.
Drawing the canvas after the strokes. Order matters: paint the background first, then the strokes on top, then the border. Reversed order hides the strokes behind the background.
Storing the color in the stroke as [f32; 3]. The picker uses floats, but Color32 is what egui draws with. Convert at commit time so the stored stroke uses the rendering type.
Not clamping current_stroke.clone() size. A long drag at high frame rate can produce thousands of points per stroke. For performance, simplify with Douglas-Peucker or sample at fixed intervals.
What's next
Next episode: settings dashboard with persistence. A tabbed settings UI saving and loading via eframe::Storage. Combines side panel, tab navigation, multiple settings categories, and serde.
Recap
ui.allocate_painter(size, sense) for combined draw+input regions. dragged_by(Primary) plus interact_pointer_pos for drag input. points.windows(2) plus painter.line_segment for polyline rendering. State machine: is_drawing tracks pen-down vs released. Store completed strokes in a Vec; redraw every frame.
Next episode: settings dashboard. See you in the next one.