use crate::app::App;
use crate::Screen;
use acorn::util::constants::app::COMPACT_LOGO;
use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph};
use ratatui::Frame;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Command {
Check,
Doctor,
Gather,
Harvest,
Quit,
Theme,
}
impl Command {
const ALL: [Self; 6] = [Self::Check, Self::Doctor, Self::Gather, Self::Harvest, Self::Quit, Self::Theme];
fn name(self) -> &'static str {
match self {
| Self::Check => "/check",
| Self::Doctor => "/doctor",
| Self::Gather => "/gather",
| Self::Harvest => "/harvest",
| Self::Quit => "/quit",
| Self::Theme => "/theme",
}
}
fn description(self) -> &'static str {
match self {
| Self::Check => "Browse research activity checks",
| Self::Doctor => "Inspect system diagnostics",
| Self::Gather => "Discover persistent identifiers",
| Self::Harvest => "Discover persistent identifiers",
| Self::Quit => "Exit ACORN",
| Self::Theme => "Choose an accessible terminal palette",
}
}
}
fn filtered_commands(input: &str) -> Vec<Command> {
let query = input.strip_prefix('/').unwrap_or(input).trim().to_ascii_lowercase();
Command::ALL
.into_iter()
.filter(|command| command.name().trim_start_matches('/').starts_with(&query))
.collect()
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(height.min(area.height)), Constraint::Fill(1)])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(width.min(area.width)), Constraint::Fill(1)])
.split(vertical[1])[1]
}
pub fn render(f: &mut Frame, app: &App) {
let area = f.area();
f.render_widget(Block::default().style(Style::default().bg(app.theme.bg)), area);
let content = centered(area, 82, 18);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Length(2),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Length(1),
])
.split(content);
let logo = COMPACT_LOGO
.iter()
.map(|line| Line::from(Span::styled(*line, Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD))))
.collect::<Vec<_>>();
f.render_widget(Paragraph::new(logo).alignment(Alignment::Center), rows[0]);
f.render_widget(
Paragraph::new("Accessible Content Optimization for Research Needs")
.style(Style::default().fg(app.theme.text_muted))
.alignment(Alignment::Center),
rows[1],
);
render_composer(f, rows[2], app);
f.render_widget(
Paragraph::new("Type / for commands · ↑↓ select · enter run · esc clear")
.style(Style::default().fg(app.theme.text_muted))
.alignment(Alignment::Center),
rows[4],
);
if app.dashboard.data.input.starts_with('/') {
render_dropdown(f, rows[2], app);
}
}
fn render_composer(f: &mut Frame, area: Rect, app: &App) {
let elapsed = app.dashboard.data.started_at.elapsed().as_secs();
let title = format!(
" acorn-tui v{} · {} · {} ",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
app.theme.name
);
let footer = format!(" {} · {:02}:{:02} ", app.dashboard.data.working_directory, elapsed / 60, elapsed % 60);
let input = if app.dashboard.data.input.is_empty() {
Span::styled("Ask ACORN or type / for commands", Style::default().fg(app.theme.text_muted))
} else {
Span::styled(app.dashboard.data.input.as_str(), Style::default().fg(app.theme.text))
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.border))
.title(Line::from(title).centered())
.title_bottom(Line::from(footer).centered())
.padding(Padding::horizontal(2));
f.render_widget(Paragraph::new(Line::from(input)).block(block), area);
let cursor_offset = app.dashboard.data.input.chars().count().min(usize::from(area.width.saturating_sub(6))) as u16;
f.set_cursor_position((area.x.saturating_add(3).saturating_add(cursor_offset), area.y.saturating_add(2)));
}
fn render_dropdown(f: &mut Frame, composer: Rect, app: &App) {
let commands = filtered_commands(&app.dashboard.data.input);
let height = u16::try_from(commands.len()).unwrap_or(u16::MAX).saturating_add(2).min(7);
let y = composer.y.saturating_add(composer.height);
let available = f.area().bottom().saturating_sub(y);
let area = Rect::new(composer.x, y, composer.width, height.min(available));
if area.height < 2 {
return;
}
let items = if commands.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
"No matching commands",
Style::default().fg(app.theme.text_muted),
)))]
} else {
commands
.iter()
.map(|command| {
ListItem::new(Line::from(vec![
Span::styled(format!("{:<18}", command.name()), Style::default().fg(app.theme.accent)),
Span::styled(command.description(), Style::default().fg(app.theme.text_muted)),
]))
})
.collect()
};
let mut state =
ListState::default().with_selected((!commands.is_empty()).then_some(app.dashboard.selected.min(commands.len().saturating_sub(1))));
f.render_widget(Clear, area);
f.render_stateful_widget(
List::new(items)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(app.theme.border)))
.highlight_style(
Style::default()
.bg(app.theme.selection_bg)
.fg(app.theme.selection_fg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("› "),
area,
&mut state,
);
}
fn execute(app: &mut App, command: Command) {
match command {
| Command::Check => app.navigate_to(Screen::Check),
| Command::Doctor => app.navigate_to(Screen::Doctor),
| Command::Gather | Command::Harvest => app.navigate_to(Screen::Gather),
| Command::Quit => app.should_quit = true,
| Command::Theme => app.navigate_to(Screen::ThemePicker),
}
app.dashboard.data.input.clear();
app.dashboard.selected = 0;
}
pub fn handle_event(app: &mut App, event: Event) {
let Event::Key(key) = event else { return };
if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat {
return;
}
match key.code {
| KeyCode::Char('c') if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => app.should_quit = true,
| KeyCode::Char(character) => {
app.dashboard.data.input.push(character);
app.dashboard.selected = 0;
}
| KeyCode::Backspace => {
app.dashboard.data.input.pop();
app.dashboard.selected = 0;
}
| KeyCode::Esc => {
app.dashboard.data.input.clear();
app.dashboard.selected = 0;
}
| KeyCode::Up if app.dashboard.data.input.starts_with('/') => {
let len = filtered_commands(&app.dashboard.data.input).len();
app.dashboard.selected = app.dashboard.selected.checked_sub(1).unwrap_or_else(|| len.saturating_sub(1));
}
| KeyCode::Down if app.dashboard.data.input.starts_with('/') => {
let len = filtered_commands(&app.dashboard.data.input).len();
app.dashboard.selected = if len == 0 { 0 } else { (app.dashboard.selected + 1) % len };
}
| KeyCode::Enter if app.dashboard.data.input.starts_with('/') => {
if let Some(command) = filtered_commands(&app.dashboard.data.input).get(app.dashboard.selected).copied() {
execute(app, command);
}
}
| _ => {}
}
}
#[cfg(test)]
mod tests;