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
304impl From<&crate::theme::Theme> for StepDisplayStyle {
305    fn from(theme: &crate::theme::Theme) -> Self {
306        let p = &theme.palette;
307        Self {
308            focused_border: p.border_accent,
309            unfocused_border: p.border_disabled,
310            max_output_lines: 5,
311        }
312    }
313}
314
315/// Step display widget
316pub struct StepDisplay<'a> {
317    state: &'a StepDisplayState,
318    style: StepDisplayStyle,
319}
320
321impl<'a> StepDisplay<'a> {
322    /// Create a new step display
323    pub fn new(state: &'a StepDisplayState) -> Self {
324        Self {
325            state,
326            style: StepDisplayStyle::default(),
327        }
328    }
329
330    /// Set the style
331    pub fn style(mut self, style: StepDisplayStyle) -> Self {
332        self.style = style;
333        self
334    }
335
336    /// Apply a theme to derive the style
337    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
338        self.style(StepDisplayStyle::from(theme))
339    }
340
341    /// Build content lines
342    fn build_lines(&self, area: Rect) -> Vec<Line<'static>> {
343        let mut lines = Vec::new();
344        let full_width = area.width as usize;
345
346        for (idx, step) in self.state.steps.iter().enumerate() {
347            // Step header
348            let icon_color = step.status.color();
349            let step_style = match step.status {
350                StepStatus::Running => Style::default()
351                    .fg(Color::Yellow)
352                    .add_modifier(Modifier::BOLD),
353                StepStatus::Failed => Style::default().fg(Color::Red),
354                StepStatus::Completed => Style::default().fg(Color::Green),
355                _ => Style::default().fg(Color::White),
356            };
357
358            let header_suffix = if !step.sub_steps.is_empty() {
359                let (completed, total) = step.sub_step_progress();
360                format!(" ({}/{})", completed, total)
361            } else {
362                String::new()
363            };
364
365            lines.push(Line::from(vec![
366                Span::styled(
367                    format!("{} ", step.status.icon()),
368                    Style::default().fg(icon_color),
369                ),
370                Span::styled(format!("Step {}: ", idx + 1), step_style),
371                Span::styled(step.name.clone(), step_style),
372                Span::styled(header_suffix, Style::default().fg(Color::DarkGray)),
373            ]));
374
375            // Sub-steps (if running or expanded)
376            if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
377                for sub in &step.sub_steps {
378                    let sub_color = sub.status.color();
379                    let sub_style = match sub.status {
380                        StepStatus::Running => Style::default().fg(Color::Yellow),
381                        StepStatus::Completed => Style::default().fg(Color::Green),
382                        StepStatus::Failed => Style::default().fg(Color::Red),
383                        StepStatus::Skipped => Style::default().fg(Color::DarkGray),
384                        _ => Style::default().fg(Color::White),
385                    };
386
387                    lines.push(Line::from(vec![
388                        Span::raw("    "),
389                        Span::styled(
390                            format!("{} ", sub.status.sub_icon()),
391                            Style::default().fg(sub_color),
392                        ),
393                        Span::styled(sub.name.clone(), sub_style),
394                    ]));
395                }
396            }
397
398            // Output frame (if expanded and has output)
399            if step.expanded && !step.output.is_empty() {
400                let is_focused = self.state.focused_step == Some(idx);
401                let border_color = if is_focused {
402                    self.style.focused_border
403                } else {
404                    self.style.unfocused_border
405                };
406
407                let border_width = full_width.saturating_sub(6);
408                let content_width = full_width.saturating_sub(8);
409
410                // Top border
411                lines.push(Line::from(Span::styled(
412                    format!("  ┌{:─<width$}┐  ", " Output ", width = border_width),
413                    Style::default().fg(border_color),
414                )));
415
416                // Output content
417                let visible_lines = self.style.max_output_lines;
418                let scroll = step.scroll as usize;
419                let total = step.output.len();
420
421                for i in 0..visible_lines {
422                    let line_idx = scroll + i;
423                    let content = if line_idx < total {
424                        truncate_to_width(&step.output[line_idx], content_width)
425                    } else {
426                        String::new()
427                    };
428
429                    let padded = pad_to_width(&content, content_width);
430                    lines.push(Line::from(vec![
431                        Span::styled("  │ ", Style::default().fg(border_color)),
432                        Span::styled(padded, Style::default().fg(Color::Gray)),
433                        Span::styled(" │  ", Style::default().fg(border_color)),
434                    ]));
435                }
436
437                // Bottom border with scroll info
438                let scroll_info = if total > visible_lines {
439                    format!(" [{}/{} lines] ", scroll + visible_lines.min(total), total)
440                } else {
441                    String::new()
442                };
443
444                lines.push(Line::from(Span::styled(
445                    format!("  └{:─<width$}┘  ", scroll_info, width = border_width),
446                    Style::default().fg(border_color),
447                )));
448
449                // Empty line after output
450                lines.push(Line::from(""));
451            }
452        }
453
454        lines
455    }
456}
457
458impl Widget for StepDisplay<'_> {
459    fn render(self, area: Rect, buf: &mut Buffer) {
460        let lines = self.build_lines(area);
461        let para = Paragraph::new(lines).scroll((self.state.scroll, 0));
462        para.render(area, buf);
463    }
464}
465
466/// Calculate total height needed for step display
467pub fn calculate_height(state: &StepDisplayState, style: &StepDisplayStyle) -> u16 {
468    let mut height = 0u16;
469
470    for step in &state.steps {
471        height += 1; // Step header
472
473        // Sub-steps
474        if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
475            height += step.sub_steps.len() as u16;
476        }
477
478        // Output frame
479        if step.expanded && !step.output.is_empty() {
480            height += 2; // borders
481            height += style.max_output_lines as u16;
482            height += 1; // empty line after
483        }
484    }
485
486    height
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn test_step_status_icons() {
495        assert_eq!(StepStatus::Pending.icon(), "[ ]");
496        assert_eq!(StepStatus::Running.icon(), "[▶]");
497        assert_eq!(StepStatus::Completed.icon(), "[✓]");
498        assert_eq!(StepStatus::Failed.icon(), "[✗]");
499        assert_eq!(StepStatus::Skipped.icon(), "[↷]");
500    }
501
502    #[test]
503    fn test_step_status_sub_icons() {
504        assert_eq!(StepStatus::Pending.sub_icon(), "○");
505        assert_eq!(StepStatus::Running.sub_icon(), "◐");
506        assert_eq!(StepStatus::Completed.sub_icon(), "●");
507        assert_eq!(StepStatus::Failed.sub_icon(), "✗");
508        assert_eq!(StepStatus::Skipped.sub_icon(), "◌");
509    }
510
511    #[test]
512    fn test_step_status_colors() {
513        assert_eq!(StepStatus::Pending.color(), Color::DarkGray);
514        assert_eq!(StepStatus::Running.color(), Color::Yellow);
515        assert_eq!(StepStatus::Completed.color(), Color::Green);
516        assert_eq!(StepStatus::Failed.color(), Color::Red);
517        assert_eq!(StepStatus::Skipped.color(), Color::DarkGray);
518    }
519
520    #[test]
521    fn test_step_new() {
522        let step = Step::new("Build");
523        assert_eq!(step.name, "Build");
524        assert_eq!(step.status, StepStatus::Pending);
525        assert!(step.sub_steps.is_empty());
526        assert!(!step.expanded);
527        assert!(step.output.is_empty());
528    }
529
530    #[test]
531    fn test_step_with_sub_steps() {
532        let step = Step::new("Build").with_sub_steps(vec!["Compile", "Link", "Package"]);
533        assert_eq!(step.sub_steps.len(), 3);
534        assert_eq!(step.sub_steps[0].name, "Compile");
535        assert_eq!(step.sub_steps[1].name, "Link");
536        assert_eq!(step.sub_steps[2].name, "Package");
537    }
538
539    #[test]
540    fn test_step_progress() {
541        let step = Step::new("Test").with_sub_steps(vec!["A", "B", "C"]);
542        let (completed, total) = step.sub_step_progress();
543        assert_eq!(completed, 0);
544        assert_eq!(total, 3);
545    }
546
547    #[test]
548    fn test_step_add_output() {
549        let mut step = Step::new("Test");
550        step.add_output("Line 1");
551        step.add_output("Line 2");
552        assert_eq!(step.output.len(), 2);
553        assert_eq!(step.output[0], "Line 1");
554    }
555
556    #[test]
557    fn test_step_clear_output() {
558        let mut step = Step::new("Test");
559        step.add_output("Line 1");
560        step.add_output("Line 2");
561        step.scroll = 1;
562        step.clear_output();
563        assert!(step.output.is_empty());
564        assert_eq!(step.scroll, 0);
565    }
566
567    #[test]
568    fn test_step_auto_scroll() {
569        let mut step = Step::new("Test");
570        // Add more than 5 lines to trigger auto-scroll
571        for i in 0..10 {
572            step.add_output(format!("Line {}", i));
573        }
574        // scroll should be updated to show latest content
575        assert!(step.scroll > 0);
576    }
577
578    #[test]
579    fn test_sub_step_new() {
580        let sub = SubStep::new("Compile");
581        assert_eq!(sub.name, "Compile");
582        assert_eq!(sub.status, StepStatus::Pending);
583    }
584
585    #[test]
586    fn test_state_new() {
587        let steps = vec![Step::new("Step 1"), Step::new("Step 2")];
588        let state = StepDisplayState::new(steps);
589        assert_eq!(state.steps.len(), 2);
590        assert!(state.focused_step.is_none());
591        assert_eq!(state.scroll, 0);
592    }
593
594    #[test]
595    fn test_state_progress() {
596        let steps = vec![
597            Step::new("Step 1"),
598            Step::new("Step 2"),
599            Step::new("Step 3"),
600            Step::new("Step 4"),
601        ];
602        let mut state = StepDisplayState::new(steps);
603
604        assert_eq!(state.progress(), 0.0);
605
606        state.complete_step(0);
607        assert!((state.progress() - 0.25).abs() < 0.01);
608
609        state.complete_step(1);
610        assert!((state.progress() - 0.5).abs() < 0.01);
611    }
612
613    #[test]
614    fn test_state_progress_empty() {
615        let state = StepDisplayState::new(vec![]);
616        assert_eq!(state.progress(), 0.0);
617    }
618
619    #[test]
620    fn test_state_current_step() {
621        let steps = vec![
622            Step::new("Step 1"),
623            Step::new("Step 2"),
624            Step::new("Step 3"),
625        ];
626        let mut state = StepDisplayState::new(steps);
627
628        assert_eq!(state.current_step(), 0);
629
630        state.complete_step(0);
631        assert_eq!(state.current_step(), 1);
632
633        state.skip_step(1);
634        assert_eq!(state.current_step(), 2);
635    }
636
637    #[test]
638    fn test_state_operations() {
639        let steps = vec![Step::new("Test")];
640        let mut state = StepDisplayState::new(steps);
641
642        state.start_step(0);
643        assert_eq!(state.steps[0].status, StepStatus::Running);
644        assert!(state.steps[0].expanded);
645
646        state.add_output(0, "Line 1");
647        assert_eq!(state.steps[0].output.len(), 1);
648
649        state.complete_step(0);
650        assert_eq!(state.steps[0].status, StepStatus::Completed);
651    }
652
653    #[test]
654    fn test_state_fail_step() {
655        let steps = vec![Step::new("Test")];
656        let mut state = StepDisplayState::new(steps);
657
658        state.fail_step(0);
659        assert_eq!(state.steps[0].status, StepStatus::Failed);
660    }
661
662    #[test]
663    fn test_state_skip_step() {
664        let steps = vec![Step::new("Test")];
665        let mut state = StepDisplayState::new(steps);
666
667        state.skip_step(0);
668        assert_eq!(state.steps[0].status, StepStatus::Skipped);
669    }
670
671    #[test]
672    fn test_state_sub_step_operations() {
673        let steps = vec![Step::new("Test").with_sub_steps(vec!["A", "B"])];
674        let mut state = StepDisplayState::new(steps);
675
676        state.start_sub_step(0, 0);
677        assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Running);
678
679        state.complete_sub_step(0, 0);
680        assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Completed);
681    }
682
683    #[test]
684    fn test_state_toggle_expanded() {
685        let steps = vec![Step::new("Test")];
686        let mut state = StepDisplayState::new(steps);
687
688        assert!(!state.steps[0].expanded);
689        state.toggle_expanded(0);
690        assert!(state.steps[0].expanded);
691        state.toggle_expanded(0);
692        assert!(!state.steps[0].expanded);
693    }
694
695    #[test]
696    fn test_state_scroll_output() {
697        let mut step = Step::new("Test");
698        for i in 0..20 {
699            step.add_output(format!("Line {}", i));
700        }
701        let steps = vec![step];
702        let mut state = StepDisplayState::new(steps);
703        state.steps[0].scroll = 0;
704
705        state.scroll_output(0, 5);
706        assert_eq!(state.steps[0].scroll, 5);
707
708        state.scroll_output(0, -3);
709        assert_eq!(state.steps[0].scroll, 2);
710
711        // Should not go negative
712        state.scroll_output(0, -10);
713        assert_eq!(state.steps[0].scroll, 0);
714    }
715
716    #[test]
717    fn test_state_invalid_index() {
718        let steps = vec![Step::new("Test")];
719        let mut state = StepDisplayState::new(steps);
720
721        // These should not panic with invalid indices
722        state.start_step(10);
723        state.complete_step(10);
724        state.fail_step(10);
725        state.skip_step(10);
726        state.add_output(10, "test");
727        state.toggle_expanded(10);
728        state.scroll_output(10, 5);
729        state.start_sub_step(10, 0);
730        state.complete_sub_step(10, 0);
731    }
732
733    #[test]
734    fn test_step_display_style_default() {
735        let style = StepDisplayStyle::default();
736        assert_eq!(style.focused_border, Color::Cyan);
737        assert_eq!(style.unfocused_border, Color::DarkGray);
738        assert_eq!(style.max_output_lines, 5);
739    }
740
741    #[test]
742    fn test_calculate_height() {
743        let steps = vec![
744            Step::new("Step 1"),
745            Step::new("Step 2").with_sub_steps(vec!["A", "B"]),
746        ];
747        let mut state = StepDisplayState::new(steps);
748        let style = StepDisplayStyle::default();
749
750        // Initially just 2 headers
751        let height = calculate_height(&state, &style);
752        assert_eq!(height, 2);
753
754        // Expand step 2 with sub-steps
755        state.start_step(1);
756        let height = calculate_height(&state, &style);
757        assert!(height > 2); // Should include sub-steps
758    }
759
760    #[test]
761    fn test_step_display_render() {
762        let steps = vec![
763            Step::new("Build").with_sub_steps(vec!["Compile", "Link"]),
764            Step::new("Test"),
765        ];
766        let state = StepDisplayState::new(steps);
767        let display = StepDisplay::new(&state);
768
769        let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
770        display.render(Rect::new(0, 0, 60, 20), &mut buf);
771        // Should not panic
772    }
773}