npm_run_scripts/tui/widgets/
description.rs

1//! Description panel widget for the TUI.
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    text::{Line, Span},
7    widgets::{Paragraph, Widget, Wrap},
8};
9
10use crate::config::AppearanceConfig;
11use crate::package::{get_description, Script};
12use crate::tui::theme::Theme;
13
14/// Description panel widget.
15pub struct Description<'a> {
16    script: Option<&'a Script>,
17    theme: &'a Theme,
18    show_command: bool,
19    compact: bool,
20}
21
22impl<'a> Description<'a> {
23    /// Create a new description widget.
24    pub fn new(script: Option<&'a Script>, theme: &'a Theme, config: &AppearanceConfig) -> Self {
25        Self {
26            script,
27            theme,
28            show_command: config.icons, // Reusing icons flag for command preview
29            compact: config.compact,
30        }
31    }
32
33    /// Create description with explicit command preview setting.
34    pub fn with_command_preview(mut self, show: bool) -> Self {
35        self.show_command = show;
36        self
37    }
38
39    /// Build lines for the description panel.
40    fn build_lines(&self, width: u16) -> Vec<Line<'a>> {
41        let Some(script) = self.script else {
42            return vec![Line::from(Span::styled(
43                "No script selected",
44                self.theme.description(),
45            ))];
46        };
47
48        let mut lines = Vec::new();
49
50        // Description text
51        let desc = get_description(script);
52        let desc_text = if desc.is_empty() {
53            "No description".to_string()
54        } else {
55            desc.to_string()
56        };
57        lines.push(Line::from(Span::styled(
58            desc_text,
59            self.theme.description(),
60        )));
61
62        // Separator (only in non-compact mode)
63        if !self.compact && self.show_command {
64            let sep_width = (width as usize).min(60);
65            let separator = "─".repeat(sep_width);
66            lines.push(Line::from(Span::styled(separator, self.theme.separator())));
67        }
68
69        // Command preview
70        if self.show_command {
71            let command = script.command();
72            let cmd_display = if command.len() > width as usize - 4 {
73                // Truncate long commands
74                let truncated: String = command.chars().take(width as usize - 7).collect();
75                format!("$ {}...", truncated)
76            } else {
77                format!("$ {}", command)
78            };
79            lines.push(Line::from(Span::styled(cmd_display, self.theme.command())));
80        }
81
82        lines
83    }
84}
85
86impl Widget for Description<'_> {
87    fn render(self, area: Rect, buf: &mut Buffer) {
88        if area.height == 0 {
89            return;
90        }
91
92        let lines = self.build_lines(area.width);
93        let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
94        paragraph.render(area, buf);
95    }
96}
97
98/// Error display widget.
99pub struct ErrorDisplay<'a> {
100    message: &'a str,
101    theme: &'a Theme,
102}
103
104impl<'a> ErrorDisplay<'a> {
105    /// Create a new error display widget.
106    pub fn new(message: &'a str, theme: &'a Theme) -> Self {
107        Self { message, theme }
108    }
109
110    /// Build lines for the error display.
111    fn build_lines(&self) -> Vec<Line<'a>> {
112        vec![
113            Line::from(Span::styled("Error", self.theme.error())),
114            Line::from(Span::styled(self.message, self.theme.description())),
115        ]
116    }
117}
118
119impl Widget for ErrorDisplay<'_> {
120    fn render(self, area: Rect, buf: &mut Buffer) {
121        if area.height == 0 {
122            return;
123        }
124
125        let lines = self.build_lines();
126        let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
127        paragraph.render(area, buf);
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_description_no_script() {
137        let theme = Theme::default();
138        let config = AppearanceConfig::default();
139        let desc = Description::new(None, &theme, &config);
140
141        let lines = desc.build_lines(80);
142        let content: String = lines
143            .iter()
144            .flat_map(|l| l.spans.iter())
145            .map(|s| s.content.to_string())
146            .collect();
147
148        assert!(content.contains("No script selected"));
149    }
150
151    #[test]
152    fn test_description_with_script() {
153        let theme = Theme::default();
154        let config = AppearanceConfig::default();
155        let script = Script::new("dev", "vite --mode development");
156
157        let desc = Description::new(Some(&script), &theme, &config);
158        let lines = desc.build_lines(80);
159
160        let content: String = lines
161            .iter()
162            .flat_map(|l| l.spans.iter())
163            .map(|s| s.content.to_string())
164            .collect();
165
166        assert!(content.contains("vite"));
167    }
168
169    #[test]
170    fn test_description_compact() {
171        let theme = Theme::default();
172        let config = AppearanceConfig {
173            compact: true,
174            ..Default::default()
175        };
176        let script = Script::new("dev", "vite");
177
178        let desc = Description::new(Some(&script), &theme, &config);
179        let lines = desc.build_lines(80);
180
181        // Compact mode should have fewer lines (no separator)
182        assert!(lines.len() <= 2);
183    }
184
185    #[test]
186    fn test_description_long_command() {
187        let theme = Theme::default();
188        let config = AppearanceConfig::default();
189        let long_cmd =
190            "webpack --config webpack.config.js --mode production --env NODE_ENV=production";
191        let script = Script::new("build", long_cmd);
192
193        let desc = Description::new(Some(&script), &theme, &config);
194        let lines = desc.build_lines(40);
195
196        let content: String = lines
197            .iter()
198            .flat_map(|l| l.spans.iter())
199            .map(|s| s.content.to_string())
200            .collect();
201
202        // Should be truncated
203        assert!(content.contains("..."));
204    }
205
206    #[test]
207    fn test_error_display() {
208        let theme = Theme::default();
209        let error = ErrorDisplay::new("Something went wrong", &theme);
210
211        let lines = error.build_lines();
212        let content: String = lines
213            .iter()
214            .flat_map(|l| l.spans.iter())
215            .map(|s| s.content.to_string())
216            .collect();
217
218        assert!(content.contains("Error"));
219        assert!(content.contains("Something went wrong"));
220    }
221}