Skip to main content

ratatui_interact/components/
step_display.rs

1//! Step display widget
2//!
3//! A multi-step progress display with expandable sub-steps and output areas.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{StepDisplay, StepDisplayState, Step, StepStatus};
9//!
10//! // Create steps
11//! let steps = vec![
12//!     Step::new("Build project")
13//!         .with_sub_steps(vec!["Compile", "Link", "Package"]),
14//!     Step::new("Run tests"),
15//!     Step::new("Deploy"),
16//! ];
17//!
18//! // Create state
19//! let mut state = StepDisplayState::new(steps);
20//!
21//! // Update step status
22//! state.start_step(0);
23//! state.complete_step(0);
24//! state.start_step(1);
25//! ```
26
27use ratatui::{
28    buffer::Buffer,
29    layout::Rect,
30    style::{Color, Modifier, Style},
31    text::{Line, Span},
32    widgets::{Paragraph, Widget},
33};
34
35use crate::utils::display::{pad_to_width, truncate_to_width};
36
37/// Status of a step or sub-step
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum StepStatus {
40    /// Not yet started
41    #[default]
42    Pending,
43    /// Currently running
44    Running,
45    /// Successfully completed
46    Completed,
47    /// Failed with error
48    Failed,
49    /// Skipped
50    Skipped,
51}
52
53impl StepStatus {
54    /// Get the icon for this status
55    pub fn icon(&self) -> &'static str {
56        match self {
57            StepStatus::Pending => "[ ]",
58            StepStatus::Running => "[▶]",
59            StepStatus::Completed => "[✓]",
60            StepStatus::Failed => "[✗]",
61            StepStatus::Skipped => "[↷]",
62        }
63    }
64
65    /// Get the color for this status
66    pub fn color(&self) -> Color {
67        match self {
68            StepStatus::Pending => Color::DarkGray,
69            StepStatus::Running => Color::Yellow,
70            StepStatus::Completed => Color::Green,
71            StepStatus::Failed => Color::Red,
72            StepStatus::Skipped => Color::DarkGray,
73        }
74    }
75
76    /// Get the sub-step icon
77    pub fn sub_icon(&self) -> &'static str {
78        match self {
79            StepStatus::Pending => "○",
80            StepStatus::Running => "◐",
81            StepStatus::Completed => "●",
82            StepStatus::Failed => "✗",
83            StepStatus::Skipped => "◌",
84        }
85    }
86}
87
88/// A sub-step within a step
89#[derive(Debug, Clone)]
90pub struct SubStep {
91    /// Name of the sub-step
92    pub name: String,
93    /// Current status
94    pub status: StepStatus,
95}
96
97impl SubStep {
98    /// Create a new sub-step
99    pub fn new(name: impl Into<String>) -> Self {
100        Self {
101            name: name.into(),
102            status: StepStatus::Pending,
103        }
104    }
105}
106
107/// A step in the process
108#[derive(Debug, Clone)]
109pub struct Step {
110    /// Name of the step
111    pub name: String,
112    /// Current status
113    pub status: StepStatus,
114    /// Sub-steps
115    pub sub_steps: Vec<SubStep>,
116    /// Whether the output is expanded
117    pub expanded: bool,
118    /// Output lines
119    pub output: Vec<String>,
120    /// Output scroll position
121    pub scroll: u16,
122}
123
124impl Step {
125    /// Create a new step
126    pub fn new(name: impl Into<String>) -> Self {
127        Self {
128            name: name.into(),
129            status: StepStatus::Pending,
130            sub_steps: Vec::new(),
131            expanded: false,
132            output: Vec::new(),
133            scroll: 0,
134        }
135    }
136
137    /// Add sub-steps
138    pub fn with_sub_steps(mut self, names: Vec<&str>) -> Self {
139        self.sub_steps = names.into_iter().map(SubStep::new).collect();
140        self
141    }
142
143    /// Add a line to output
144    pub fn add_output(&mut self, line: impl Into<String>) {
145        self.output.push(line.into());
146        // Auto-scroll to bottom
147        let visible_lines = 5;
148        if self.output.len() > visible_lines {
149            self.scroll = (self.output.len() - visible_lines) as u16;
150        }
151    }
152
153    /// Clear output
154    pub fn clear_output(&mut self) {
155        self.output.clear();
156        self.scroll = 0;
157    }
158
159    /// Get sub-step progress (completed, total)
160    pub fn sub_step_progress(&self) -> (usize, usize) {
161        let completed = self
162            .sub_steps
163            .iter()
164            .filter(|s| s.status == StepStatus::Completed)
165            .count();
166        (completed, self.sub_steps.len())
167    }
168}
169
170/// State for step display widget
171#[derive(Debug, Clone)]
172pub struct StepDisplayState {
173    /// Steps
174    pub steps: Vec<Step>,
175    /// Currently focused step index
176    pub focused_step: Option<usize>,
177    /// Console scroll position
178    pub scroll: u16,
179}
180
181impl StepDisplayState {
182    /// Create a new step display state
183    pub fn new(steps: Vec<Step>) -> Self {
184        Self {
185            steps,
186            focused_step: None,
187            scroll: 0,
188        }
189    }
190
191    /// Get total progress (0.0 to 1.0)
192    pub fn progress(&self) -> f64 {
193        if self.steps.is_empty() {
194            return 0.0;
195        }
196        let completed = self
197            .steps
198            .iter()
199            .filter(|s| s.status == StepStatus::Completed)
200            .count();
201        completed as f64 / self.steps.len() as f64
202    }
203
204    /// Get current step index (first non-completed)
205    pub fn current_step(&self) -> usize {
206        self.steps
207            .iter()
208            .position(|s| s.status != StepStatus::Completed && s.status != StepStatus::Skipped)
209            .unwrap_or(self.steps.len())
210    }
211
212    /// Start a step
213    pub fn start_step(&mut self, index: usize) {
214        if let Some(step) = self.steps.get_mut(index) {
215            step.status = StepStatus::Running;
216            step.expanded = true;
217        }
218    }
219
220    /// Complete a step
221    pub fn complete_step(&mut self, index: usize) {
222        if let Some(step) = self.steps.get_mut(index) {
223            step.status = StepStatus::Completed;
224        }
225    }
226
227    /// Fail a step
228    pub fn fail_step(&mut self, index: usize) {
229        if let Some(step) = self.steps.get_mut(index) {
230            step.status = StepStatus::Failed;
231        }
232    }
233
234    /// Skip a step
235    pub fn skip_step(&mut self, index: usize) {
236        if let Some(step) = self.steps.get_mut(index) {
237            step.status = StepStatus::Skipped;
238        }
239    }
240
241    /// Start a sub-step
242    pub fn start_sub_step(&mut self, step_index: usize, sub_index: usize) {
243        if let Some(step) = self.steps.get_mut(step_index) {
244            if let Some(sub) = step.sub_steps.get_mut(sub_index) {
245                sub.status = StepStatus::Running;
246            }
247        }
248    }
249
250    /// Complete a sub-step
251    pub fn complete_sub_step(&mut self, step_index: usize, sub_index: usize) {
252        if let Some(step) = self.steps.get_mut(step_index) {
253            if let Some(sub) = step.sub_steps.get_mut(sub_index) {
254                sub.status = StepStatus::Completed;
255            }
256        }
257    }
258
259    /// Add output to a step
260    pub fn add_output(&mut self, step_index: usize, line: impl Into<String>) {
261        if let Some(step) = self.steps.get_mut(step_index) {
262            step.add_output(line);
263        }
264    }
265
266    /// Toggle expansion of a step
267    pub fn toggle_expanded(&mut self, index: usize) {
268        if let Some(step) = self.steps.get_mut(index) {
269            step.expanded = !step.expanded;
270        }
271    }
272
273    /// Scroll output for a step
274    pub fn scroll_output(&mut self, index: usize, delta: i32) {
275        if let Some(step) = self.steps.get_mut(index) {
276            let max_scroll = step.output.len().saturating_sub(5) as i32;
277            let new_scroll = (step.scroll as i32 + delta).clamp(0, max_scroll);
278            step.scroll = new_scroll as u16;
279        }
280    }
281}
282
283/// Style for step display
284#[derive(Debug, Clone)]
285pub struct StepDisplayStyle {
286    /// Output box border color when focused
287    pub focused_border: Color,
288    /// Output box border color when not focused
289    pub unfocused_border: Color,
290    /// Maximum visible output lines
291    pub max_output_lines: usize,
292}
293
294impl Default for StepDisplayStyle {
295    fn default() -> Self {
296        Self {
297            focused_border: Color::Cyan,
298            unfocused_border: Color::DarkGray,
299            max_output_lines: 5,
300        }
301    }
302}
303
304/// Step display widget
305pub struct StepDisplay<'a> {
306    state: &'a StepDisplayState,
307    style: StepDisplayStyle,
308}
309
310impl<'a> StepDisplay<'a> {
311    /// Create a new step display
312    pub fn new(state: &'a StepDisplayState) -> Self {
313        Self {
314            state,
315            style: StepDisplayStyle::default(),
316        }
317    }
318
319    /// Set the style
320    pub fn style(mut self, style: StepDisplayStyle) -> Self {
321        self.style = style;
322        self
323    }
324
325    /// Build content lines
326    fn build_lines(&self, area: Rect) -> Vec<Line<'static>> {
327        let mut lines = Vec::new();
328        let full_width = area.width as usize;
329
330        for (idx, step) in self.state.steps.iter().enumerate() {
331            // Step header
332            let icon_color = step.status.color();
333            let step_style = match step.status {
334                StepStatus::Running => Style::default()
335                    .fg(Color::Yellow)
336                    .add_modifier(Modifier::BOLD),
337                StepStatus::Failed => Style::default().fg(Color::Red),
338                StepStatus::Completed => Style::default().fg(Color::Green),
339                _ => Style::default().fg(Color::White),
340            };
341
342            let header_suffix = if !step.sub_steps.is_empty() {
343                let (completed, total) = step.sub_step_progress();
344                format!(" ({}/{})", completed, total)
345            } else {
346                String::new()
347            };
348
349            lines.push(Line::from(vec![
350                Span::styled(
351                    format!("{} ", step.status.icon()),
352                    Style::default().fg(icon_color),
353                ),
354                Span::styled(format!("Step {}: ", idx + 1), step_style),
355                Span::styled(step.name.clone(), step_style),
356                Span::styled(header_suffix, Style::default().fg(Color::DarkGray)),
357            ]));
358
359            // Sub-steps (if running or expanded)
360            if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
361                for sub in &step.sub_steps {
362                    let sub_color = sub.status.color();
363                    let sub_style = match sub.status {
364                        StepStatus::Running => Style::default().fg(Color::Yellow),
365                        StepStatus::Completed => Style::default().fg(Color::Green),
366                        StepStatus::Failed => Style::default().fg(Color::Red),
367                        StepStatus::Skipped => Style::default().fg(Color::DarkGray),
368                        _ => Style::default().fg(Color::White),
369                    };
370
371                    lines.push(Line::from(vec![
372                        Span::raw("    "),
373                        Span::styled(
374                            format!("{} ", sub.status.sub_icon()),
375                            Style::default().fg(sub_color),
376                        ),
377                        Span::styled(sub.name.clone(), sub_style),
378                    ]));
379                }
380            }
381
382            // Output frame (if expanded and has output)
383            if step.expanded && !step.output.is_empty() {
384                let is_focused = self.state.focused_step == Some(idx);
385                let border_color = if is_focused {
386                    self.style.focused_border
387                } else {
388                    self.style.unfocused_border
389                };
390
391                let border_width = full_width.saturating_sub(6);
392                let content_width = full_width.saturating_sub(8);
393
394                // Top border
395                lines.push(Line::from(Span::styled(
396                    format!("  ┌{:─<width$}┐  ", " Output ", width = border_width),
397                    Style::default().fg(border_color),
398                )));
399
400                // Output content
401                let visible_lines = self.style.max_output_lines;
402                let scroll = step.scroll as usize;
403                let total = step.output.len();
404
405                for i in 0..visible_lines {
406                    let line_idx = scroll + i;
407                    let content = if line_idx < total {
408                        truncate_to_width(&step.output[line_idx], content_width)
409                    } else {
410                        String::new()
411                    };
412
413                    let padded = pad_to_width(&content, content_width);
414                    lines.push(Line::from(vec![
415                        Span::styled("  │ ", Style::default().fg(border_color)),
416                        Span::styled(padded, Style::default().fg(Color::Gray)),
417                        Span::styled(" │  ", Style::default().fg(border_color)),
418                    ]));
419                }
420
421                // Bottom border with scroll info
422                let scroll_info = if total > visible_lines {
423                    format!(" [{}/{} lines] ", scroll + visible_lines.min(total), total)
424                } else {
425                    String::new()
426                };
427
428                lines.push(Line::from(Span::styled(
429                    format!("  └{:─<width$}┘  ", scroll_info, width = border_width),
430                    Style::default().fg(border_color),
431                )));
432
433                // Empty line after output
434                lines.push(Line::from(""));
435            }
436        }
437
438        lines
439    }
440}
441
442impl Widget for StepDisplay<'_> {
443    fn render(self, area: Rect, buf: &mut Buffer) {
444        let lines = self.build_lines(area);
445        let para = Paragraph::new(lines).scroll((self.state.scroll, 0));
446        para.render(area, buf);
447    }
448}
449
450/// Calculate total height needed for step display
451pub fn calculate_height(state: &StepDisplayState, style: &StepDisplayStyle) -> u16 {
452    let mut height = 0u16;
453
454    for step in &state.steps {
455        height += 1; // Step header
456
457        // Sub-steps
458        if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
459            height += step.sub_steps.len() as u16;
460        }
461
462        // Output frame
463        if step.expanded && !step.output.is_empty() {
464            height += 2; // borders
465            height += style.max_output_lines as u16;
466            height += 1; // empty line after
467        }
468    }
469
470    height
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_step_status_icons() {
479        assert_eq!(StepStatus::Pending.icon(), "[ ]");
480        assert_eq!(StepStatus::Running.icon(), "[▶]");
481        assert_eq!(StepStatus::Completed.icon(), "[✓]");
482        assert_eq!(StepStatus::Failed.icon(), "[✗]");
483        assert_eq!(StepStatus::Skipped.icon(), "[↷]");
484    }
485
486    #[test]
487    fn test_step_status_sub_icons() {
488        assert_eq!(StepStatus::Pending.sub_icon(), "○");
489        assert_eq!(StepStatus::Running.sub_icon(), "◐");
490        assert_eq!(StepStatus::Completed.sub_icon(), "●");
491        assert_eq!(StepStatus::Failed.sub_icon(), "✗");
492        assert_eq!(StepStatus::Skipped.sub_icon(), "◌");
493    }
494
495    #[test]
496    fn test_step_status_colors() {
497        assert_eq!(StepStatus::Pending.color(), Color::DarkGray);
498        assert_eq!(StepStatus::Running.color(), Color::Yellow);
499        assert_eq!(StepStatus::Completed.color(), Color::Green);
500        assert_eq!(StepStatus::Failed.color(), Color::Red);
501        assert_eq!(StepStatus::Skipped.color(), Color::DarkGray);
502    }
503
504    #[test]
505    fn test_step_new() {
506        let step = Step::new("Build");
507        assert_eq!(step.name, "Build");
508        assert_eq!(step.status, StepStatus::Pending);
509        assert!(step.sub_steps.is_empty());
510        assert!(!step.expanded);
511        assert!(step.output.is_empty());
512    }
513
514    #[test]
515    fn test_step_with_sub_steps() {
516        let step = Step::new("Build").with_sub_steps(vec!["Compile", "Link", "Package"]);
517        assert_eq!(step.sub_steps.len(), 3);
518        assert_eq!(step.sub_steps[0].name, "Compile");
519        assert_eq!(step.sub_steps[1].name, "Link");
520        assert_eq!(step.sub_steps[2].name, "Package");
521    }
522
523    #[test]
524    fn test_step_progress() {
525        let step = Step::new("Test").with_sub_steps(vec!["A", "B", "C"]);
526        let (completed, total) = step.sub_step_progress();
527        assert_eq!(completed, 0);
528        assert_eq!(total, 3);
529    }
530
531    #[test]
532    fn test_step_add_output() {
533        let mut step = Step::new("Test");
534        step.add_output("Line 1");
535        step.add_output("Line 2");
536        assert_eq!(step.output.len(), 2);
537        assert_eq!(step.output[0], "Line 1");
538    }
539
540    #[test]
541    fn test_step_clear_output() {
542        let mut step = Step::new("Test");
543        step.add_output("Line 1");
544        step.add_output("Line 2");
545        step.scroll = 1;
546        step.clear_output();
547        assert!(step.output.is_empty());
548        assert_eq!(step.scroll, 0);
549    }
550
551    #[test]
552    fn test_step_auto_scroll() {
553        let mut step = Step::new("Test");
554        // Add more than 5 lines to trigger auto-scroll
555        for i in 0..10 {
556            step.add_output(format!("Line {}", i));
557        }
558        // scroll should be updated to show latest content
559        assert!(step.scroll > 0);
560    }
561
562    #[test]
563    fn test_sub_step_new() {
564        let sub = SubStep::new("Compile");
565        assert_eq!(sub.name, "Compile");
566        assert_eq!(sub.status, StepStatus::Pending);
567    }
568
569    #[test]
570    fn test_state_new() {
571        let steps = vec![Step::new("Step 1"), Step::new("Step 2")];
572        let state = StepDisplayState::new(steps);
573        assert_eq!(state.steps.len(), 2);
574        assert!(state.focused_step.is_none());
575        assert_eq!(state.scroll, 0);
576    }
577
578    #[test]
579    fn test_state_progress() {
580        let steps = vec![
581            Step::new("Step 1"),
582            Step::new("Step 2"),
583            Step::new("Step 3"),
584            Step::new("Step 4"),
585        ];
586        let mut state = StepDisplayState::new(steps);
587
588        assert_eq!(state.progress(), 0.0);
589
590        state.complete_step(0);
591        assert!((state.progress() - 0.25).abs() < 0.01);
592
593        state.complete_step(1);
594        assert!((state.progress() - 0.5).abs() < 0.01);
595    }
596
597    #[test]
598    fn test_state_progress_empty() {
599        let state = StepDisplayState::new(vec![]);
600        assert_eq!(state.progress(), 0.0);
601    }
602
603    #[test]
604    fn test_state_current_step() {
605        let steps = vec![
606            Step::new("Step 1"),
607            Step::new("Step 2"),
608            Step::new("Step 3"),
609        ];
610        let mut state = StepDisplayState::new(steps);
611
612        assert_eq!(state.current_step(), 0);
613
614        state.complete_step(0);
615        assert_eq!(state.current_step(), 1);
616
617        state.skip_step(1);
618        assert_eq!(state.current_step(), 2);
619    }
620
621    #[test]
622    fn test_state_operations() {
623        let steps = vec![Step::new("Test")];
624        let mut state = StepDisplayState::new(steps);
625
626        state.start_step(0);
627        assert_eq!(state.steps[0].status, StepStatus::Running);
628        assert!(state.steps[0].expanded);
629
630        state.add_output(0, "Line 1");
631        assert_eq!(state.steps[0].output.len(), 1);
632
633        state.complete_step(0);
634        assert_eq!(state.steps[0].status, StepStatus::Completed);
635    }
636
637    #[test]
638    fn test_state_fail_step() {
639        let steps = vec![Step::new("Test")];
640        let mut state = StepDisplayState::new(steps);
641
642        state.fail_step(0);
643        assert_eq!(state.steps[0].status, StepStatus::Failed);
644    }
645
646    #[test]
647    fn test_state_skip_step() {
648        let steps = vec![Step::new("Test")];
649        let mut state = StepDisplayState::new(steps);
650
651        state.skip_step(0);
652        assert_eq!(state.steps[0].status, StepStatus::Skipped);
653    }
654
655    #[test]
656    fn test_state_sub_step_operations() {
657        let steps = vec![Step::new("Test").with_sub_steps(vec!["A", "B"])];
658        let mut state = StepDisplayState::new(steps);
659
660        state.start_sub_step(0, 0);
661        assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Running);
662
663        state.complete_sub_step(0, 0);
664        assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Completed);
665    }
666
667    #[test]
668    fn test_state_toggle_expanded() {
669        let steps = vec![Step::new("Test")];
670        let mut state = StepDisplayState::new(steps);
671
672        assert!(!state.steps[0].expanded);
673        state.toggle_expanded(0);
674        assert!(state.steps[0].expanded);
675        state.toggle_expanded(0);
676        assert!(!state.steps[0].expanded);
677    }
678
679    #[test]
680    fn test_state_scroll_output() {
681        let mut step = Step::new("Test");
682        for i in 0..20 {
683            step.add_output(format!("Line {}", i));
684        }
685        let steps = vec![step];
686        let mut state = StepDisplayState::new(steps);
687        state.steps[0].scroll = 0;
688
689        state.scroll_output(0, 5);
690        assert_eq!(state.steps[0].scroll, 5);
691
692        state.scroll_output(0, -3);
693        assert_eq!(state.steps[0].scroll, 2);
694
695        // Should not go negative
696        state.scroll_output(0, -10);
697        assert_eq!(state.steps[0].scroll, 0);
698    }
699
700    #[test]
701    fn test_state_invalid_index() {
702        let steps = vec![Step::new("Test")];
703        let mut state = StepDisplayState::new(steps);
704
705        // These should not panic with invalid indices
706        state.start_step(10);
707        state.complete_step(10);
708        state.fail_step(10);
709        state.skip_step(10);
710        state.add_output(10, "test");
711        state.toggle_expanded(10);
712        state.scroll_output(10, 5);
713        state.start_sub_step(10, 0);
714        state.complete_sub_step(10, 0);
715    }
716
717    #[test]
718    fn test_step_display_style_default() {
719        let style = StepDisplayStyle::default();
720        assert_eq!(style.focused_border, Color::Cyan);
721        assert_eq!(style.unfocused_border, Color::DarkGray);
722        assert_eq!(style.max_output_lines, 5);
723    }
724
725    #[test]
726    fn test_calculate_height() {
727        let steps = vec![
728            Step::new("Step 1"),
729            Step::new("Step 2").with_sub_steps(vec!["A", "B"]),
730        ];
731        let mut state = StepDisplayState::new(steps);
732        let style = StepDisplayStyle::default();
733
734        // Initially just 2 headers
735        let height = calculate_height(&state, &style);
736        assert_eq!(height, 2);
737
738        // Expand step 2 with sub-steps
739        state.start_step(1);
740        let height = calculate_height(&state, &style);
741        assert!(height > 2); // Should include sub-steps
742    }
743
744    #[test]
745    fn test_step_display_render() {
746        let steps = vec![
747            Step::new("Build").with_sub_steps(vec!["Compile", "Link"]),
748            Step::new("Test"),
749        ];
750        let state = StepDisplayState::new(steps);
751        let display = StepDisplay::new(&state);
752
753        let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
754        display.render(Rect::new(0, 0, 60, 20), &mut buf);
755        // Should not panic
756    }
757}