keybinds-tui 0.1.0

A terminal UI for looking up keybindings across your tools, auto-opening on the tab matching the app you were just focused on
use crate::model::Tab;
use crate::theme::{self, Theme};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Tabs};
use ratatui::Frame;

pub fn draw(
    f: &mut Frame,
    tabs: &[Tab],
    active: usize,
    search: &str,
    searching: bool,
    table_state: &mut TableState,
    spinner: Option<&str>,
) {
    let t = theme::current();
    let area = f.area();
    f.render_widget(Block::default().style(Style::default().bg(t.bg)), area);

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(0),
            Constraint::Length(if searching || !search.is_empty() { 3 } else { 1 }),
        ])
        .split(area);

    draw_tabs(f, chunks[0], tabs, active, spinner, t);
    draw_binds(f, chunks[1], &tabs[active], search, table_state, spinner, t);
    draw_footer(f, chunks[2], search, searching, t);
}

fn draw_tabs(f: &mut Frame, area: Rect, tabs: &[Tab], active: usize, spinner: Option<&str>, t: &Theme) {
    let titles: Vec<Line> = tabs
        .iter()
        .map(|tab| {
            match spinner {
                Some(frame) if tab.app == "Neovim" && tab.sections.is_empty() => {
                    Line::from(format!("{} {}", tab.app, frame))
                }
                _ => Line::from(tab.app.clone()),
            }
        })
        .collect();
    let widget = Tabs::new(titles)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(t.border))
                .style(Style::default().bg(t.bg))
                .title(" keybinds "),
        )
        .select(active)
        .highlight_style(Style::default().fg(t.accent_text).add_modifier(Modifier::BOLD))
        .style(Style::default().fg(t.text_3).bg(t.bg));
    f.render_widget(widget, area);
}

fn draw_binds(
    f: &mut Frame,
    area: Rect,
    tab: &Tab,
    search: &str,
    table_state: &mut TableState,
    spinner: Option<&str>,
    t: &Theme,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(t.border))
        .style(Style::default().bg(t.bg));

    if tab.app == "Neovim" && tab.sections.is_empty() {
        let msg = Paragraph::new(format!(
            "{} loading nvim keymaps (headless)...",
            spinner.unwrap_or("")
        ))
        .style(Style::default().fg(t.text_3).bg(t.bg))
        .block(block);
        f.render_widget(msg, area);
        return;
    }

    let filtered = tab.filtered(search);
    let rows: Vec<Row> = filtered
        .iter()
        .map(|(section, bind)| {
            Row::new(vec![
                Cell::from(Span::styled(section.to_string(), Style::default().fg(t.text_3))),
                Cell::from(Span::styled(
                    bind.keys.clone(),
                    Style::default().fg(t.accent_text).add_modifier(Modifier::BOLD),
                )),
                Cell::from(Span::styled(bind.action.clone(), Style::default().fg(t.text))),
            ])
        })
        .collect();

    if rows.is_empty() {
        let msg = Paragraph::new("no results")
            .style(Style::default().fg(t.text_3).bg(t.bg))
            .block(block);
        f.render_widget(msg, area);
        return;
    }

    let table = Table::new(
        rows,
        [
            Constraint::Percentage(20),
            Constraint::Percentage(25),
            Constraint::Percentage(55),
        ],
    )
    .header(Row::new(vec!["Section", "Key", "Action"]).style(Style::default().fg(t.text_2).add_modifier(Modifier::BOLD)))
    .row_highlight_style(Style::default().bg(t.surface).add_modifier(Modifier::BOLD))
    .highlight_symbol("")
    .style(Style::default().bg(t.bg))
    .block(block);

    f.render_stateful_widget(table, area, table_state);
}

fn draw_footer(f: &mut Frame, area: Rect, search: &str, searching: bool, t: &Theme) {
    if searching || !search.is_empty() {
        let label = if searching { "search> " } else { "filter: " };
        let text = format!("{label}{search}");
        let p = Paragraph::new(text).style(Style::default().fg(t.accent_text).bg(t.bg)).block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(t.border))
                .style(Style::default().bg(t.bg)),
        );
        f.render_widget(p, area);
    } else {
        let hint = Paragraph::new(
            " h/l switch tab · j/k navigate · Ctrl+d/u half page · gg/G top/bottom · / search · q quit ",
        )
            .style(Style::default().fg(t.text_4).bg(t.bg));
        f.render_widget(hint, area);
    }
}