use ratatui::{ layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, Frame, }; use crate::app::{App, CustomFocus, Focus, Screen}; pub fn draw(f: &mut Frame, app: &mut App) { match app.screen { Screen::Main => draw_main(f, app), Screen::Custom => draw_custom(f, app), Screen::Search => draw_search(f, app), } } fn draw_main(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Min(8), Constraint::Percentage(40), Constraint::Length(1), ]) .split(f.area()); let top_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(25), Constraint::Percentage(25), Constraint::Percentage(50), ]) .split(chunks[0]); draw_groups(f, app, top_chunks[0]); draw_files(f, app, top_chunks[1]); draw_sections(f, app, top_chunks[2]); draw_preview(f, app, chunks[1]); draw_status(f, app, chunks[2]); } fn draw_custom(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Min(5), Constraint::Length(5), Constraint::Length(5), Constraint::Length(1), ]) .split(f.area()); let Some(state) = app.custom_state.as_mut() else { return; }; let items: Vec = state .lines .iter() .enumerate() .map(|(i, line)| { let checked = if state.selected.get(i).copied().unwrap_or(false) { "[x]" } else { "[ ]" }; let label = format!( "{} {} — {}", checked, line.command, line.section ); let mut style = Style::default(); if i == state.cursor && state.focus == CustomFocus::Lines { style = style .bg(Color::Blue) .fg(Color::Black) .add_modifier(Modifier::BOLD); } ListItem::new(label).style(style) }) .collect(); let list_block = Block::default() .title(" Custom command — select lines with Space ") .borders(Borders::ALL) .border_style(if state.focus == CustomFocus::Lines { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }); let list = List::new(items).block(list_block); state.list_state.select(Some(state.cursor)); f.render_stateful_widget(list, chunks[0], &mut state.list_state); let input = Paragraph::new(state.command.as_str()) .block( Block::default() .title(" Command (use {} for selected line) ") .borders(Borders::ALL) .border_style(if state.focus == CustomFocus::Input { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }), ) .wrap(Wrap { trim: true }); f.render_widget(input, chunks[1]); let pre_command = Paragraph::new(state.pre_command.as_str()) .block( Block::default() .title(" Pre-command ") .borders(Borders::ALL) .border_style(if state.focus == CustomFocus::PreCommand { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }), ) .wrap(Wrap { trim: true }); f.render_widget(pre_command, chunks[2]); let help = "↑/↓: move | Space: toggle | a: select all | Tab: focus | Enter: run | Esc: back | scroll: move"; let text = match &app.message { Some(msg) => Text::from(msg.as_str()), None => Text::from(help), }; let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray)); f.render_widget(paragraph, chunks[3]); } fn draw_search(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ]) .split(f.area()); let Some(state) = app.search_state.as_mut() else { return; }; let input = Paragraph::new(state.query.as_str()) .block( Block::default() .title(" Search ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)), ) .wrap(Wrap { trim: true }); f.render_widget(input, chunks[0]); let items: Vec = state .results .iter() .enumerate() .map(|(i, result)| { let style = if i == state.cursor { Style::default() .bg(Color::Blue) .fg(Color::Black) .add_modifier(Modifier::BOLD) } else { Style::default() }; let label = if result.command.is_empty() { format!("[{}] {} — {}", result.group, result.file, result.section) } else { format!( "[{}] {} — {} — {}", result.group, result.file, result.section, result.command ) }; ListItem::new(label).style(style) }) .collect(); let list = List::new(items).block( Block::default() .title(format!(" Results ({}) ", state.results.len())) .borders(Borders::ALL), ); state.list_state.select(Some(state.cursor)); f.render_stateful_widget(list, chunks[1], &mut state.list_state); let preview_text = state .results .get(state.cursor) .map(|r| Text::from(r.command.as_str())) .unwrap_or_else(|| Text::from("No result selected.")); let preview = Paragraph::new(preview_text) .block(Block::default().title(" Preview ").borders(Borders::ALL)) .wrap(Wrap { trim: true }); f.render_widget(preview, chunks[2]); } fn draw_groups(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { let items: Vec = app .groups .iter() .enumerate() .map(|(i, g)| { let style = if i == app.group_index { Style::default() .bg(Color::Blue) .fg(Color::Black) .add_modifier(Modifier::BOLD) } else { Style::default() }; ListItem::new(g.name.clone()).style(style) }) .collect(); let block = Block::default() .title(" Groups ") .borders(Borders::ALL) .border_style(border_style(app.focus == Focus::Groups)); let list = List::new(items).block(block); app.group_list_state.select(Some(app.group_index)); f.render_stateful_widget(list, area, &mut app.group_list_state); } fn draw_files(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { let items: Vec = app .current_files() .iter() .enumerate() .map(|(i, file)| { let style = if i == app.file_index { Style::default() .bg(Color::Blue) .fg(Color::Black) .add_modifier(Modifier::BOLD) } else { Style::default() }; ListItem::new(file.name.clone()).style(style) }) .collect(); let block = Block::default() .title(" Files ") .borders(Borders::ALL) .border_style(border_style(app.focus == Focus::Files)); let list = List::new(items).block(block); app.file_list_state.select(Some(app.file_index)); f.render_stateful_widget(list, area, &mut app.file_list_state); } fn draw_sections(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { let items: Vec = app .current_sections() .iter() .enumerate() .map(|(i, section)| { let label = if section.is_run_all() { format!("{} (all)", section.title) } else { section.title.clone() }; let style = if i == app.section_index { Style::default() .bg(Color::Blue) .fg(Color::Black) .add_modifier(Modifier::BOLD) } else { Style::default() }; ListItem::new(label).style(style) }) .collect(); let block = Block::default() .title(" Commands ") .borders(Borders::ALL) .border_style(border_style(app.focus == Focus::Sections)); let list = List::new(items).block(block); app.section_list_state.select(Some(app.section_index)); f.render_stateful_widget(list, area, &mut app.section_list_state); } fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let text = match app.current_section() { Some(section) => { let title = if section.is_run_all() { format!("{} — runs all commands in this file", section.title) } else { section.title.clone() }; let mut lines = vec![Line::from(Span::styled( title, Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ))]; if section.is_run_all() { if let Some(file) = app.current_file() { for sec in &file.sections { if sec.is_run_all() { continue; } lines.push(Line::from(Span::styled( format!("# {}", sec.title), Style::default().fg(Color::Cyan), ))); for cmd in &sec.commands { lines.push(Line::from(cmd.as_str())); } lines.push(Line::from("")); } } } else { for cmd in §ion.commands { lines.push(Line::from(cmd.as_str())); } } Text::from(lines) } None => Text::from("No command to display."), }; let block = Block::default() .title(" Preview ") .borders(Borders::ALL); let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let help = "↑/↓: move | →/←: panel | Enter: run | /: search | o: new window | c: custom | e: edit | r: reload | q: quit"; let text = match &app.message { Some(msg) => Text::from(msg.as_str()), None => Text::from(help), }; let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray)); f.render_widget(paragraph, area); } fn border_style(focused: bool) -> Style { if focused { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) } }