shuvarie 0.1.0

Blazingly fast AI coding TUI for chivalrous people
use ratatui::layout::Constraint::{Length, Min};
use ratatui::prelude::*;
use ratatui::widgets::{Clear, ListItem, Paragraph};
use termina::event::{KeyCode, KeyEvent};

use crate::tui::utils::ctrl;

use super::commands::{CommandAction, CommandEntry, default_commands};
use super::list::{render_list_item_line, scroll_offset_for};
use super::search::{Search, SearchMessage};
use super::theme;

pub enum CommandMenuMessage {
    Search(SearchMessage),
    Next,
    Prev,
    Run,
    Close,
    Resize { viewport_height: u16 },
}

pub struct CommandMenu {
    pub open: bool,
    pub commands: Vec<CommandEntry>,
    pub filtered: Vec<usize>,
    pub selected: usize,
    pub offset: usize,
    viewport_height: u16,
    pub search: Search,
}

impl CommandMenu {
    pub fn new() -> Self {
        let commands = default_commands();
        let filtered = (0..commands.len()).collect();
        Self {
            open: false,
            commands,
            filtered,
            selected: 0,
            offset: 0,
            viewport_height: 0,
            search: Search::new(),
        }
    }

    pub fn open(&mut self) {
        self.open = true;
        self.search.clear();
        self.search.active = true;
        self.refilter();
    }

    pub fn close(&mut self) {
        self.open = false;
        self.search.clear();
    }

    fn refilter(&mut self) {
        self.filtered = self
            .search
            .filter_indices(self.commands.len(), |i| self.commands[i].name.to_string())
            .into_iter()
            .filter(|&i| self.commands[i].available)
            .collect();
        self.selected = 0;
        self.offset = 0;
        self.recompute_offset();
    }

    pub fn set_availability(&mut self, action: CommandAction, available: bool) {
        for cmd in &mut self.commands {
            if cmd.action == action {
                cmd.available = available;
            }
        }
        if self.open {
            self.refilter();
        }
    }

    fn next(&mut self) {
        if !self.filtered.is_empty() {
            self.selected = (self.selected + 1).min(self.filtered.len() - 1);
            self.recompute_offset();
        }
    }

    fn prev(&mut self) {
        if !self.filtered.is_empty() {
            self.selected = self.selected.saturating_sub(1);
            self.recompute_offset();
        }
    }

    fn recompute_offset(&mut self) {
        let vh = self.viewport_height as usize;
        let len = self.filtered.len();
        self.offset = scroll_offset_for(self.selected, self.offset, vh, len);
    }

    fn selected_action(&self) -> Option<CommandAction> {
        let idx = self.selected;
        let cmd_idx = self.filtered.get(idx)?;
        Some(self.commands[*cmd_idx].action)
    }

    pub fn map_event(&self, key: &KeyEvent) -> Option<CommandMenuMessage> {
        if ctrl(key) {
            return match key.code {
                KeyCode::Char('n') => Some(CommandMenuMessage::Next),
                KeyCode::Char('p') => Some(CommandMenuMessage::Prev),
                _ => None,
            };
        }
        match key.code {
            KeyCode::Escape => Some(CommandMenuMessage::Close),
            KeyCode::Down => Some(CommandMenuMessage::Next),
            KeyCode::Up => Some(CommandMenuMessage::Prev),
            KeyCode::Enter => Some(CommandMenuMessage::Run),
            KeyCode::Backspace => Some(CommandMenuMessage::Search(SearchMessage::Backspace)),
            KeyCode::Char(c) => Some(CommandMenuMessage::Search(SearchMessage::Input(c))),
            _ => None,
        }
    }

    pub fn update(&mut self, msg: CommandMenuMessage) -> Option<CommandAction> {
        if !self.open && !matches!(msg, CommandMenuMessage::Close) {
            return None;
        }
        match msg {
            CommandMenuMessage::Close => {
                self.close();
            }
            CommandMenuMessage::Next => self.next(),
            CommandMenuMessage::Prev => self.prev(),
            CommandMenuMessage::Search(m) => {
                self.search.update(m);
                self.refilter();
            }
            CommandMenuMessage::Run => {
                if let Some(action) = self.selected_action() {
                    self.close();
                    return Some(action);
                }
            }
            CommandMenuMessage::Resize { viewport_height } => {
                if self.viewport_height != viewport_height {
                    self.viewport_height = viewport_height;
                    self.recompute_offset();
                }
            }
        }
        None
    }

    pub fn view(&self, frame: &mut Frame<'_>, area: Rect) {
        if !self.open {
            return;
        }
        let popup = centered_rect(60, 40, area);
        frame.render_widget(Clear, popup);
        let block = theme::overlay_block("Command Menu");
        let inner = block.inner(popup);
        frame.render_widget(block, popup);

        let [input_area, list_area, hint_area] =
            Layout::vertical([Length(1), Min(0), Length(1)]).areas(inner);

        self.search
            .view(frame, input_area, "Type to search commands");

        let offset = scroll_offset_for(
            self.selected,
            self.offset,
            list_area.height as usize,
            self.filtered.len(),
        );
        let visible: Vec<ListItem> = self
            .filtered
            .iter()
            .enumerate()
            .skip(offset)
            .take(list_area.height as usize)
            .map(|(idx, &i)| {
                let cmd = &self.commands[i];
                let line = Line::from(vec![
                    Span::raw(format!("{:<20} ", cmd.name)).fg(theme::text()),
                    Span::raw(cmd.description.to_string()).fg(theme::text_muted()),
                ]);
                render_list_item_line(line, idx == self.selected)
            })
            .collect();
        frame.render_widget(ratatui::widgets::List::new(visible), list_area);

        frame.render_widget(
            Paragraph::new(theme::help_line(&[
                ("Enter", "run"),
                ("Esc", "close"),
                ("↑↓", "navigate"),
            ]))
            .fg(theme::text_muted()),
            hint_area,
        );
    }
}

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let pop_w = area.width * percent_x / 100;
    let pop_h = area.height * percent_y / 100;
    let x = area.x + (area.width.saturating_sub(pop_w)) / 2;
    let y = area.y + (area.height.saturating_sub(pop_h)) / 2;
    Rect::new(x, y, pop_w, pop_h)
}

impl Default for CommandMenu {
    fn default() -> Self {
        Self::new()
    }
}