gmsg 0.1.3

AI-powered commit message generator with a TUI editor
Documentation
use crate::ai::ModelEntry;
use ratatui::{
    Frame,
    crossterm::event::{self, Event, KeyCode},
    layout::{Constraint, Layout},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, List, ListItem, ListState},
};

use crate::tui::TerminalGuard;

pub struct Selector {
    items: Vec<ModelEntry>,
    state: ListState,
}

impl Selector {
    pub fn new(items: Vec<ModelEntry>) -> Self {
        let mut state = ListState::default();
        state.select(Some(0));
        Self { items, state }
    }

    pub fn run(&mut self, terminal: &mut TerminalGuard) -> anyhow::Result<Option<String>> {
        let terminal = &mut terminal.0;
        loop {
            terminal.draw(|f| self.render(f))?;

            if let Event::Key(key) = event::read()? {
                match key.code {
                    KeyCode::Down | KeyCode::Char('j') => self.next(),
                    KeyCode::Up | KeyCode::Char('k') => self.previous(),
                    KeyCode::Enter => return Ok(self.selected_id()),
                    KeyCode::Esc | KeyCode::Char('q') => return Ok(None),
                    _ => {}
                }
            }
        }
    }

    fn next(&mut self) {
        let i = match self.state.selected() {
            Some(i) => (i + 1) % self.items.len(),
            None => 0,
        };
        self.state.select(Some(i));
    }

    fn previous(&mut self) {
        let i = match self.state.selected() {
            Some(i) => {
                if i == 0 {
                    self.items.len() - 1
                } else {
                    i - 1
                }
            }
            None => 0,
        };
        self.state.select(Some(i));
    }

    fn selected_id(&self) -> Option<String> {
        self.state.selected().map(|i| self.items[i].id.clone())
    }

    fn render(&mut self, frame: &mut Frame) {
        let [header, list_area, footer] = Layout::vertical([
            Constraint::Length(1),
            Constraint::Fill(1),
            Constraint::Length(1),
        ])
        .areas(frame.area());

        // header
        frame.render_widget(
            Line::from(Span::styled(
                " Select an option",
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            )),
            header,
        );

        // list
        let items: Vec<ListItem> = self
            .items
            .iter()
            .map(|e| ListItem::new(format!("  {}", e.display)))
            .collect();

        let list = List::new(items)
            .block(Block::default().borders(Borders::ALL))
            .highlight_style(
                Style::default()
                    .fg(Color::Black)
                    .bg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("");

        frame.render_stateful_widget(list, list_area, &mut self.state);

        // footer
        frame.render_widget(
            Line::from(vec![
                Span::styled(" ↑↓ / jk", Style::default().fg(Color::DarkGray)),
                Span::raw("  navigate  "),
                Span::styled("Enter", Style::default().fg(Color::DarkGray)),
                Span::raw("  select  "),
                Span::styled("Esc/q", Style::default().fg(Color::DarkGray)),
                Span::raw("  cancel"),
            ]),
            footer,
        );
    }
}