Skip to main content

elegance/
steps.rs

1//! Stepped progress — a sequence of discrete steps with per-step state.
2//!
3//! Three visual styles share the same state model:
4//! - [`StepsStyle::Cells`] renders a segmented bar of uniform rounded cells
5//!   with small gaps between them. Compact "N of M" progress.
6//! - [`StepsStyle::Numbered`] renders numbered circles connected by thin
7//!   lines. Done steps show a checkmark; the active step glows. Suits
8//!   user-facing wizard and onboarding flows. Pair with [`Steps::labeled`]
9//!   to render captions under each circle, and
10//!   [`Steps::active_sublabel`] for an "in progress" hint under the
11//!   active step.
12//! - [`StepsStyle::Labeled`] renders a sequence of labeled pills — taller
13//!   cells containing a text label. Horizontal by default (a progress bar
14//!   with readable stage names); flip to vertical with
15//!   [`Steps::vertical`] for wizard sidebars and checklist flows. Pair
16//!   with [`Steps::labeled`] to supply the labels.
17//!
18//! All styles read the same three fields: `total` steps, `current` = how
19//! many are complete (0..=total), and `errored` = whether the current
20//! step failed.
21
22use egui::{
23    Color32, CornerRadius, Painter, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2,
24    Widget, WidgetInfo, WidgetType,
25};
26
27use crate::theme::{with_alpha, Theme};
28
29/// Visual style for a [`Steps`] widget.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum StepsStyle {
32    /// Segmented bar of uniform rounded cells with small gaps.
33    Cells,
34    /// Numbered circles connected by thin lines. Done dots show a check;
35    /// the active dot glows.
36    Numbered,
37    /// Labeled pills — taller cells containing a text label. Horizontal by
38    /// default; flip with [`Steps::vertical`] for a wizard sidebar.
39    Labeled,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum StepState {
44    Done,
45    Active,
46    Error,
47    Pending,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Orient {
52    Horizontal,
53    Vertical,
54}
55
56/// A stepped progress indicator.
57///
58/// ```no_run
59/// # use elegance::{Steps, StepsStyle};
60/// # egui::__run_test_ui(|ui| {
61/// // Release pipeline: 4 of 6 steps complete, step 5 running.
62/// ui.add(Steps::new(6).current(4));
63///
64/// // Migration failed on step 3.
65/// ui.add(Steps::new(5).current(2).errored(true));
66///
67/// // Onboarding — numbered circles.
68/// ui.add(Steps::new(5).current(2).style(StepsStyle::Numbered));
69///
70/// // Setup wizard — numbered circles with labels and an "in progress"
71/// // hint under the active step.
72/// ui.add(
73///     Steps::labeled(["Account", "Workspace", "Billing", "Integrations", "Review"])
74///         .style(StepsStyle::Numbered)
75///         .current(2)
76///         .active_sublabel("In progress"),
77/// );
78///
79/// // Horizontal labeled strip — a progress bar with stage names.
80/// ui.add(
81///     Steps::labeled(["Plan", "Build", "Test", "Deploy"])
82///         .current(2),
83/// );
84///
85/// // Vertical wizard sidebar.
86/// ui.add(
87///     Steps::labeled(["Plan", "Design", "Build", "Test", "Deploy"])
88///         .current(2)
89///         .vertical(),
90/// );
91/// # });
92/// ```
93#[derive(Debug, Clone)]
94#[must_use = "Add with `ui.add(...)`."]
95pub struct Steps {
96    total: usize,
97    current: usize,
98    errored: bool,
99    style: StepsStyle,
100    orientation: Orient,
101    labels: Vec<String>,
102    active_sublabel: Option<String>,
103    height: Option<f32>,
104    desired_width: Option<f32>,
105}
106
107impl Steps {
108    /// Create a cells-style stepped bar with `total` steps (clamped to at
109    /// least 1), all pending.
110    pub fn new(total: usize) -> Self {
111        Self {
112            total: total.max(1),
113            current: 0,
114            errored: false,
115            style: StepsStyle::Cells,
116            orientation: Orient::Horizontal,
117            labels: Vec::new(),
118            active_sublabel: None,
119            height: None,
120            desired_width: None,
121        }
122    }
123
124    /// Create a [`StepsStyle::Labeled`] widget whose step count and labels
125    /// come from `labels`. Horizontal by default; call [`Self::vertical`]
126    /// for a wizard-sidebar layout. All steps start pending; add
127    /// `.current(n)` to mark the first `n` as done.
128    ///
129    /// Pair with `.style(StepsStyle::Numbered)` to render the same labels
130    /// as captions under numbered circles, an onboarding stepper layout.
131    pub fn labeled(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
132        let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
133        Self {
134            total: labels.len().max(1),
135            current: 0,
136            errored: false,
137            style: StepsStyle::Labeled,
138            orientation: Orient::Horizontal,
139            labels,
140            active_sublabel: None,
141            height: None,
142            desired_width: None,
143        }
144    }
145
146    /// Render labeled cells stacked vertically. Only affects
147    /// [`StepsStyle::Labeled`].
148    #[inline]
149    pub fn vertical(mut self) -> Self {
150        self.orientation = Orient::Vertical;
151        self
152    }
153
154    /// Render labeled cells arranged horizontally (the default for
155    /// [`Steps::labeled`]). Provided as the explicit counterpart to
156    /// [`Self::vertical`].
157    #[inline]
158    pub fn horizontal(mut self) -> Self {
159        self.orientation = Orient::Horizontal;
160        self
161    }
162
163    /// Set how many steps are complete. Clamped to `0..=total`.
164    #[inline]
165    pub fn current(mut self, current: usize) -> Self {
166        self.current = current.min(self.total);
167        self
168    }
169
170    /// When `true`, paint the step at `current` as errored instead of
171    /// active. No effect when `current == total` (nothing to error on).
172    #[inline]
173    pub fn errored(mut self, errored: bool) -> Self {
174        self.errored = errored;
175        self
176    }
177
178    /// Pick the visual style. Default: [`StepsStyle::Cells`].
179    #[inline]
180    pub fn style(mut self, style: StepsStyle) -> Self {
181        self.style = style;
182        self
183    }
184
185    /// Caption shown directly under the active step's label (numbered
186    /// style with labels only). Useful for stepper-style "In progress" or
187    /// "Action required" hints. No effect on other styles or when the
188    /// pipeline has no active step.
189    #[inline]
190    pub fn active_sublabel(mut self, text: impl Into<String>) -> Self {
191        self.active_sublabel = Some(text.into());
192        self
193    }
194
195    /// Override the cell height (cells style) or dot diameter (numbered
196    /// style). Defaults: 6 for cells, 22 for numbered.
197    #[inline]
198    pub fn height(mut self, height: f32) -> Self {
199        self.height = Some(height);
200        self
201    }
202
203    /// Override the total width. Defaults to `ui.available_width()`.
204    #[inline]
205    pub fn desired_width(mut self, width: f32) -> Self {
206        self.desired_width = Some(width);
207        self
208    }
209
210    fn step_state(&self, i: usize) -> StepState {
211        if i < self.current {
212            StepState::Done
213        } else if i == self.current && self.current < self.total {
214            if self.errored {
215                StepState::Error
216            } else {
217                StepState::Active
218            }
219        } else {
220            StepState::Pending
221        }
222    }
223}
224
225impl Widget for Steps {
226    fn ui(self, ui: &mut Ui) -> Response {
227        match self.style {
228            StepsStyle::Cells => paint_cells(ui, &self),
229            StepsStyle::Numbered => paint_numbered(ui, &self),
230            StepsStyle::Labeled => paint_labeled(ui, &self),
231        }
232    }
233}
234
235fn paint_cells(ui: &mut Ui, s: &Steps) -> Response {
236    let theme = Theme::current(ui.ctx());
237    let p = &theme.palette;
238
239    let height = s.height.unwrap_or(6.0);
240    let gap = 4.0;
241    let width = s
242        .desired_width
243        .unwrap_or_else(|| ui.available_width())
244        .max(height * 2.0);
245
246    let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
247
248    if ui.is_rect_visible(rect) {
249        let painter = ui.painter();
250        let n = s.total as f32;
251        let total_gap = gap * (n - 1.0).max(0.0);
252        let cell_w = ((width - total_gap) / n).max(1.0);
253        let radius = CornerRadius::same((height * 0.65).round().clamp(2.0, 8.0) as u8);
254        let pending_fill = p.depth_tint(p.card, 0.08);
255
256        for i in 0..s.total {
257            let x = rect.min.x + (cell_w + gap) * i as f32;
258            let cell_rect =
259                Rect::from_min_size(Pos2::new(x, rect.min.y), Vec2::new(cell_w, height));
260            let fill = match s.step_state(i) {
261                StepState::Done => p.success,
262                StepState::Active => p.sky,
263                StepState::Error => p.danger,
264                StepState::Pending => pending_fill,
265            };
266            painter.rect(cell_rect, radius, fill, Stroke::NONE, StrokeKind::Inside);
267        }
268    }
269
270    response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
271    response
272}
273
274fn paint_numbered(ui: &mut Ui, s: &Steps) -> Response {
275    let theme = Theme::current(ui.ctx());
276    let p = &theme.palette;
277    let t = &theme.typography;
278
279    let dot_d = s.height.unwrap_or(22.0);
280    let dot_r = dot_d * 0.5;
281    let conn_h = (dot_d * 0.09).max(1.5);
282    let conn_inset = 4.0;
283    let width = s
284        .desired_width
285        .unwrap_or_else(|| ui.available_width())
286        .max(dot_d * s.total as f32);
287
288    let has_labels = !s.labels.is_empty();
289    let has_sublabel = has_labels && s.active_sublabel.is_some() && s.current < s.total;
290    let label_gap = 8.0;
291    let sublabel_gap = 2.0;
292    let label_block = if has_labels { t.body + label_gap } else { 0.0 };
293    let sublabel_block = if has_sublabel {
294        t.small + sublabel_gap
295    } else {
296        0.0
297    };
298    let total_h = dot_d + label_block + sublabel_block;
299
300    let (rect, response) = ui.allocate_exact_size(Vec2::new(width, total_h), Sense::hover());
301
302    if !ui.is_rect_visible(rect) {
303        response
304            .widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
305        return response;
306    }
307
308    let painter = ui.painter();
309    let center_y = rect.min.y + dot_r;
310
311    let dot_center_x = |i: usize| -> f32 {
312        if s.total == 1 {
313            rect.center().x
314        } else {
315            let f = i as f32 / (s.total - 1) as f32;
316            rect.min.x + dot_r + f * (width - dot_d)
317        }
318    };
319
320    let pending_fill = p.depth_tint(p.card, 0.08);
321
322    for i in 0..s.total.saturating_sub(1) {
323        let x_start = dot_center_x(i) + dot_r + conn_inset;
324        let x_end = dot_center_x(i + 1) - dot_r - conn_inset;
325        if x_end <= x_start {
326            continue;
327        }
328        let conn_rect = Rect::from_min_max(
329            Pos2::new(x_start, center_y - conn_h * 0.5),
330            Pos2::new(x_end, center_y + conn_h * 0.5),
331        );
332        let color = match s.step_state(i) {
333            StepState::Done => p.success,
334            _ => pending_fill,
335        };
336        painter.rect_filled(conn_rect, CornerRadius::ZERO, color);
337    }
338
339    for i in 0..s.total {
340        let center = Pos2::new(dot_center_x(i), center_y);
341        let state = s.step_state(i);
342        let (fill, text_color) = match state {
343            StepState::Done => (p.success, Color32::WHITE),
344            StepState::Active => (p.sky, Color32::WHITE),
345            StepState::Error => (p.danger, Color32::WHITE),
346            StepState::Pending => (pending_fill, p.text_muted),
347        };
348
349        if matches!(state, StepState::Active) {
350            painter.circle_filled(center, dot_r + 3.0, with_alpha(p.sky, 64));
351        }
352
353        painter.circle_filled(center, dot_r, fill);
354
355        if matches!(state, StepState::Done) {
356            paint_check(painter, center, dot_r * 0.45, text_color);
357        } else {
358            let label = (i + 1).to_string();
359            let font_size = (dot_d * 0.55).max(10.0).min(t.body);
360            let galley =
361                crate::theme::placeholder_galley(ui, &label, font_size, true, f32::INFINITY);
362            let pos = Pos2::new(
363                center.x - galley.size().x * 0.5,
364                center.y - galley.size().y * 0.5,
365            );
366            painter.galley(pos, galley, text_color);
367        }
368
369        if has_labels {
370            if let Some(label_text) = s.labels.get(i) {
371                let is_active = matches!(state, StepState::Active | StepState::Error);
372                let label_color = match state {
373                    StepState::Done => p.text_muted,
374                    StepState::Active => p.text,
375                    StepState::Error => p.danger,
376                    StepState::Pending => p.text_muted,
377                };
378                let label_galley = crate::theme::placeholder_galley(
379                    ui,
380                    label_text,
381                    t.body,
382                    is_active,
383                    f32::INFINITY,
384                );
385                let label_y = rect.min.y + dot_d + label_gap;
386                let pos = Pos2::new(center.x - label_galley.size().x * 0.5, label_y);
387                painter.galley(pos, label_galley, label_color);
388
389                if has_sublabel && i == s.current {
390                    if let Some(sub) = s.active_sublabel.as_deref() {
391                        let sub_galley = crate::theme::placeholder_galley(
392                            ui,
393                            sub,
394                            t.small,
395                            false,
396                            f32::INFINITY,
397                        );
398                        let sub_y = label_y + t.body + sublabel_gap;
399                        let sub_pos = Pos2::new(center.x - sub_galley.size().x * 0.5, sub_y);
400                        painter.galley(sub_pos, sub_galley, p.text_faint);
401                    }
402                }
403            }
404        }
405    }
406
407    response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
408    response
409}
410
411fn paint_labeled(ui: &mut Ui, s: &Steps) -> Response {
412    let theme = Theme::current(ui.ctx());
413    let p = &theme.palette;
414    let t = &theme.typography;
415
416    let pill_h = s.height.unwrap_or(32.0);
417    let horizontal = matches!(s.orientation, Orient::Horizontal);
418    // Wider gaps on Labeled so a chevron connector fits cleanly between cells.
419    let gap = if horizontal { 22.0 } else { 20.0 };
420    let icon_gap = 6.0;
421    let pending_fill = p.depth_tint(p.card, 0.08);
422    let n = s.total;
423    let width = s.desired_width.unwrap_or_else(|| ui.available_width());
424
425    let (alloc_size, cell_w) = if horizontal {
426        let total_gap = gap * n.saturating_sub(1) as f32;
427        let cell_w = ((width - total_gap) / n as f32).max(1.0);
428        (Vec2::new(width, pill_h), cell_w)
429    } else {
430        let total_h = pill_h * n as f32 + gap * n.saturating_sub(1) as f32;
431        (Vec2::new(width, total_h), width)
432    };
433
434    let (rect, response) = ui.allocate_exact_size(alloc_size, Sense::hover());
435
436    if ui.is_rect_visible(rect) {
437        let painter = ui.painter();
438        let radius = CornerRadius::same((pill_h * 0.22).round().clamp(4.0, 12.0) as u8);
439        let chevron_color = p.text_faint;
440
441        for i in 0..n {
442            let cell_rect = if horizontal {
443                let x = rect.min.x + (cell_w + gap) * i as f32;
444                Rect::from_min_size(Pos2::new(x, rect.min.y), Vec2::new(cell_w, pill_h))
445            } else {
446                let y = rect.min.y + (pill_h + gap) * i as f32;
447                Rect::from_min_size(Pos2::new(rect.min.x, y), Vec2::new(cell_w, pill_h))
448            };
449
450            if i + 1 < n {
451                let (chev_center, direction) = if horizontal {
452                    (
453                        Pos2::new(cell_rect.max.x + gap * 0.5, cell_rect.center().y),
454                        ChevronDir::Right,
455                    )
456                } else {
457                    (
458                        Pos2::new(cell_rect.center().x, cell_rect.max.y + gap * 0.5),
459                        ChevronDir::Down,
460                    )
461                };
462                paint_chevron(painter, chev_center, direction, chevron_color);
463            }
464
465            let state = s.step_state(i);
466            let (fill, text_color) = match state {
467                StepState::Done => (p.success, Color32::WHITE),
468                StepState::Active => (p.sky, Color32::WHITE),
469                StepState::Error => (p.danger, Color32::WHITE),
470                StepState::Pending => (pending_fill, p.text_muted),
471            };
472            painter.rect(cell_rect, radius, fill, Stroke::NONE, StrokeKind::Inside);
473
474            let label = s.labels.get(i).map(String::as_str).unwrap_or("");
475            let galley = if label.is_empty() {
476                None
477            } else {
478                Some(crate::theme::placeholder_galley(
479                    ui,
480                    label,
481                    t.body,
482                    true,
483                    f32::INFINITY,
484                ))
485            };
486            let galley_w = galley.as_ref().map_or(0.0, |g| g.size().x);
487            let galley_h = galley.as_ref().map_or(0.0, |g| g.size().y);
488            let check_scale = pill_h * 0.2;
489
490            if horizontal {
491                // Centered group: optional check + label, clipped to the cell.
492                let has_check = matches!(state, StepState::Done);
493                let check_block = if has_check {
494                    check_scale * 2.0 + icon_gap
495                } else {
496                    0.0
497                };
498                let group_w = check_block + galley_w;
499                let start_x = cell_rect.center().x - group_w * 0.5;
500                let clip = painter.clip_rect().intersect(cell_rect.shrink(2.0));
501                let clipped = painter.with_clip_rect(clip);
502
503                let mut cursor_x = start_x;
504                if has_check {
505                    let check_center = Pos2::new(start_x + check_scale, cell_rect.center().y);
506                    paint_check(&clipped, check_center, check_scale, text_color);
507                    cursor_x = check_center.x + check_scale + icon_gap;
508                }
509                if let Some(g) = galley {
510                    let pos = Pos2::new(cursor_x, cell_rect.center().y - galley_h * 0.5);
511                    clipped.galley(pos, g, text_color);
512                }
513            } else {
514                // Left-aligned: optional check, then label.
515                let pad_x = 12.0;
516                let mut text_x = cell_rect.min.x + pad_x;
517                if matches!(state, StepState::Done) {
518                    let check_center = Pos2::new(text_x + check_scale, cell_rect.center().y);
519                    paint_check(painter, check_center, check_scale, text_color);
520                    text_x = check_center.x + check_scale + icon_gap;
521                }
522                if let Some(g) = galley {
523                    let pos = Pos2::new(text_x, cell_rect.center().y - galley_h * 0.5);
524                    painter.galley(pos, g, text_color);
525                }
526            }
527        }
528    }
529
530    response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
531    response
532}
533
534fn paint_check(painter: &Painter, center: Pos2, scale: f32, color: Color32) {
535    let stroke = Stroke::new((scale * 0.45).max(1.5), color);
536    let a = Pos2::new(center.x - scale, center.y);
537    let b = Pos2::new(center.x - scale * 0.375, center.y + scale * 0.625);
538    let c = Pos2::new(center.x + scale, center.y - scale * 0.75);
539    painter.line_segment([a, b], stroke);
540    painter.line_segment([b, c], stroke);
541}
542
543#[derive(Clone, Copy)]
544enum ChevronDir {
545    Right,
546    Down,
547}
548
549fn paint_chevron(painter: &Painter, center: Pos2, dir: ChevronDir, color: Color32) {
550    let stroke = Stroke::new(1.6, color);
551    // Apex angle = 2·atan(w / 2d). Pick d=3, w=2d·tan(60°) ≈ 10.4 for 120°.
552    let d = 3.0;
553    let w = 10.4;
554    let (a, apex, b) = match dir {
555        ChevronDir::Right => (
556            Pos2::new(center.x - d, center.y - w),
557            Pos2::new(center.x + d, center.y),
558            Pos2::new(center.x - d, center.y + w),
559        ),
560        ChevronDir::Down => (
561            Pos2::new(center.x - w, center.y - d),
562            Pos2::new(center.x, center.y + d),
563            Pos2::new(center.x + w, center.y - d),
564        ),
565    };
566    painter.line_segment([a, apex], stroke);
567    painter.line_segment([apex, b], stroke);
568}