tabiew 0.15.1

A lightweight TUI application to view and query tabular data files, such as CSV, TSV, and parquet.
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
    layout::{Constraint, Layout},
    symbols::{
        border::{ROUNDED, Set},
        line::{VERTICAL_LEFT, VERTICAL_RIGHT},
    },
    widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget, Widget},
};

use crate::{
    misc::{buffer_ext::BufferExt, rect_ext::RectExt},
    tui::{
        app_default::{AppDefault, AppTitle},
        component::Component,
        widgets::input::Input,
    },
};

#[derive(Debug, Default)]
pub struct TextPickerWithSuggestion<P: Provider> {
    title: Option<String>,
    input: Input,
    list: ListState,
    args: (String, usize),
    items: Vec<P::Suggestion>,
    provider: P,
}

impl<P> TextPickerWithSuggestion<P>
where
    P: Provider,
{
    pub fn new(provider: P) -> Self {
        Self {
            title: Default::default(),
            input: Default::default(),
            list: ListState::default().with_selected(0.into()),
            args: (String::default(), 0),
            items: provider.suggestions("", 0),
            provider,
        }
    }

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

    pub fn with_value(self, value: impl Into<String>) -> Self {
        Self {
            input: self.input.with_value(value.into()),
            ..self
        }
    }

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

    pub fn has_suggestions(&self) -> bool {
        !self.items.is_empty()
    }

    pub fn selected_suggestion(&self) -> Option<&<P as Provider>::Suggestion> {
        self.list.selected().and_then(|idx| self.items.get(idx))
    }

    pub fn clear_suggestion_selection(&mut self) {
        self.list = ListState::default().with_selected(0.into());
    }

    pub fn apply_selected_suggestion(&mut self) {
        if let Some(suggestion) = self.list.selected().and_then(|idx| self.items.get(idx)) {
            suggestion.apply_to(&mut self.input);
        }
    }
}

impl<P> Component for TextPickerWithSuggestion<P>
where
    P: Provider,
{
    fn render(
        &mut self,
        _area: ratatui::prelude::Rect,
        buf: &mut ratatui::prelude::Buffer,
        focus_state: crate::tui::component::FocusState,
    ) {
        if self.args.0 != self.input.value() || self.args.1 != self.input.cursor() {
            self.args = (self.input.value().to_owned(), self.input.cursor());
            self.items = self
                .provider
                .suggestions(self.input.value(), self.input.cursor());
        }

        let list = List::app_default()
            .block(Block::app_default().border_set(Set {
                top_left: VERTICAL_RIGHT,
                top_right: VERTICAL_LEFT,
                ..ROUNDED
            }))
            .items(
                self.items
                    .iter()
                    .map(|suggestion| ListItem::new(suggestion.title())),
            );

        let height = list.len().saturating_add(4).min(25) as u16;
        let area = buf.area.palette(height);

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

        let input_area = {
            let mut block =
                Block::app_default().borders(Borders::LEFT | Borders::RIGHT | Borders::TOP);

            if let Some(title) = &self.title {
                block = block.app_title(title.as_str());
            }

            let input_inner = block.inner(input_area);
            Widget::render(block, area, buf);
            input_inner
        };
        self.input.render(input_area, buf, focus_state);
        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.items.len().saturating_sub(1)));
                    }
                    true
                }
                (KeyCode::Down, KeyModifiers::NONE)
                | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
                    if self.list.selected() != Some(self.items.len().saturating_sub(1)) {
                        self.list.select_next();
                    } else {
                        self.list.select_first();
                    }
                    true
                }
                _ => false,
            }
    }
}

pub trait Suggestion {
    fn title(&self) -> &str;
    fn apply_to(&self, input: &mut Input);
}

pub trait Provider {
    type Suggestion: Suggestion;
    fn suggestions(&self, query: &str, cursor: usize) -> Vec<Self::Suggestion>;
}

impl<S> Provider for fn(&str, usize) -> Vec<S>
where
    S: Suggestion,
{
    type Suggestion = S;

    fn suggestions(&self, query: &str, cursor: usize) -> Vec<Self::Suggestion> {
        self(query, cursor)
    }
}