1use 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
27const CURSOR_BLINK_MS: u64 = 530;
29
30static TERMINAL_RAW_MODE: AtomicBool = AtomicBool::new(false);
32
33pub struct TerminalGuard {
36 terminal: Terminal<CrosstermBackend<Stdout>>,
37}
38
39impl TerminalGuard {
40 pub fn new() -> Result<Self> {
42 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 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 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
77fn setup_panic_hook() {
79 let original_hook = panic::take_hook();
80
81 panic::set_hook(Box::new(move |panic_info| {
82 if TERMINAL_RAW_MODE.load(Ordering::SeqCst) {
84 let _ = disable_raw_mode();
85 let _ = execute!(stdout(), LeaveAlternateScreen, cursor::Show);
86 }
87
88 original_hook(panic_info);
90 }));
91}
92
93pub 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
106pub fn run_tui(mut app: App) -> Result<Vec<ScriptRun>> {
110 let mut guard = TerminalGuard::new()?;
111
112 let result = run_loop(guard.terminal(), &mut app);
114
115 drop(guard);
117
118 result?;
119
120 if let Some(script_run) = app.script_to_run() {
122 Ok(vec![script_run.clone()])
123 } else {
124 Ok(vec![])
125 }
126}
127
128fn 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 if last_blink.elapsed() >= Duration::from_millis(CURSOR_BLINK_MS) {
137 blink_state = !blink_state;
138 last_blink = Instant::now();
139 }
140
141 let size = terminal.size()?;
143 app.update_columns(size.width);
144
145 terminal.draw(|frame| render(frame, app, &theme, blink_state))?;
147
148 if event::poll(Duration::from_millis(50))? {
150 let event = event::read()?;
151 if handle_event(app, event)? {
152 break;
153 }
154 blink_state = true;
156 last_blink = Instant::now();
157 }
158
159 if app.should_quit() {
160 break;
161 }
162 }
163
164 Ok(())
165}
166
167pub 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_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 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
191fn render_header(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
193 let config = &app.config().appearance;
194 let title = app.breadcrumb();
196 let header = Header::new(&title, app.runner(), theme, config);
197 frame.render_widget(header, area);
198}
199
200fn 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
228fn 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 if let AppMode::MultiSelect { selected } = app.mode() {
247 grid = grid.multi_selected(selected);
248 }
249
250 frame.render_widget(grid, area);
251}
252
253fn 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
262fn 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
268fn 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 let mut items: Vec<ListItem> = Vec::with_capacity(workspaces.len() + 1);
283
284 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 for (i, ws) in workspaces.iter().enumerate() {
297 let num = i + 2; 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 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 let mut state = ListState::default();
325 state.select(Some(selected));
326
327 frame.render_stateful_widget(list, area, &mut state);
328}
329
330fn 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 frame.render_widget(Clear, help_area);
337
338 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
400fn 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 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 let _app = create_test_app();
461 let _theme = Theme::default();
462 }
464}