Rust egui Tutorial #19: Custom Painting — Draw Shapes with the Painter API
Video: Rust egui Tutorial #19: Custom Painting — Draw Shapes with the Painter API by Taught by Celeste AI - AI Coding Coach
ui.painter().line_segment([p1, p2], stroke),.circle_filled(center, radius, color),.rect_stroke(...), plus polygon shapes — egui's lower-level draw API.
Most apps you build with egui never need custom painting. Widgets cover 95% of UI needs. The other 5% — charts, sparklines, custom indicators, drawing apps, simulations — needs lines and shapes drawn directly. egui's Painter API gives you that.
We have already used painter().rect_filled for color swatches (Episode 12) and slider previews (Episode 14). Today we look at the full Painter surface and build a small "shape canvas" demo.
What we are building
A toolbar at the top with shape selector (Line / Circle / Rect / Triangle), color picker, filled checkbox, Add button, Clear button. A canvas below that lays out one shape per row. Pick a shape and color, click Add — it appears on the canvas in a new grid cell.
The shape state
#[derive(PartialEq, Clone, Copy)]
enum ShapeKind { Line, Circle, Rectangle, Triangle }
struct DrawnShape {
kind: ShapeKind,
color: egui::Color32,
filled: bool,
}
pub struct MyApp {
shape: ShapeKind, // currently-selected
color: [f32; 3],
filled: bool,
shapes: Vec<DrawnShape>,
}
The model: a current selection (what would be drawn next) and a list of DrawnShape instances (what has been drawn). Each Add push creates a new entry from the current selection.
Allocating a canvas
let size = egui::vec2(440.0, 340.0);
let (canvas, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let painter = ui.painter();
painter.rect_filled(canvas, 0.0, egui::Color32::from_rgb(30, 30, 46));
Same pattern as before. allocate_exact_size reserves a rectangle in the layout. ui.painter() returns a Painter scoped to this Ui's region. We fill the canvas with a dark background using rect_filled.
Painter methods
The Painter has a small but expressive API. The methods we use today:
line_segment([p1, p2], stroke)— a straight line between two points.circle_filled(center, radius, color)— solid circle.circle_stroke(center, radius, stroke)— circle outline.rect_filled(rect, rounding, color)— filled rectangle.rect_stroke(rect, rounding, stroke, stroke_kind)— rectangle outline.add(Shape)— generic shape adder. Used for polygons, paths, and anything not covered by the convenience methods.
A few more not used here that you should know about:
text(pos, anchor, text, font, color)— paint text directly (different fromui.labelbecause it does not participate in layout).add(Shape::path(...))— arbitrary paths for charts, splines, custom curves.hline(x_range, y, stroke),vline(x, y_range, stroke)— convenient horizontal/vertical lines.
Stroke
let stroke = egui::Stroke::new(2.0, s.color);
Stroke::new(width, color) describes a line: how thick, what color. Used by every outline-drawing method (line_segment, circle_stroke, rect_stroke, polygon outlines).
There is also egui::Stroke::NONE for "no outline" — useful when you want a filled shape with no border.
Polygons
let pts = vec![
egui::pos2(x, y - 30.0),
egui::pos2(x - 30.0, y + 20.0),
egui::pos2(x + 30.0, y + 20.0),
];
if s.filled {
painter.add(egui::Shape::convex_polygon(pts, s.color, egui::Stroke::NONE));
} else {
painter.add(egui::Shape::closed_line(pts, stroke));
}
Triangles (and any polygon) are not in the convenience method list. We build a Shape and pass it to painter.add.
Shape::convex_polygon(points, fill, stroke) builds a filled polygon. Shape::closed_line(points, stroke) builds an outlined polygon (the line returns to the start).
For arbitrary shapes (concave, with holes, with curves), use Shape::path or egui::epaint::Path.
Rectangle outlines and stroke kind
painter.rect_stroke(rect, 4.0, stroke, egui::StrokeKind::Outside);
rect_stroke takes a fourth argument: where the stroke goes relative to the rectangle's edge — Inside, Middle, or Outside. For a 100×100 rectangle with a 4px outside stroke, the visible bounding box is 108×108.
For most app UIs, Outside is the right default — it preserves the rectangle's nominal size and grows the visible region.
Coordinate space
let x = canvas.min.x + 55.0 + col * 100.0;
All Painter coordinates are in screen space — the same coordinate system the layout uses. canvas.min is the top-left of the allocated rectangle. To draw something inside the canvas, offset from canvas.min.
If you want a logical coordinate system different from the screen (e.g., a 0–100 grid), apply your own transform: scale and offset values from your model space into screen space before passing to the Painter. egui does not have a transform stack like Canvas2D — the math is yours.
Layout in a grid pattern
let col = (i % 4) as f32;
let row = (i / 4) as f32;
let x = canvas.min.x + 55.0 + col * 100.0;
let y = canvas.min.y + 55.0 + row * 100.0;
Each shape is placed in a 4-column grid. We compute column and row from the index (i % 4 and i / 4), then offset by 100px between cells. Standard grid layout in screen coordinates.
For a real charting library you would use a scale (an f64 -> f32 mapping) instead of hard-coded offsets, but the math here is simple enough to inline.
Running it
cargo run. The toolbar at the top has the shape selector, color picker, filled checkbox, Add and Clear buttons. The canvas below is dark blue.
Pick "Circle", choose orange, leave Filled unchecked. Click Add — an orange circle outline appears in the top-left of the canvas. Click Add again — a second one next to the first. Toggle Filled, click Add — a filled circle. Switch to Triangle, change color, Add — a triangle in the next cell.
Click Clear — the canvas empties. The state model (Vec<DrawnShape>) makes this a one-line operation.
Common mistakes
Using ui.painter() outside an active Ui. The painter is tied to a Ui; using a stale reference after the closure ends panics or misbehaves. Always paint inside the same closure where you allocated the canvas.
Forgetting to allocate space. Calling painter.rect_filled(some_rect, ...) without first allocating means egui has not reserved layout space and your subsequent widgets will overlap. Always allocate_exact_size or allocate_response.
Using world coordinates without transforming. Painter is screen-space; if you have logical coordinates, convert them to screen space before painting.
Drawing thousands of shapes per frame. The painter batches and draws efficiently, but very large shape counts (10,000+) can slow down. For dense plots, look at egui_plot or batch shapes into one Shape::Path.
What's next
Next episode: drag values. egui::DragValue — a numeric input you can drag-to-modify rather than slide. Better than a slider for unbounded values, smaller in the UI, easy to combine with grids for transform editors.
Recap
ui.painter() for direct draw. line_segment, circle_filled, rect_filled, rect_stroke for the common shapes. Shape::convex_polygon and Shape::closed_line via painter.add for arbitrary polygons. Stroke::new(width, color) for outlines. All coordinates are screen-space.
Next episode: drag values. See you in the next one.