datasight 0.7.0

Terminal TUI for CSV, TSV, Parquet, JSON and NDJSON files — vim-style navigation, filtering, sorting, group-by, plotting and cloud storage (Azure, S3)
use crate::theme::{default_theme, list_themes, theme_by_name, Theme};

pub struct ThemePicker {
    pub cursor: usize,
    pub original: &'static str,
}

impl ThemePicker {
    pub fn open(current: &'static Theme) -> Self {
        let cursor = list_themes()
            .iter()
            .position(|t| t.name == current.name)
            .unwrap_or(0);
        Self {
            cursor,
            original: current.name,
        }
    }

    pub fn move_up(&mut self) -> &'static Theme {
        let n = list_themes().len();
        self.cursor = if self.cursor == 0 {
            n - 1
        } else {
            self.cursor - 1
        };
        self.current()
    }

    pub fn move_down(&mut self) -> &'static Theme {
        let n = list_themes().len();
        self.cursor = (self.cursor + 1) % n;
        self.current()
    }

    pub fn current(&self) -> &'static Theme {
        &list_themes()[self.cursor]
    }

    pub fn original_theme(&self) -> &'static Theme {
        theme_by_name(self.original).unwrap_or_else(default_theme)
    }
}

use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use ratatui::Frame;

pub fn render_picker(frame: &mut Frame, area: Rect, picker: &ThemePicker, theme: &Theme) {
    let popup_w = 36u16.min(area.width.saturating_sub(2));
    let popup_h = 13u16.min(area.height.saturating_sub(2));
    let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
    let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
    let popup = Rect {
        x,
        y,
        width: popup_w,
        height: popup_h,
    };

    frame.render_widget(Clear, popup);

    let block = Block::default()
        .title(Line::from(" Theme ").style(Style::default().fg(theme.accent)))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.accent))
        .style(Style::default().bg(theme.bg_alt).fg(theme.fg));

    let inner = block.inner(popup);
    frame.render_widget(block, popup);

    let [list_area, footer_area] =
        Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);

    let items: Vec<ListItem> = list_themes()
        .iter()
        .map(|t| {
            ListItem::new(Line::from(vec![
                Span::styled(format!(" {:14}", t.name), Style::default().fg(theme.fg)),
                Span::styled(
                    format!("  {}", t.display_name),
                    Style::default().fg(theme.fg_dim),
                ),
            ]))
        })
        .collect();

    let mut state = ListState::default();
    state.select(Some(picker.cursor));

    let list = List::new(items).highlight_style(
        Style::default()
            .bg(theme.bg_sel)
            .add_modifier(Modifier::BOLD),
    );

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

    let footer = Paragraph::new(Line::from(vec![
        Span::styled(" j/k ", Style::default().bg(theme.accent).fg(theme.bg)),
        Span::styled(" navigate  ", Style::default().fg(theme.fg_dim)),
        Span::styled(" Enter ", Style::default().bg(theme.accent).fg(theme.bg)),
        Span::styled(" keep  ", Style::default().fg(theme.fg_dim)),
        Span::styled(" Esc ", Style::default().bg(theme.accent).fg(theme.bg)),
        Span::styled(" cancel", Style::default().fg(theme.fg_dim)),
    ]))
    .alignment(Alignment::Left)
    .style(Style::default().bg(theme.bg_alt));
    frame.render_widget(footer, footer_area);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn open_starts_cursor_at_current_theme() {
        let nord = theme_by_name("nord").unwrap();
        let picker = ThemePicker::open(nord);
        assert_eq!(picker.current().name, "nord");
        assert_eq!(picker.original, "nord");
    }

    #[test]
    fn move_down_advances_and_wraps() {
        let mut picker = ThemePicker::open(default_theme());
        let n = list_themes().len();
        for _ in 0..n - 1 {
            picker.move_down();
        }
        let _ = picker.move_down();
        assert_eq!(picker.cursor, 0);
    }

    #[test]
    fn move_up_retreats_and_wraps() {
        let mut picker = ThemePicker::open(default_theme());
        let last = picker.move_up();
        assert_eq!(picker.cursor, list_themes().len() - 1);
        assert_eq!(last.name, list_themes().last().unwrap().name);
    }

    #[test]
    fn original_theme_returns_starting_theme() {
        let dracula = theme_by_name("dracula").unwrap();
        let mut picker = ThemePicker::open(dracula);
        picker.move_down();
        picker.move_down();
        assert_eq!(picker.original_theme().name, "dracula");
    }
}