npm_run_scripts/tui/
ui.rs

1//! Main UI rendering and TUI loop.
2
3use std::io::{self, stdout, Stdout, Write};
4use std::panic;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result};
9use crossterm::{
10    cursor, event, execute,
11    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
12};
13use ratatui::{
14    backend::CrosstermBackend,
15    layout::Alignment,
16    text::{Line, Span},
17    widgets::{Block, Borders, Clear, Paragraph, Wrap},
18    Frame, Terminal,
19};
20
21use super::app::{App, AppMode, ScriptRun};
22use super::input::handle_event;
23use super::layout::{centered_rect_fixed, MainLayout};
24use super::theme::Theme;
25use super::widgets::{ArgsFilter, Description, EmptyScripts, Filter, Footer, Header, ScriptsGrid};
26
27/// Blink interval for cursor (in milliseconds).
28const CURSOR_BLINK_MS: u64 = 530;
29
30/// Global flag to track if terminal is in raw mode.
31static TERMINAL_RAW_MODE: AtomicBool = AtomicBool::new(false);
32
33/// RAII guard for terminal state.
34/// Ensures terminal is properly restored even on panic.
35pub struct TerminalGuard {
36    terminal: Terminal<CrosstermBackend<Stdout>>,
37}
38
39impl TerminalGuard {
40    /// Create a new terminal guard, setting up the terminal for TUI.
41    pub fn new() -> Result<Self> {
42        // Set up panic hook before entering raw mode
43        setup_panic_hook();
44
45        enable_raw_mode().context("Failed to enable raw mode")?;
46        TERMINAL_RAW_MODE.store(true, Ordering::SeqCst);
47
48        let mut stdout = stdout();
49        execute!(stdout, EnterAlternateScreen, cursor::Hide)
50            .context("Failed to enter alternate screen")?;
51
52        let backend = CrosstermBackend::new(stdout);
53        let terminal = Terminal::new(backend).context("Failed to create terminal")?;
54
55        Ok(Self { terminal })
56    }
57
58    /// Get a mutable reference to the terminal.
59    pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
60        &mut self.terminal
61    }
62}
63
64impl Drop for TerminalGuard {
65    fn drop(&mut self) {
66        // Restore terminal state
67        let _ = disable_raw_mode();
68        TERMINAL_RAW_MODE.store(false, Ordering::SeqCst);
69        let _ = execute!(
70            self.terminal.backend_mut(),
71            LeaveAlternateScreen,
72            cursor::Show
73        );
74    }
75}
76
77/// Set up a panic hook that restores the terminal.
78fn setup_panic_hook() {
79    let original_hook = panic::take_hook();
80
81    panic::set_hook(Box::new(move |panic_info| {
82        // Restore terminal
83        if TERMINAL_RAW_MODE.load(Ordering::SeqCst) {
84            let _ = disable_raw_mode();
85            let _ = execute!(stdout(), LeaveAlternateScreen, cursor::Show);
86        }
87
88        // Call the original panic hook
89        original_hook(panic_info);
90    }));
91}
92
93/// Restore terminal to normal state.
94/// Call this before running external commands.
95pub fn restore_terminal() -> Result<()> {
96    if TERMINAL_RAW_MODE.load(Ordering::SeqCst) {
97        disable_raw_mode().context("Failed to disable raw mode")?;
98        execute!(stdout(), LeaveAlternateScreen, cursor::Show)
99            .context("Failed to leave alternate screen")?;
100        TERMINAL_RAW_MODE.store(false, Ordering::SeqCst);
101    }
102    io::stdout().flush()?;
103    Ok(())
104}
105
106/// Run the TUI application.
107///
108/// Returns the scripts to run after TUI exits, along with their arguments.
109pub fn run_tui(mut app: App) -> Result<Vec<ScriptRun>> {
110    let mut guard = TerminalGuard::new()?;
111
112    // Main loop
113    let result = run_loop(guard.terminal(), &mut app);
114
115    // Guard will restore terminal on drop
116    drop(guard);
117
118    result?;
119
120    // Return all scripts to run
121    if let Some(script_run) = app.script_to_run() {
122        Ok(vec![script_run.clone()])
123    } else {
124        Ok(vec![])
125    }
126}
127
128/// Main TUI loop.
129fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
130    let theme = Theme::new(&app.config().appearance.theme);
131    let mut last_blink = Instant::now();
132    let mut blink_state = true;
133
134    loop {
135        // Update blink state
136        if last_blink.elapsed() >= Duration::from_millis(CURSOR_BLINK_MS) {
137            blink_state = !blink_state;
138            last_blink = Instant::now();
139        }
140
141        // Update columns based on terminal size
142        let size = terminal.size()?;
143        app.update_columns(size.width);
144
145        // Draw UI
146        terminal.draw(|frame| render(frame, app, &theme, blink_state))?;
147
148        // Handle events
149        if event::poll(Duration::from_millis(50))? {
150            let event = event::read()?;
151            if handle_event(app, event)? {
152                break;
153            }
154            // Reset blink on input
155            blink_state = true;
156            last_blink = Instant::now();
157        }
158
159        if app.should_quit() {
160            break;
161        }
162    }
163
164    Ok(())
165}
166
167/// Render the complete UI.
168pub fn render(frame: &mut Frame, app: &App, theme: &Theme, blink_state: bool) {
169    let config = &app.config().appearance;
170    let layout = MainLayout::with_config(frame.area(), config);
171
172    // Render main components
173    render_header(frame, app, theme, layout.header);
174    render_filter(frame, app, theme, layout.filter, blink_state);
175    render_scripts(frame, app, theme, layout.scripts);
176    render_description(frame, app, theme, layout.description);
177
178    if config.show_footer {
179        render_footer(frame, app, theme, layout.footer);
180    }
181
182    // Render overlays
183    match app.mode() {
184        AppMode::Help => render_help_overlay(frame, theme),
185        AppMode::Error { message } => render_error_overlay(frame, theme, message),
186        AppMode::WorkspaceSelect => render_workspace_selector(frame, app, theme, layout.scripts),
187        _ => {}
188    }
189}
190
191/// Render the header.
192fn render_header(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
193    let config = &app.config().appearance;
194    // Use breadcrumb if in workspace context
195    let title = app.breadcrumb();
196    let header = Header::new(&title, app.runner(), theme, config);
197    frame.render_widget(header, area);
198}
199
200/// Render the filter bar.
201fn render_filter(
202    frame: &mut Frame,
203    app: &App,
204    theme: &Theme,
205    area: ratatui::layout::Rect,
206    blink_state: bool,
207) {
208    let config = &app.config().appearance;
209
210    match app.mode() {
211        AppMode::Filter { query } => {
212            let filter = Filter::new(query, true, theme, config).blink(blink_state);
213            frame.render_widget(filter, area);
214        }
215        AppMode::Args { input, .. } => {
216            let script_name = app.selected_script().map(|s| s.name()).unwrap_or("script");
217            let args_filter = ArgsFilter::new(script_name, input, theme).blink(blink_state);
218            frame.render_widget(args_filter, area);
219        }
220        _ => {
221            let query = app.filter_text();
222            let filter = Filter::new(query, false, theme, config);
223            frame.render_widget(filter, area);
224        }
225    }
226}
227
228/// Render the scripts grid.
229fn render_scripts(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
230    let visible = app.visible_scripts();
231
232    if visible.is_empty() {
233        let empty = if app.filter_text().is_empty() {
234            EmptyScripts::no_scripts(theme)
235        } else {
236            EmptyScripts::no_matches(theme)
237        };
238        frame.render_widget(empty, area);
239        return;
240    }
241
242    let mut grid =
243        ScriptsGrid::new(&visible, app.selected_index(), theme).scroll_offset(app.scroll_offset());
244
245    // Add multi-select state if in that mode
246    if let AppMode::MultiSelect { selected } = app.mode() {
247        grid = grid.multi_selected(selected);
248    }
249
250    frame.render_widget(grid, area);
251}
252
253/// Render the description panel.
254fn render_description(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
255    let config = &app.config().appearance;
256    let script = app.selected_script();
257    let desc = Description::new(script, theme, config)
258        .with_command_preview(app.config().general.show_command_preview);
259    frame.render_widget(desc, area);
260}
261
262/// Render the footer.
263fn render_footer(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
264    let footer = Footer::new(app.mode(), theme);
265    frame.render_widget(footer, area);
266}
267
268/// Render the workspace selector.
269fn render_workspace_selector(
270    frame: &mut Frame,
271    app: &App,
272    theme: &Theme,
273    area: ratatui::layout::Rect,
274) {
275    use ratatui::text::{Line, Span};
276    use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
277
278    let workspaces = app.workspaces();
279    let selected = app.workspace_selected();
280
281    // Build list items: [root, workspace1, workspace2, ...]
282    let mut items: Vec<ListItem> = Vec::with_capacity(workspaces.len() + 1);
283
284    // Root item
285    let root_label = format!(" 1  {} (root)", app.project_name());
286    let root_style = if selected == 0 {
287        theme.selected()
288    } else {
289        theme.script()
290    };
291    items.push(ListItem::new(Line::from(Span::styled(
292        root_label, root_style,
293    ))));
294
295    // Workspace items
296    for (i, ws) in workspaces.iter().enumerate() {
297        let num = i + 2; // 2-indexed since root is 1
298        let label = if num <= 9 {
299            format!(" {}  {}", num, ws.name())
300        } else {
301            format!("    {}", ws.name())
302        };
303
304        let style = if selected == i + 1 {
305            theme.selected()
306        } else {
307            theme.script()
308        };
309        items.push(ListItem::new(Line::from(Span::styled(label, style))));
310    }
311
312    // Create the list widget
313    let list = List::new(items)
314        .block(
315            Block::default()
316                .borders(Borders::ALL)
317                .title(" Select Workspace ")
318                .title_style(theme.bold())
319                .border_style(theme.separator()),
320        )
321        .highlight_style(theme.selected());
322
323    // Render with state
324    let mut state = ListState::default();
325    state.select(Some(selected));
326
327    frame.render_stateful_widget(list, area, &mut state);
328}
329
330/// Render the help overlay.
331fn render_help_overlay(frame: &mut Frame, theme: &Theme) {
332    let area = frame.area();
333    let help_area = centered_rect_fixed(50, 18, area);
334
335    // Clear the area
336    frame.render_widget(Clear, help_area);
337
338    // Help content
339    let help_lines = vec![
340        Line::from(Span::styled("Keyboard Shortcuts", theme.bold())),
341        Line::from(""),
342        Line::from(vec![
343            Span::styled("  j/k     ", theme.key()),
344            Span::styled("Move up/down", theme.description()),
345        ]),
346        Line::from(vec![
347            Span::styled("  h/l     ", theme.key()),
348            Span::styled("Move left/right (in grid)", theme.description()),
349        ]),
350        Line::from(vec![
351            Span::styled("  g/G     ", theme.key()),
352            Span::styled("First/last item", theme.description()),
353        ]),
354        Line::from(""),
355        Line::from(vec![
356            Span::styled("  Enter   ", theme.key()),
357            Span::styled("Run selected script", theme.description()),
358        ]),
359        Line::from(vec![
360            Span::styled("  1-9     ", theme.key()),
361            Span::styled("Quick run numbered script", theme.description()),
362        ]),
363        Line::from(vec![
364            Span::styled("  /       ", theme.key()),
365            Span::styled("Filter scripts", theme.description()),
366        ]),
367        Line::from(vec![
368            Span::styled("  s       ", theme.key()),
369            Span::styled("Cycle sort mode", theme.description()),
370        ]),
371        Line::from(""),
372        Line::from(vec![
373            Span::styled("  ?       ", theme.key()),
374            Span::styled("Toggle this help", theme.description()),
375        ]),
376        Line::from(vec![
377            Span::styled("  q/Esc   ", theme.key()),
378            Span::styled("Quit", theme.description()),
379        ]),
380        Line::from(""),
381        Line::from(Span::styled(
382            "Press any key to close",
383            theme.filter_placeholder(),
384        )),
385    ];
386
387    let help = Paragraph::new(help_lines)
388        .block(
389            Block::default()
390                .borders(Borders::ALL)
391                .title(" Help ")
392                .style(theme.description()),
393        )
394        .alignment(Alignment::Left)
395        .wrap(Wrap { trim: true });
396
397    frame.render_widget(help, help_area);
398}
399
400/// Render an error overlay.
401fn render_error_overlay(frame: &mut Frame, theme: &Theme, message: &str) {
402    let area = frame.area();
403    let error_area = centered_rect_fixed(60, 8, area);
404
405    // Clear the area
406    frame.render_widget(Clear, error_area);
407
408    let error_lines = vec![
409        Line::from(Span::styled("Error", theme.error())),
410        Line::from(""),
411        Line::from(Span::styled(message, theme.description())),
412        Line::from(""),
413        Line::from(Span::styled(
414            "Press any key to dismiss",
415            theme.filter_placeholder(),
416        )),
417    ];
418
419    let error = Paragraph::new(error_lines)
420        .block(
421            Block::default()
422                .borders(Borders::ALL)
423                .title(" Error ")
424                .border_style(theme.error()),
425        )
426        .alignment(Alignment::Center)
427        .wrap(Wrap { trim: true });
428
429    frame.render_widget(error, error_area);
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::config::Config;
436    use crate::history::History;
437    use crate::package::{Runner, Script, Scripts};
438    use std::path::PathBuf;
439
440    fn create_test_app() -> App {
441        let mut scripts = Scripts::new();
442        scripts.add(Script::new("dev", "vite"));
443        scripts.add(Script::new("build", "vite build"));
444        scripts.add(Script::new("test", "vitest"));
445
446        App::new(
447            scripts,
448            Config::default(),
449            History::new(),
450            "test-project".to_string(),
451            PathBuf::from("/test"),
452            Runner::Npm,
453        )
454    }
455
456    #[test]
457    fn test_render_creates_layout() {
458        // This is a basic test to ensure the render function doesn't panic
459        // Full rendering tests would require a mock terminal
460        let _app = create_test_app();
461        let _theme = Theme::default();
462        // Can't easily test render without a terminal, but ensure it compiles
463    }
464}