clankerdiff-ratatui 0.1.0

Embeddable Ratatui diff review widget
Documentation
use crate::{RatatuiTheme, ui::Modal};
use clankerdiff_theme::{DiffTheme, SelectionState, ThemeDescriptor};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::Style,
    text::{Line, Span},
    widgets::{List, ListItem, ListState, StatefulWidget},
};

#[derive(Debug)]
pub(crate) struct ThemePicker {
    themes: Vec<ThemeDescriptor>,
    selected: usize,
    original: DiffTheme,
}

pub(crate) enum ThemePickerAction {
    Preview(DiffTheme),
    Restore(DiffTheme),
    Commit,
    None,
}

impl ThemePicker {
    pub(crate) fn new(current: &DiffTheme) -> Self {
        let themes = DiffTheme::catalog();
        let current_id = current.id().to_string();
        let selected = themes
            .iter()
            .position(|theme| theme.id == current_id)
            .unwrap_or(0);
        Self {
            themes,
            selected,
            original: current.clone(),
        }
    }

    pub(crate) fn handle_key(&mut self, key: KeyEvent) -> ThemePickerAction {
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') => ThemePickerAction::Restore(self.original.clone()),
            KeyCode::Enter => ThemePickerAction::Commit,
            KeyCode::Up | KeyCode::Char('k') => self.select_relative(-1),
            KeyCode::Down | KeyCode::Char('j') => self.select_relative(1),
            KeyCode::Home | KeyCode::Char('g') => self.select(0),
            KeyCode::End | KeyCode::Char('G') => self.select(self.themes.len().saturating_sub(1)),
            _ => ThemePickerAction::None,
        }
    }

    fn select_relative(&mut self, delta: isize) -> ThemePickerAction {
        let last = self.themes.len().saturating_sub(1);
        let selected = self.selected.saturating_add_signed(delta).min(last);
        self.select(selected)
    }

    fn select(&mut self, selected: usize) -> ThemePickerAction {
        self.selected = selected;
        DiffTheme::builtin(&self.themes[self.selected].id)
            .map_or(ThemePickerAction::None, ThemePickerAction::Preview)
    }
}

pub(crate) fn render_theme_picker(
    area: Rect,
    buffer: &mut Buffer,
    picker: &ThemePicker,
    theme: &RatatuiTheme,
) {
    let popup = Modal::new("Theme", theme)
        .hint("j/k preview · Enter save · Esc cancel")
        .render(area, buffer);
    if popup.is_empty() {
        return;
    }
    let items = picker.themes.iter().map(|descriptor| {
        let appearance = if descriptor.is_dark { "dark" } else { "light" };
        ListItem::new(Line::from(vec![
            Span::raw(descriptor.name.clone()),
            Span::styled(
                format!("  {appearance}"),
                Style::new().fg(theme.ui.text_muted),
            ),
        ]))
    });
    let mut state = ListState::default().with_selected(Some(picker.selected));
    let list = List::new(items)
        .style(theme.ui.selection_style(SelectionState::None))
        .highlight_style(theme.ui.selection_style(SelectionState::Selected))
        .highlight_symbol("");
    StatefulWidget::render(list, popup, buffer, &mut state);
}

#[cfg(test)]
mod tests {
    use super::*;
    use clankerdiff_theme::ThemeId;
    use crossterm::event::KeyModifiers;

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    #[test]
    fn previews_navigation_and_restores_original_theme() {
        let original = DiffTheme::default();
        let mut picker = ThemePicker::new(&original);
        let ThemePickerAction::Preview(preview) = picker.handle_key(key(KeyCode::Down)) else {
            panic!("down should preview a theme");
        };
        assert_ne!(preview.id(), &ThemeId::Sage);

        let ThemePickerAction::Restore(restored) = picker.handle_key(key(KeyCode::Esc)) else {
            panic!("escape should restore the opening theme");
        };
        assert_eq!(restored.id(), &ThemeId::Sage);
    }

    #[test]
    fn enter_commits_selection() {
        let mut picker = ThemePicker::new(&DiffTheme::default());
        assert!(matches!(
            picker.handle_key(key(KeyCode::Enter)),
            ThemePickerAction::Commit
        ));
    }
}