1use ratatui::prelude::*;
8
9use crate::icons;
10use crate::palette::color;
11
12pub trait StatusItem {
18 fn label(&self) -> &str;
20
21 fn status(&self) -> ItemStatus;
23
24 fn tag(&self) -> Option<(&str, Style)> {
26 None
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ItemStatus {
33 Pending,
34 Active,
35 Complete,
36 Failed,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Orientation {
42 Vertical,
43 Horizontal,
44}
45
46pub struct StatusList<'a, T: StatusItem> {
53 pub items: &'a [T],
55 pub current: usize,
58 pub orientation: Orientation,
60}
61
62impl<'a, T: StatusItem> StatusList<'a, T> {
63 pub fn new(items: &'a [T], current: usize) -> Self {
65 Self {
66 items,
67 current,
68 orientation: Orientation::Vertical,
69 }
70 }
71
72 pub fn horizontal(items: &'a [T], current: usize) -> Self {
74 Self {
75 items,
76 current,
77 orientation: Orientation::Horizontal,
78 }
79 }
80}
81
82impl<T: StatusItem> Widget for StatusList<'_, T> {
83 fn render(self, area: Rect, buf: &mut Buffer) {
84 if area.height == 0 || area.width == 0 || self.items.is_empty() {
85 return;
86 }
87
88 match self.orientation {
89 Orientation::Vertical => render_vertical(self.items, self.current, area, buf),
90 Orientation::Horizontal => render_horizontal(self.items, self.current, area, buf),
91 }
92 }
93}
94
95#[allow(clippy::cast_possible_truncation)]
100fn render_vertical<T: StatusItem>(items: &[T], current: usize, area: Rect, buf: &mut Buffer) {
101 let visible_count = area.height as usize;
102 let total = items.len();
103
104 let (start, end) = if total <= visible_count {
106 (0, total)
107 } else {
108 let half = visible_count / 2;
109 let start = if current < half {
110 0
111 } else if current + half >= total {
112 total.saturating_sub(visible_count)
113 } else {
114 current.saturating_sub(half)
115 };
116 let end = (start + visible_count).min(total);
117 (start, end)
118 };
119
120 for (display_idx, idx) in (start..end).enumerate() {
121 if display_idx >= visible_count {
122 break;
123 }
124
125 let item = &items[idx];
126 let y = area.y + display_idx as u16;
127 let status = item.status();
128
129 let (icon_ch, icon_style) = status_to_icon(status);
131 buf.set_string(area.x, y, icon_ch.to_string(), icon_style);
132
133 let text_style = status_text_style(status);
135 let available_width = area.width.saturating_sub(2) as usize; let label = item.label();
137 let display_label = if label.len() > available_width {
138 if available_width >= 4 {
139 format!("{}...", &label[..available_width.saturating_sub(3)])
140 } else {
141 label[..available_width].to_string()
142 }
143 } else {
144 label.to_string()
145 };
146
147 buf.set_string(area.x + 2, y, &display_label, text_style);
148
149 if let Some((tag_text, tag_style)) = item.tag() {
151 let label_end = area.x + 2 + display_label.len() as u16;
152 let tag_x = label_end.min(area.x + area.width.saturating_sub(tag_text.len() as u16));
153
154 if tag_x + tag_text.len() as u16 <= area.x + area.width {
155 buf.set_string(tag_x, y, tag_text, tag_style);
156 }
157 }
158 }
159
160 if total > visible_count {
162 let indicator = format!("({}/{})", end.min(total), total);
163 let x = area.x + area.width.saturating_sub(indicator.len() as u16);
164 if x >= area.x {
165 buf.set_string(x, area.y, &indicator, Style::default().fg(color::INACTIVE));
166 }
167 }
168}
169
170#[allow(clippy::cast_possible_truncation)]
175fn render_horizontal<T: StatusItem>(items: &[T], current: usize, area: Rect, buf: &mut Buffer) {
176 let separator = " -> ";
177 let mut x = area.x;
178
179 for (i, item) in items.iter().enumerate() {
180 if x >= area.x + area.width {
181 break;
182 }
183
184 let status = item.status();
185
186 let (icon_ch, icon_style) = horizontal_icon(status, i, current);
188 let icon_str = format!("{icon_ch} ");
189 let icon_len = icon_str.len() as u16;
190
191 if x + icon_len > area.x + area.width {
192 break;
193 }
194 buf.set_string(x, area.y, &icon_str, icon_style);
195 x += icon_len;
196
197 let label = item.label();
199 let label_style = horizontal_label_style(status, i, current);
200 let remaining = (area.x + area.width).saturating_sub(x) as usize;
201 let display_label = if label.len() > remaining {
202 if remaining >= 4 {
203 format!("{}...", &label[..remaining.saturating_sub(3)])
204 } else {
205 label[..remaining].to_string()
206 }
207 } else {
208 label.to_string()
209 };
210
211 buf.set_string(x, area.y, &display_label, label_style);
212 x += display_label.len() as u16;
213
214 if i < items.len() - 1 {
216 let sep_len = separator.len() as u16;
217 if x + sep_len <= area.x + area.width {
218 buf.set_string(x, area.y, separator, Style::default().fg(color::INACTIVE));
219 x += sep_len;
220 }
221 }
222 }
223}
224
225fn status_to_icon(status: ItemStatus) -> (char, Style) {
231 match status {
232 ItemStatus::Pending => icons::status_icon(false, false, false),
233 ItemStatus::Active => icons::status_icon(false, true, false),
234 ItemStatus::Complete => icons::status_icon(true, false, false),
235 ItemStatus::Failed => icons::status_icon(false, false, true),
236 }
237}
238
239fn status_text_style(status: ItemStatus) -> Style {
241 match status {
242 ItemStatus::Pending => Style::default().fg(color::INACTIVE),
243 ItemStatus::Active => Style::default()
244 .fg(color::WARNING)
245 .add_modifier(Modifier::BOLD),
246 ItemStatus::Complete => Style::default().fg(color::TEXT),
247 ItemStatus::Failed => Style::default().fg(color::ERROR),
248 }
249}
250
251fn horizontal_icon(status: ItemStatus, idx: usize, current: usize) -> (char, Style) {
254 if idx < current || status == ItemStatus::Complete {
255 (icons::COMPLETE, Style::default().fg(color::SUCCESS))
256 } else if idx == current || status == ItemStatus::Active {
257 (
258 icons::RUNNING,
259 Style::default()
260 .fg(color::ACCENT)
261 .add_modifier(Modifier::BOLD),
262 )
263 } else {
264 (icons::PENDING, Style::default().fg(color::INACTIVE))
265 }
266}
267
268fn horizontal_label_style(status: ItemStatus, idx: usize, current: usize) -> Style {
270 if idx < current || status == ItemStatus::Complete {
271 Style::default().fg(color::SUCCESS)
272 } else if idx == current || status == ItemStatus::Active {
273 Style::default()
274 .fg(color::ACCENT)
275 .add_modifier(Modifier::BOLD)
276 } else {
277 Style::default().fg(color::INACTIVE)
278 }
279}
280
281#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[derive(Debug)]
291 struct Step {
292 label: String,
293 status: ItemStatus,
294 tag: Option<(String, Style)>,
295 }
296
297 impl Step {
298 fn new(label: &str, status: ItemStatus) -> Self {
299 Self {
300 label: label.to_string(),
301 status,
302 tag: None,
303 }
304 }
305
306 fn with_tag(mut self, tag: &str, style: Style) -> Self {
307 self.tag = Some((tag.to_string(), style));
308 self
309 }
310 }
311
312 impl StatusItem for Step {
313 fn label(&self) -> &str {
314 &self.label
315 }
316
317 fn status(&self) -> ItemStatus {
318 self.status
319 }
320
321 fn tag(&self) -> Option<(&str, Style)> {
322 self.tag.as_ref().map(|(s, st)| (s.as_str(), *st))
323 }
324 }
325
326 fn create_buffer(width: u16, height: u16) -> Buffer {
327 Buffer::empty(Rect::new(0, 0, width, height))
328 }
329
330 fn buffer_text(buf: &Buffer) -> String {
331 buf.content()
332 .iter()
333 .map(ratatui::buffer::Cell::symbol)
334 .collect()
335 }
336
337 #[test]
340 fn empty_items_does_not_panic() {
341 let mut buf = create_buffer(40, 5);
342 let area = Rect::new(0, 0, 40, 5);
343
344 let items: Vec<Step> = vec![];
345 let list = StatusList::new(&items, 0);
346 list.render(area, &mut buf);
347 }
348
349 #[test]
350 fn empty_horizontal_does_not_panic() {
351 let mut buf = create_buffer(80, 1);
352 let area = Rect::new(0, 0, 80, 1);
353
354 let items: Vec<Step> = vec![];
355 let list = StatusList::horizontal(&items, 0);
356 list.render(area, &mut buf);
357 }
358
359 #[test]
362 fn vertical_renders_labels() {
363 let mut buf = create_buffer(60, 5);
364 let area = Rect::new(0, 0, 60, 5);
365
366 let items = vec![
367 Step::new("WORKDIR /app", ItemStatus::Complete),
368 Step::new("RUN npm ci", ItemStatus::Active),
369 Step::new("COPY . .", ItemStatus::Pending),
370 ];
371
372 let list = StatusList::new(&items, 1);
373 list.render(area, &mut buf);
374
375 let text = buffer_text(&buf);
376 assert!(text.contains("WORKDIR"));
377 assert!(text.contains("RUN npm ci"));
378 assert!(text.contains("COPY"));
379 }
380
381 #[test]
382 fn vertical_renders_icons() {
383 let mut buf = create_buffer(60, 5);
384 let area = Rect::new(0, 0, 60, 5);
385
386 let items = vec![
387 Step::new("done", ItemStatus::Complete),
388 Step::new("active", ItemStatus::Active),
389 Step::new("waiting", ItemStatus::Pending),
390 Step::new("failed", ItemStatus::Failed),
391 ];
392
393 let list = StatusList::new(&items, 1);
394 list.render(area, &mut buf);
395
396 let text = buffer_text(&buf);
397 assert!(text.contains(icons::COMPLETE));
399 assert!(text.contains(icons::RUNNING));
400 assert!(text.contains(icons::PENDING));
401 assert!(text.contains(icons::FAILED));
402 }
403
404 #[test]
405 fn vertical_tag_rendered() {
406 let mut buf = create_buffer(60, 3);
407 let area = Rect::new(0, 0, 60, 3);
408
409 let items = vec![Step::new("COPY package.json ./", ItemStatus::Complete)
410 .with_tag(" [cached]", Style::default().fg(Color::Cyan))];
411
412 let list = StatusList::new(&items, 0);
413 list.render(area, &mut buf);
414
415 let text = buffer_text(&buf);
416 assert!(text.contains("[cached]"));
417 }
418
419 #[test]
420 fn vertical_scroll_indicator() {
421 let mut buf = create_buffer(40, 3);
422 let area = Rect::new(0, 0, 40, 3);
423
424 let items: Vec<Step> = (0..10)
425 .map(|i| Step::new(&format!("Step {i}"), ItemStatus::Pending))
426 .collect();
427
428 let list = StatusList::new(&items, 5);
429 list.render(area, &mut buf);
430
431 let text = buffer_text(&buf);
432 assert!(text.contains('/'));
434 assert!(text.contains('('));
435 }
436
437 #[test]
438 fn vertical_no_scroll_when_all_visible() {
439 let mut buf = create_buffer(60, 10);
440 let area = Rect::new(0, 0, 60, 10);
441
442 let items = vec![
443 Step::new("one", ItemStatus::Complete),
444 Step::new("two", ItemStatus::Active),
445 ];
446
447 let list = StatusList::new(&items, 1);
448 list.render(area, &mut buf);
449
450 let text = buffer_text(&buf);
451 assert!(!text.contains("(/"));
453 }
454
455 #[test]
456 fn vertical_truncates_long_labels() {
457 let mut buf = create_buffer(15, 2);
458 let area = Rect::new(0, 0, 15, 2);
459
460 let items = vec![Step::new(
461 "A very long instruction label that exceeds width",
462 ItemStatus::Active,
463 )];
464
465 let list = StatusList::new(&items, 0);
466 list.render(area, &mut buf);
467
468 let text = buffer_text(&buf);
469 assert!(text.contains("..."));
470 }
471
472 #[test]
473 fn vertical_centering_at_start() {
474 let mut buf = create_buffer(40, 3);
475 let area = Rect::new(0, 0, 40, 3);
476
477 let items: Vec<Step> = (0..10)
478 .map(|i| Step::new(&format!("Step {i}"), ItemStatus::Pending))
479 .collect();
480
481 let list = StatusList::new(&items, 0);
483 list.render(area, &mut buf);
484
485 let text = buffer_text(&buf);
486 assert!(text.contains("Step 0"));
487 }
488
489 #[test]
490 fn vertical_centering_at_end() {
491 let mut buf = create_buffer(40, 3);
492 let area = Rect::new(0, 0, 40, 3);
493
494 let items: Vec<Step> = (0..10)
495 .map(|i| Step::new(&format!("Step {i}"), ItemStatus::Pending))
496 .collect();
497
498 let list = StatusList::new(&items, 9);
500 list.render(area, &mut buf);
501
502 let text = buffer_text(&buf);
503 assert!(text.contains("Step 9"));
504 }
505
506 #[test]
509 fn horizontal_renders_items_with_separators() {
510 let mut buf = create_buffer(80, 1);
511 let area = Rect::new(0, 0, 80, 1);
512
513 let items = vec![
514 Step::new("Source", ItemStatus::Complete),
515 Step::new("Configure", ItemStatus::Active),
516 Step::new("Build", ItemStatus::Pending),
517 ];
518
519 let list = StatusList::horizontal(&items, 1);
520 list.render(area, &mut buf);
521
522 let text = buffer_text(&buf);
523 assert!(text.contains("Source"));
524 assert!(text.contains("Configure"));
525 assert!(text.contains("Build"));
526 assert!(text.contains("->"));
527 }
528
529 #[test]
530 fn horizontal_completed_items_use_checkmark() {
531 let mut buf = create_buffer(80, 1);
532 let area = Rect::new(0, 0, 80, 1);
533
534 let items = vec![
535 Step::new("Done", ItemStatus::Complete),
536 Step::new("Active", ItemStatus::Active),
537 Step::new("Waiting", ItemStatus::Pending),
538 ];
539
540 let list = StatusList::horizontal(&items, 1);
541 list.render(area, &mut buf);
542
543 let text = buffer_text(&buf);
544 assert!(text.contains(icons::COMPLETE));
545 assert!(text.contains(icons::RUNNING));
546 assert!(text.contains(icons::PENDING));
547 }
548
549 #[test]
550 fn horizontal_narrow_area_truncates() {
551 let mut buf = create_buffer(20, 1);
552 let area = Rect::new(0, 0, 20, 1);
553
554 let items = vec![
555 Step::new("VeryLongStepName", ItemStatus::Complete),
556 Step::new("AnotherLongOne", ItemStatus::Active),
557 ];
558
559 let list = StatusList::horizontal(&items, 1);
560 list.render(area, &mut buf);
561
562 }
564
565 #[test]
568 fn new_creates_vertical() {
569 let items = vec![Step::new("a", ItemStatus::Pending)];
570 let list = StatusList::new(&items, 0);
571 assert_eq!(list.orientation, Orientation::Vertical);
572 }
573
574 #[test]
575 fn horizontal_constructor() {
576 let items = vec![Step::new("a", ItemStatus::Pending)];
577 let list = StatusList::horizontal(&items, 0);
578 assert_eq!(list.orientation, Orientation::Horizontal);
579 }
580
581 #[test]
584 fn zero_height_does_not_panic() {
585 let mut buf = create_buffer(40, 0);
586 let area = Rect::new(0, 0, 40, 0);
587
588 let items = vec![Step::new("x", ItemStatus::Active)];
589 let list = StatusList::new(&items, 0);
590 list.render(area, &mut buf);
591 }
592
593 #[test]
594 fn zero_width_does_not_panic() {
595 let mut buf = create_buffer(0, 5);
596 let area = Rect::new(0, 0, 0, 5);
597
598 let items = vec![Step::new("x", ItemStatus::Active)];
599 let list = StatusList::new(&items, 0);
600 list.render(area, &mut buf);
601 }
602
603 #[test]
604 fn current_out_of_bounds_does_not_panic() {
605 let mut buf = create_buffer(40, 5);
606 let area = Rect::new(0, 0, 40, 5);
607
608 let items = vec![Step::new("only", ItemStatus::Active)];
609 let list = StatusList::new(&items, 99);
611 list.render(area, &mut buf);
612
613 let text = buffer_text(&buf);
614 assert!(text.contains("only"));
615 }
616
617 #[test]
618 fn item_status_equality() {
619 assert_eq!(ItemStatus::Pending, ItemStatus::Pending);
620 assert_ne!(ItemStatus::Pending, ItemStatus::Active);
621 assert_ne!(ItemStatus::Complete, ItemStatus::Failed);
622 }
623}