tabiew 0.13.1

A lightweight TUI application to view and query tabular data files, such as CSV, TSV, and parquet.
use std::{
    fmt::{Debug, Display},
    hash::{DefaultHasher, Hash, Hasher},
};

use crossterm::event::{KeyCode, KeyModifiers};
use itertools::Itertools;
use ratatui::{
    layout::{Constraint, Flex, Layout},
    symbols::{
        border::{ROUNDED, Set},
        line::{VERTICAL_LEFT, VERTICAL_RIGHT},
    },
    widgets::{Borders, Clear, List, ListItem, ListState, StatefulWidget, Widget},
};

use crate::{
    misc::config::theme,
    tui::{
        component::Component,
        widgets::{block::Block, highlighted_line::HighlightedLine, input::Input},
    },
};

#[derive(Debug)]
pub struct SearchPicker<T> {
    title: String,
    input: Input,
    list: ListState,
    items: Vec<T>,
    strings: Vec<String>,
    cached_filter: CachedFilter,
}

impl<T> SearchPicker<T>
where
    T: Display,
{
    pub fn new(items: Vec<T>) -> Self {
        Self {
            title: Default::default(),
            input: Default::default(),
            list: ListState::default().with_selected(Some(0)),
            cached_filter: Default::default(),
            strings: items.iter().map(ToString::to_string).collect(),
            items,
        }
    }
}

impl<T> SearchPicker<T> {
    pub fn with_title(self, title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            ..self
        }
    }

    pub fn text(&self) -> &str {
        self.input.value()
    }

    pub fn set_text(&mut self, text: impl AsRef<str>) {
        let mut input = Input::default();
        for c in text.as_ref().chars() {
            input.insert(c);
        }
        self.input = input;
    }

    pub fn select(&mut self, index: impl Into<Option<usize>>) {
        self.list.select(index.into());
    }

    pub fn selected(&self) -> Option<usize> {
        if self.text().is_empty() {
            self.list.selected()
        } else {
            self.list
                .selected()
                .and_then(|idx| self.cached_filter.indices.get(idx))
                .map(|(i, _)| *i)
        }
    }

    pub fn selected_item(&self) -> Option<&T> {
        self.selected().and_then(|i| self.items.get(i))
    }

    pub fn selected_str(&self) -> Option<&str> {
        self.selected()
            .and_then(|i| self.strings.get(i))
            .map(|s| s.as_str())
    }

    pub fn items(&self) -> &[T] {
        &self.items
    }

    pub fn strings(&self) -> &[String] {
        &self.strings
    }

    pub fn into_items(self) -> Vec<T> {
        self.items
    }

    pub fn into_string(self) -> Vec<String> {
        self.strings
    }

    pub fn len(&self) -> usize {
        if self.input.value().is_empty() {
            self.items.len()
        } else {
            self.cached_filter.indices.len()
        }
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

impl<T> Component for SearchPicker<T> {
    fn render(
        &mut self,
        _area: ratatui::prelude::Rect,
        buf: &mut ratatui::prelude::Buffer,
        focus_state: crate::tui::component::FocusState,
    ) {
        let items = if self.input.value().is_empty() {
            self.strings
                .iter()
                .map(|item| ListItem::new(item.as_str()).style(theme().text()))
                .collect_vec()
        } else {
            self.cached_filter
                .query(self.input.value(), &self.strings)
                .iter()
                .map(|(idx, hl)| (&self.strings[*idx], hl))
                .map(|(item, hl)| {
                    ListItem::new(
                        HighlightedLine::default()
                            .text(item.as_ref())
                            .highlights(hl.iter().copied())
                            .text_style(theme().text())
                            .highlight_style(theme().text_highlighted()),
                    )
                    .style(theme().text())
                })
                .collect_vec()
        };
        let list = List::new(items)
            .highlight_style(theme().row_highlighted())
            .block(
                Block::default()
                    .border_set(Set {
                        top_left: VERTICAL_RIGHT,
                        top_right: VERTICAL_LEFT,
                        ..ROUNDED
                    })
                    .into_widget(),
            );

        let width = 80;
        let height = list.len().saturating_add(4).min(25) as u16;

        let [area] = Layout::horizontal([Constraint::Length(width)])
            .flex(Flex::Center)
            .areas(buf.area);
        let [_, area] =
            Layout::vertical([Constraint::Length(3), Constraint::Length(height)]).areas(area);

        Clear.render(area, buf);
        let [input_area, list_area] =
            Layout::vertical([Constraint::Length(2), Constraint::Fill(1)]).areas(area);

        let input_area = {
            let block = Block::default()
                .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
                .title(self.title.as_str());
            let input_inner = block.inner(input_area);
            block.render(area, buf);
            input_inner
        };
        self.input.render(input_area, buf, focus_state);

        *self.list.offset_mut() = self
            .list
            .offset()
            .min(list.len().saturating_sub(list_area.height as usize));
        if self.list.selected().is_none() && !list.is_empty() {
            self.list.select(Some(0));
        }
        StatefulWidget::render(list, list_area, buf, &mut self.list);
    }

    fn handle(&mut self, event: crossterm::event::KeyEvent) -> bool {
        self.input.handle(event)
            || match (event.code, event.modifiers) {
                (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
                    if self.list.selected() != Some(0) {
                        self.list.select_previous();
                    } else {
                        self.list.select(Some(self.len().saturating_sub(1)));
                    }
                    true
                }
                (KeyCode::Down, KeyModifiers::NONE)
                | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
                    if self.list.selected() != Some(self.len().saturating_sub(1)) {
                        self.list.select_next();
                    } else {
                        self.list.select_first();
                    }
                    true
                }
                _ => false,
            }
    }
}

#[derive(Debug, Default)]
struct CachedFilter {
    indices: Vec<(usize, Vec<usize>)>,
    query_hash: u64,
}

impl CachedFilter {
    pub fn query<T>(&mut self, query: &str, items: &[T]) -> &[(usize, Vec<usize>)]
    where
        T: AsRef<str>,
    {
        let mut hasher = DefaultHasher::new();
        query.hash(&mut hasher);
        let query_hash = hasher.finish();

        if self.query_hash != query_hash {
            self.indices.clear();
            self.indices.extend(
                items
                    .iter()
                    .enumerate()
                    .filter_map(|(idx, item)| {
                        subsequence_pos(item.as_ref(), query).map(|v| (idx, v))
                    })
                    .sorted_by_key(|(idx, _)| items[*idx].as_ref().len()),
            );
            self.query_hash = query_hash;
        }
        &self.indices
    }
}

fn subsequence_pos(larger: &str, other: &str) -> Option<Vec<usize>> {
    let mut idxs = Vec::with_capacity(other.chars().count());
    let mut larger_iter = larger.chars().enumerate();
    for oc in other.chars() {
        if let Some((pos, _)) = larger_iter.find(|(_, lc)| lc.eq_ignore_ascii_case(&oc)) {
            idxs.push(pos);
        } else {
            return None;
        }
    }
    Some(idxs)
}