npm_run_scripts/tui/widgets/
description.rs1use 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
14pub 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 pub fn new(script: Option<&'a Script>, theme: &'a Theme, config: &AppearanceConfig) -> Self {
25 Self {
26 script,
27 theme,
28 show_command: config.icons, compact: config.compact,
30 }
31 }
32
33 pub fn with_command_preview(mut self, show: bool) -> Self {
35 self.show_command = show;
36 self
37 }
38
39 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 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 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 if self.show_command {
71 let command = script.command();
72 let cmd_display = if command.len() > width as usize - 4 {
73 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
98pub struct ErrorDisplay<'a> {
100 message: &'a str,
101 theme: &'a Theme,
102}
103
104impl<'a> ErrorDisplay<'a> {
105 pub fn new(message: &'a str, theme: &'a Theme) -> Self {
107 Self { message, theme }
108 }
109
110 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 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 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}