npm_run_scripts/tui/widgets/
scripts.rs1use std::collections::HashSet;
4
5use ratatui::{
6 buffer::Buffer,
7 layout::Rect,
8 text::{Line, Span},
9 widgets::Widget,
10};
11
12use crate::package::Script;
13use crate::tui::layout::{calculate_column_width, calculate_columns};
14use crate::tui::theme::Theme;
15use crate::tui::widgets::header::truncate_with_ellipsis;
16
17pub struct ScriptsGrid<'a> {
19 scripts: &'a [&'a Script],
20 selected: usize,
21 scroll_offset: usize,
22 theme: &'a Theme,
23 multi_selected: Option<&'a HashSet<usize>>,
24}
25
26impl<'a> ScriptsGrid<'a> {
27 pub fn new(scripts: &'a [&'a Script], selected: usize, theme: &'a Theme) -> Self {
29 Self {
30 scripts,
31 selected,
32 scroll_offset: 0,
33 theme,
34 multi_selected: None,
35 }
36 }
37
38 pub fn scroll_offset(mut self, offset: usize) -> Self {
40 self.scroll_offset = offset;
41 self
42 }
43
44 pub fn multi_selected(mut self, selected: &'a HashSet<usize>) -> Self {
46 self.multi_selected = Some(selected);
47 self
48 }
49
50 fn render_script(
52 &self,
53 script: &Script,
54 index: usize,
55 is_selected: bool,
56 is_multi_selected: bool,
57 max_width: u16,
58 ) -> Vec<Span<'a>> {
59 let num_str = if index < 9 {
61 format!("{}", index + 1)
62 } else {
63 " ".to_string()
64 };
65
66 let marker = if is_selected {
68 ">"
69 } else if is_multi_selected {
70 "*"
71 } else {
72 " "
73 };
74
75 let prefix_len = 4; let name_width = (max_width as usize).saturating_sub(prefix_len);
78 let name = truncate_with_ellipsis(script.name(), name_width);
79
80 let marker_style = if is_multi_selected {
82 self.theme.multiselect()
83 } else {
84 self.theme.cursor()
85 };
86
87 let name_style = if is_selected {
88 self.theme.selected()
89 } else {
90 self.theme.script()
91 };
92
93 vec![
94 Span::styled(format!("{} ", num_str), self.theme.number()),
95 Span::styled(format!("{} ", marker), marker_style),
96 Span::styled(name, name_style),
97 ]
98 }
99}
100
101impl Widget for ScriptsGrid<'_> {
102 fn render(self, area: Rect, buf: &mut Buffer) {
103 if area.height == 0 || area.width == 0 || self.scripts.is_empty() {
104 return;
105 }
106
107 let columns = calculate_columns(area.width);
108 let column_width = calculate_column_width(area.width, columns);
109 let rows = area.height as usize;
110
111 let total_visible = rows * columns;
113 let start_idx = self.scroll_offset;
114 let end_idx = (start_idx + total_visible).min(self.scripts.len());
115
116 for display_idx in 0..(end_idx - start_idx) {
118 let script_idx = start_idx + display_idx;
119
120 let row = display_idx / columns;
122 let col = display_idx % columns;
123
124 if row >= rows {
125 break;
126 }
127
128 let x = area.x + (col as u16 * column_width);
130 let y = area.y + row as u16;
131
132 if y >= area.y + area.height {
133 break;
134 }
135
136 let script = self.scripts[script_idx];
138 let is_selected = script_idx == self.selected;
139 let is_multi = self
140 .multi_selected
141 .map(|m| m.contains(&script_idx))
142 .unwrap_or(false);
143
144 let spans =
146 self.render_script(script, display_idx, is_selected, is_multi, column_width);
147 let line = Line::from(spans);
148
149 let item_area = Rect::new(x, y, column_width, 1);
151 buf.set_line(item_area.x, item_area.y, &line, item_area.width);
152 }
153 }
154}
155
156pub struct EmptyScripts<'a> {
158 message: &'a str,
159 hint: Option<&'a str>,
160 theme: &'a Theme,
161}
162
163impl<'a> EmptyScripts<'a> {
164 pub fn new(message: &'a str, theme: &'a Theme) -> Self {
166 Self {
167 message,
168 hint: None,
169 theme,
170 }
171 }
172
173 pub fn with_hint(message: &'a str, hint: &'a str, theme: &'a Theme) -> Self {
175 Self {
176 message,
177 hint: Some(hint),
178 theme,
179 }
180 }
181
182 pub fn no_scripts(theme: &'a Theme) -> Self {
184 Self::with_hint(
185 "No scripts found in package.json",
186 "Add scripts to your package.json to get started",
187 theme,
188 )
189 }
190
191 pub fn no_matches(theme: &'a Theme) -> Self {
193 Self::with_hint(
194 "No scripts match the filter",
195 "Press Escape to clear the filter",
196 theme,
197 )
198 }
199
200 pub fn no_matches_for(filter: &str, theme: &'a Theme) -> Self {
202 let _ = filter;
204 Self::no_matches(theme)
205 }
206
207 pub fn all_excluded(theme: &'a Theme) -> Self {
209 Self::with_hint(
210 "All scripts are excluded",
211 "Check your exclude patterns in config",
212 theme,
213 )
214 }
215}
216
217impl Widget for EmptyScripts<'_> {
218 fn render(self, area: Rect, buf: &mut Buffer) {
219 if area.height == 0 {
220 return;
221 }
222
223 let has_hint = self.hint.is_some() && area.height >= 3;
224
225 let y = if has_hint {
227 area.y + area.height / 2 - 1
228 } else {
229 area.y + area.height / 2
230 };
231
232 if y >= area.y + area.height {
233 return;
234 }
235
236 let msg_len = self.message.chars().count() as u16;
238 let x = area.x + (area.width.saturating_sub(msg_len)) / 2;
239 let line = Line::from(vec![Span::styled(self.message, self.theme.description())]);
240 buf.set_line(x, y, &line, area.width.saturating_sub(x - area.x));
241
242 if let Some(hint) = self.hint {
244 let hint_y = y + 2;
245 if hint_y < area.y + area.height {
246 let hint_len = hint.chars().count() as u16;
247 let hint_x = area.x + (area.width.saturating_sub(hint_len)) / 2;
248 let hint_line =
249 Line::from(vec![Span::styled(hint, self.theme.filter_placeholder())]);
250 buf.set_line(
251 hint_x,
252 hint_y,
253 &hint_line,
254 area.width.saturating_sub(hint_x - area.x),
255 );
256 }
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn create_test_scripts() -> Vec<Script> {
266 vec![
267 Script::new("dev", "vite"),
268 Script::new("build", "vite build"),
269 Script::new("test", "vitest"),
270 Script::new("lint", "eslint ."),
271 Script::new("format", "prettier --write ."),
272 ]
273 }
274
275 #[test]
276 fn test_scripts_grid_creation() {
277 let scripts = create_test_scripts();
278 let script_refs: Vec<&Script> = scripts.iter().collect();
279 let theme = Theme::default();
280
281 let grid = ScriptsGrid::new(&script_refs, 0, &theme);
282 assert_eq!(grid.selected, 0);
283 assert_eq!(grid.scroll_offset, 0);
284 }
285
286 #[test]
287 fn test_render_script() {
288 let scripts = create_test_scripts();
289 let script_refs: Vec<&Script> = scripts.iter().collect();
290 let theme = Theme::default();
291
292 let grid = ScriptsGrid::new(&script_refs, 0, &theme);
293 let spans = grid.render_script(&scripts[0], 0, true, false, 30);
294
295 let content: String = spans.iter().map(|s| s.content.to_string()).collect();
296 assert!(content.contains("dev"));
297 assert!(content.contains("1")); }
299
300 #[test]
301 fn test_render_script_multiselect() {
302 let scripts = create_test_scripts();
303 let script_refs: Vec<&Script> = scripts.iter().collect();
304 let theme = Theme::default();
305
306 let grid = ScriptsGrid::new(&script_refs, 0, &theme);
307 let spans = grid.render_script(&scripts[0], 0, false, true, 30);
308
309 let content: String = spans.iter().map(|s| s.content.to_string()).collect();
310 assert!(content.contains("*")); }
312
313 #[test]
314 fn test_calculate_columns() {
315 assert_eq!(calculate_columns(50), 1);
316 assert_eq!(calculate_columns(80), 2);
317 assert_eq!(calculate_columns(100), 3);
318 assert_eq!(calculate_columns(130), 4);
319 assert_eq!(calculate_columns(170), 5);
320 }
321}