1use 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
14const ROW_GAP: u16 = 3;
16
17const MARKER_GAP: u16 = 2;
19
20pub 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#[derive(Debug, Default)]
46struct StepsMemory {
47 cursor: Option<usize>,
48}
49
50struct Slot {
52 rect: Rect,
53 index: usize,
54 marker: bool,
56 label: bool,
58}
59
60impl<Msg: 'static> Steps<Msg> {
61 #[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 #[must_use]
77 pub fn current(mut self, index: usize) -> Self {
78 self.current = index;
79 self
80 }
81
82 #[must_use]
84 pub fn vertical(mut self, vertical: bool) -> Self {
85 self.vertical = vertical;
86 self
87 }
88
89 #[must_use]
91 pub fn running(mut self, running: bool) -> Self {
92 self.running = running;
93 self
94 }
95
96 #[must_use]
98 pub fn failed(mut self, failed: bool) -> Self {
99 self.failed = failed;
100 self
101 }
102
103 #[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 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 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 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 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 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 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 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}