Skip to main content

qframe/widgets/
steps.rs

1//! Progress through a sequence of steps.
2
3use super::IndexMessage;
4use super::press::{self, Press};
5use crate::event::Event;
6use crate::geometry::{Rect, Size, clamp_u16};
7use crate::icons::Icons;
8use crate::keymap::Key;
9use crate::style::CellStyle;
10use crate::text;
11use crate::theme::State;
12use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
13
14/// Cells between steps laid out in a row.
15const ROW_GAP: u16 = 3;
16
17/// Cells between a step's marker and its label.
18const MARKER_GAP: u16 = 2;
19
20/// Where a sequence of steps stands: finished steps carry a check in the success colour, the
21/// current step is bold with an accent marker, and upcoming steps are faint. Steps are set apart
22/// by space and tone; nothing connects them.
23///
24/// Steps sit in a row by default. When the row does not fit, only the markers stay, followed by
25/// the current step's label. [`Steps::vertical`] puts one step on each line.
26///
27/// With [`Steps::on_select`] finished steps can be chosen to go back to them: by click, or by
28/// focusing the steps, moving with the arrow keys and pressing Enter or Space. The application
29/// owns the current step.
30///
31/// Style keys: `step` (`bg`, `pillar`) with `hover` and `focus` for choosable steps; `step-marker` and
32/// `step-label` (`fg`, `bold`) with `checked` for finished steps and `active` for the current
33/// one, and variants `running` and `failed` for the current step. Icons: `check`, `dot`,
34/// `dot-outline`, `error`.
35pub struct Steps<Msg> {
36    labels: Vec<String>,
37    current: usize,
38    vertical: bool,
39    running: bool,
40    failed: bool,
41    on_select: Option<IndexMessage<Msg>>,
42}
43
44/// Which finished step the keyboard is on.
45#[derive(Debug, Default)]
46struct StepsMemory {
47    cursor: Option<usize>,
48}
49
50/// Where one step is drawn.
51struct Slot {
52    rect: Rect,
53    index: usize,
54    /// Whether the marker is drawn.
55    marker: bool,
56    /// Whether the label is drawn, after the marker when there is one.
57    label: bool,
58}
59
60impl<Msg: 'static> Steps<Msg> {
61    /// Steps named `labels`, the first one current.
62    #[must_use]
63    pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
64        Self {
65            labels: labels.into_iter().map(Into::into).collect(),
66            current: 0,
67            vertical: false,
68            running: false,
69            failed: false,
70            on_select: None,
71        }
72    }
73
74    /// The current step. Steps before it are finished; a value past the last step shows every
75    /// step finished.
76    #[must_use]
77    pub fn current(mut self, index: usize) -> Self {
78        self.current = index;
79        self
80    }
81
82    /// One step per line instead of a row.
83    #[must_use]
84    pub fn vertical(mut self, vertical: bool) -> Self {
85        self.vertical = vertical;
86        self
87    }
88
89    /// The current step is being worked on: its marker breathes.
90    #[must_use]
91    pub fn running(mut self, running: bool) -> Self {
92        self.running = running;
93        self
94    }
95
96    /// The current step failed: its marker becomes the error icon in the danger colour.
97    #[must_use]
98    pub fn failed(mut self, failed: bool) -> Self {
99        self.failed = failed;
100        self
101    }
102
103    /// Makes finished steps choosable; the message carries the chosen step.
104    #[must_use]
105    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
106        self.on_select = Some(Box::new(message));
107        self
108    }
109
110    /// How many steps are finished and therefore choosable.
111    fn finished(&self) -> usize {
112        self.current.min(self.labels.len())
113    }
114
115    fn padding(&self) -> u16 {
116        u16::from(self.on_select.is_some())
117    }
118
119    /// Cells every marker takes: the widest of the marker icons, so steps line up in any state.
120    fn marker_width(icons: &Icons) -> u16 {
121        ["check", "dot", "dot-outline", "error"]
122            .into_iter()
123            .map(|key| text::width(&icons.glyph(key)))
124            .max()
125            .unwrap_or(1)
126    }
127
128    fn item_width(&self, index: usize, marker: u16) -> u16 {
129        (self.padding() * 2 + marker + MARKER_GAP).saturating_add(text::width(&self.labels[index]))
130    }
131
132    /// Cells of every step in a row, with the gaps between them.
133    fn row_width(&self, marker: u16) -> u16 {
134        let count = u16::try_from(self.labels.len()).unwrap_or(u16::MAX);
135        let gaps = ROW_GAP.saturating_mul(count.saturating_sub(1));
136        (0..self.labels.len()).fold(gaps, |sum, index| sum.saturating_add(self.item_width(index, marker)))
137    }
138
139    /// Every step's place in `area`.
140    fn slots(&self, area: Rect, marker: u16) -> Vec<Slot> {
141        let pad = self.padding();
142        let count = self.labels.len();
143        if self.vertical {
144            return (0..count)
145                .map(|index| Slot {
146                    rect: Rect::new(area.x, area.y + i32::try_from(index).unwrap_or(i32::MAX), area.width, 1),
147                    index,
148                    marker: true,
149                    label: true,
150                })
151                .collect();
152        }
153        let mut x = area.x;
154        if self.row_width(marker) <= area.width {
155            return (0..count)
156                .map(|index| {
157                    let width = self.item_width(index, marker);
158                    let slot = Slot { rect: Rect::new(x, area.y, width, 1), index, marker: true, label: true };
159                    x += i32::from(width) + i32::from(ROW_GAP);
160                    slot
161                })
162                .collect();
163        }
164        // Compact: every marker, then the label of the current step only.
165        let mut slots: Vec<Slot> = (0..count)
166            .map(|index| {
167                let width = pad * 2 + marker;
168                let slot = Slot { rect: Rect::new(x, area.y, width, 1), index, marker: true, label: false };
169                x += i32::from(width + 1);
170                slot
171            })
172            .collect();
173        if self.current < count {
174            let label_x = x + 1;
175            let rect = Rect::new(label_x, area.y, clamp_u16(area.right() - label_x), 1);
176            slots.push(Slot { rect, index: self.current, marker: false, label: true });
177        }
178        slots
179    }
180
181    fn states(&self, index: usize) -> Vec<State> {
182        match index.cmp(&self.current) {
183            std::cmp::Ordering::Less => vec![State::Checked],
184            std::cmp::Ordering::Equal => vec![State::Active],
185            std::cmp::Ordering::Greater => Vec::new(),
186        }
187    }
188
189    fn marker_key(&self, index: usize) -> &'static str {
190        match index.cmp(&self.current) {
191            std::cmp::Ordering::Less => "check",
192            std::cmp::Ordering::Equal if self.failed => "error",
193            std::cmp::Ordering::Equal => "dot",
194            std::cmp::Ordering::Greater => "dot-outline",
195        }
196    }
197
198    fn variant(&self, index: usize) -> Option<&'static str> {
199        if index != self.current {
200            return None;
201        }
202        if self.failed {
203            Some("failed")
204        } else if self.running {
205            Some("running")
206        } else {
207            None
208        }
209    }
210
211    /// The finished step the keyboard is on: the `remembered` one, else the last finished step.
212    fn cursor(&self, remembered: Option<usize>) -> usize {
213        let last = self.finished().saturating_sub(1);
214        remembered.unwrap_or(last).min(last)
215    }
216
217    fn choose(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
218        if let Some(message) = &self.on_select {
219            cx.memory::<StepsMemory>().cursor = Some(index);
220            cx.flash();
221            cx.emit(message(index));
222        }
223    }
224}
225
226impl<Msg: 'static> Widget<Msg> for Steps<Msg> {
227    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
228        let marker = Self::marker_width(cx.env().icons());
229        let count = clamp_u16(i32::try_from(self.labels.len()).unwrap_or(i32::MAX));
230        if self.vertical {
231            let widest = (0..self.labels.len()).map(|index| self.item_width(index, marker)).max().unwrap_or(0);
232            return Size::new(widest, count).min(available);
233        }
234        Size::new(self.row_width(marker), u16::from(count > 0)).min(available)
235    }
236
237    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
238        let marker = Self::marker_width(cx.env().icons());
239        let pad = self.padding();
240        let choosable = self.on_select.is_some() && self.finished() > 0;
241        let focused = choosable && cx.is_focused();
242        let pointer = if choosable { cx.pointer() } else { None };
243        let cursor = self.cursor(cx.memory::<StepsMemory>().cursor);
244        for slot in self.slots(area, marker) {
245            let rect = slot.rect.intersect(area);
246            if rect.is_empty() {
247                continue;
248            }
249            let mut states = self.states(slot.index);
250            let finished = slot.index < self.finished();
251            if finished && choosable {
252                if pointer.is_some_and(|(x, y)| rect.contains(x, y)) {
253                    states.push(State::Hover);
254                }
255                if focused && slot.index == cursor {
256                    states.push(State::Focus);
257                }
258            }
259            let variant = self.variant(slot.index);
260            let surface = cx.style("step", variant, &states);
261            if slot.marker {
262                if let Some(bg) = surface.text().bg {
263                    cx.clear(rect, bg);
264                }
265                // A choosable step is pressable: it rises with the pillar in its first cell.
266                if let Some(pillar) = surface.color("pillar") {
267                    cx.pillar(rect.x, rect.y, pillar);
268                }
269            }
270            let mut x = rect.x;
271            if slot.marker {
272                let glyph = cx.env().icons().glyph(self.marker_key(slot.index)).into_owned();
273                let marker_style = cx.style("step-marker", variant, &states).text();
274                x += i32::from(pad);
275                cx.text(x, rect.y, &glyph, CellStyle { bg: None, ..marker_style }, marker);
276                x += i32::from(marker + MARKER_GAP);
277            }
278            if slot.label {
279                let label_style = cx.style("step-label", variant, &states).text();
280                let label_x = x;
281                let budget = clamp_u16(rect.right() - label_x - i32::from(pad));
282                let shown = text::truncate(&self.labels[slot.index], budget).into_owned();
283                cx.text(label_x, rect.y, &shown, CellStyle { bg: None, ..label_style }, budget);
284            }
285        }
286        if choosable {
287            cx.register_hit(area);
288        }
289    }
290
291    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
292        let finished = self.finished();
293        if self.on_select.is_none() || finished == 0 {
294            return false;
295        }
296        let cursor = self.cursor(cx.memory::<StepsMemory>().cursor);
297        if let Event::Key(key) = event {
298            let (back, forward) = if self.vertical { (Key::Up, Key::Down) } else { (Key::Left, Key::Right) };
299            let target = if key.is_plain(back) {
300                Some(cursor.saturating_sub(1))
301            } else if key.is_plain(forward) {
302                Some((cursor + 1).min(finished - 1))
303            } else if key.is_plain(Key::Home) {
304                Some(0)
305            } else if key.is_plain(Key::End) {
306                Some(finished - 1)
307            } else {
308                None
309            };
310            if let Some(target) = target {
311                cx.memory::<StepsMemory>().cursor = Some(target);
312                return true;
313            }
314        }
315        match press::read(cx, event) {
316            Press::Ignored => false,
317            Press::Used => true,
318            Press::Key => {
319                self.choose(cx, cursor);
320                true
321            }
322            Press::Click(x, y) => {
323                let area = cx.area();
324                let marker = Self::marker_width(cx.env().icons());
325                let hit = self.slots(area, marker).into_iter().find(|slot| slot.marker && slot.rect.contains(x, y));
326                if let Some(slot) = hit.filter(|slot| slot.index < finished) {
327                    self.choose(cx, slot.index);
328                }
329                true
330            }
331        }
332    }
333
334    fn focusable(&self) -> bool {
335        self.on_select.is_some() && self.finished() > 0
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::icons::GlyphMode;
343    use crate::runtime::{App, Command, Harness};
344    use crate::widget::View;
345
346    const LABELS: [&str; 4] = ["Project", "Engine", "Theme", "Summary"];
347
348    struct Demo {
349        current: usize,
350        vertical: bool,
351        choosable: bool,
352        failed: bool,
353    }
354
355    impl App for Demo {
356        type Msg = usize;
357        fn update(&mut self, index: usize) -> Command<usize> {
358            self.current = index;
359            Command::none()
360        }
361        fn view(&self, ui: &mut View<'_, usize>) {
362            let mut steps = Steps::new(LABELS).current(self.current).vertical(self.vertical).failed(self.failed);
363            if self.choosable {
364                steps = steps.on_select(|index| index);
365            }
366            ui.add(steps).id("steps");
367        }
368    }
369
370    fn demo(current: usize) -> Demo {
371        Demo { current, vertical: false, choosable: false, failed: false }
372    }
373
374    #[test]
375    fn finished_current_and_upcoming_steps_read_by_marker_and_tone() {
376        let h = Harness::new(demo(2), 60, 1);
377        assert_eq!(h.screen(), "✓  Project   ✓  Engine   ●  Theme   ○  Summary\n");
378        let theme = h.env().theme();
379        assert_eq!(h.fg(0, 0), theme.color("success"));
380        assert_eq!(h.fg(25, 0), theme.color("accent"));
381        assert!(h.is_bold(28, 0));
382        assert_eq!(h.fg(36, 0), theme.color("muted"));
383    }
384
385    #[test]
386    fn narrow_rows_keep_markers_and_the_current_label() {
387        let h = Harness::new(demo(2), 24, 1);
388        assert_eq!(h.screen(), "✓ ✓ ● ○  Theme\n");
389    }
390
391    #[test]
392    fn vertical_and_failed() {
393        let mut app = demo(1);
394        app.vertical = true;
395        app.failed = true;
396        let h = Harness::new(app, 20, 4);
397        assert_eq!(h.screen(), "✓  Project\n✕  Engine\n○  Theme\n○  Summary\n");
398        assert_eq!(h.fg(0, 1), h.env().theme().color("danger"));
399    }
400
401    #[test]
402    fn finished_steps_are_chosen_by_click_and_keyboard_only_when_enabled() {
403        let mut h = Harness::new(demo(2), 60, 1);
404        h.click_text("Project");
405        assert_eq!(h.app().current, 2, "plain steps ignore clicks");
406        let mut app = demo(3);
407        app.choosable = true;
408        let mut h = Harness::new(app, 60, 1);
409        h.click_text("Summary");
410        assert_eq!(h.app().current, 3, "upcoming and current steps are not choosable");
411        h.press("tab").press("left").press("enter");
412        assert_eq!(h.app().current, 1);
413        h.click_text("Project");
414        assert_eq!(h.app().current, 0);
415    }
416
417    #[test]
418    fn choosable_steps_rise_with_the_pillar_under_the_pointer_and_the_keyboard() {
419        let mut app = demo(2);
420        app.choosable = true;
421        let mut h = Harness::new(app, 60, 1);
422        assert_eq!(h.screen(), " ✓  Project     ✓  Engine     ●  Theme     ○  Summary\n");
423        h.hover(5, 0);
424        assert_eq!(h.screen(), "▌✓  Project     ✓  Engine     ●  Theme     ○  Summary\n");
425        assert_eq!(h.bg(5, 0), h.env().theme().color("raised"));
426        h.hover(33, 0);
427        assert_eq!(
428            h.screen(),
429            " ✓  Project     ✓  Engine     ●  Theme     ○  Summary\n",
430            "the current step is not pressable"
431        );
432        h.press("tab");
433        assert_eq!(
434            h.screen(),
435            " ✓  Project    ▌✓  Engine     ●  Theme     ○  Summary\n",
436            "the keyboard starts on the last finished step"
437        );
438        assert_eq!(h.bg(20, 0), h.env().theme().color("active"));
439        let plain = Harness::new(demo(2), 60, 1);
440        assert!(!plain.screen().contains('▌'), "steps that cannot be chosen never rise");
441    }
442
443    #[test]
444    fn a_running_step_breathes_unless_motion_is_reduced() {
445        struct Running;
446        impl App for Running {
447            type Msg = ();
448            fn update(&mut self, _: ()) -> Command<()> {
449                Command::none()
450            }
451            fn view(&self, ui: &mut View<'_, ()>) {
452                ui.add(Steps::new(LABELS).current(1).running(true));
453            }
454        }
455        let mut h = Harness::new(Running, 60, 1);
456        let start = h.fg(13, 0);
457        let half = h.env().theme().motion().pulse_period / 2;
458        h.advance(half);
459        assert_ne!(h.fg(13, 0), start);
460        h.set_reduced_motion(true);
461        assert_eq!(h.fg(13, 0), start);
462    }
463
464    #[test]
465    fn ascii_markers_are_not_bracketed() {
466        let mut h = Harness::new(demo(1), 60, 1);
467        h.set_glyph_mode(GlyphMode::Ascii);
468        assert_eq!(h.screen(), "v  Project   *  Engine   o  Theme   o  Summary\n");
469    }
470}