npm_run_scripts/tui/widgets/
scripts.rs

1//! Scripts grid widget for the TUI.
2
3use 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
17/// Scripts grid widget.
18pub 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    /// Create a new scripts grid widget.
28    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    /// Set the scroll offset.
39    pub fn scroll_offset(mut self, offset: usize) -> Self {
40        self.scroll_offset = offset;
41        self
42    }
43
44    /// Set multi-selected items.
45    pub fn multi_selected(mut self, selected: &'a HashSet<usize>) -> Self {
46        self.multi_selected = Some(selected);
47        self
48    }
49
50    /// Render a single script item.
51    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        // Number prefix (1-9 for first 9 visible items)
60        let num_str = if index < 9 {
61            format!("{}", index + 1)
62        } else {
63            " ".to_string()
64        };
65
66        // Cursor/marker
67        let marker = if is_selected {
68            ">"
69        } else if is_multi_selected {
70            "*"
71        } else {
72            " "
73        };
74
75        // Calculate name width (accounting for num, marker, and padding)
76        let prefix_len = 4; // " N > " or " N * " etc
77        let name_width = (max_width as usize).saturating_sub(prefix_len);
78        let name = truncate_with_ellipsis(script.name(), name_width);
79
80        // Build spans
81        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        // Calculate which items to show
112        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        // Render items in horizontal-first order
117        for display_idx in 0..(end_idx - start_idx) {
118            let script_idx = start_idx + display_idx;
119
120            // Calculate position in grid
121            let row = display_idx / columns;
122            let col = display_idx % columns;
123
124            if row >= rows {
125                break;
126            }
127
128            // Calculate screen position
129            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            // Get script and render state
137            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            // Render the script item
145            let spans =
146                self.render_script(script, display_idx, is_selected, is_multi, column_width);
147            let line = Line::from(spans);
148
149            // Render to buffer
150            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
156/// Empty state widget when no scripts are available.
157pub struct EmptyScripts<'a> {
158    message: &'a str,
159    hint: Option<&'a str>,
160    theme: &'a Theme,
161}
162
163impl<'a> EmptyScripts<'a> {
164    /// Create a new empty scripts widget.
165    pub fn new(message: &'a str, theme: &'a Theme) -> Self {
166        Self {
167            message,
168            hint: None,
169            theme,
170        }
171    }
172
173    /// Create a new empty scripts widget with a hint.
174    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    /// Create for no scripts found.
183    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    /// Create for no filter matches.
192    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    /// Create for filter with specific query that has no matches.
201    pub fn no_matches_for(filter: &str, theme: &'a Theme) -> Self {
202        // We can't store the dynamic string, so just use the static version
203        let _ = filter;
204        Self::no_matches(theme)
205    }
206
207    /// Create for all scripts excluded.
208    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        // Center the message vertically
226        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        // Render main message
237        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        // Render hint if present
243        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")); // First item should have number 1
298    }
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("*")); // Multi-select marker
311    }
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}