tano-tui 0.1.0

Tano terminal user interface
use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::ListItem,
};

use crate::utils::layout_cache::get_list_area;

const SCROLL_OFFSET: usize = 5;

#[derive(Debug, Clone)]
pub struct ListState<T> {
    pub selected_index: Option<usize>,
    pub items: Vec<T>,
    pub offset: usize,
}

impl<T> ListState<T> {
    pub fn new(items: Vec<T>, selected_index: usize) -> Self {
        let selected_index = if selected_index < items.len() {
            Some(selected_index)
        } else {
            None
        };

        Self {
            selected_index,
            items,
            offset: 0,
        }
    }

    fn align_offset(&mut self) {
        let height = get_list_area().height as usize;

        if height == 0 || self.items.is_empty() {
            return;
        }

        let selected = self.selected_index.unwrap_or(0);
        let effective_margin = SCROLL_OFFSET.min(height.saturating_sub(1) / 2);

        if selected < self.offset + effective_margin {
            self.offset = selected.saturating_sub(effective_margin);
        } else if selected + effective_margin >= self.offset + height {
            self.offset = (selected + effective_margin + 1).saturating_sub(height);
        }

        let max_offset = self.items.len().saturating_sub(height);
        self.offset = self.offset.min(max_offset);
    }

    pub fn next(&mut self) {
        if self.items.is_empty() {
            return;
        }

        let selected_index = self.selected_index.unwrap_or(0);
        self.selected_index = Some((selected_index + 1) % self.items.len());

        self.align_offset();
    }

    pub fn previous(&mut self) {
        if self.items.is_empty() {
            return;
        }

        let selected_index = self.selected_index.unwrap_or(self.items.len());

        if selected_index == 0 {
            self.selected_index = Some(self.items.len() - 1);
        } else {
            self.selected_index = Some(selected_index - 1);
        }

        self.align_offset();
    }

    pub fn jump_top(&mut self) {
        if self.items.is_empty() {
            return;
        }

        self.selected_index = Some(0);
        self.align_offset();
    }

    pub fn jump_bottom(&mut self) {
        if self.items.is_empty() {
            return;
        }
        self.selected_index = Some(self.items.len() - 1);
        self.align_offset();
    }

    pub fn scroll_up(&mut self) {
        if self.items.is_empty() {
            return;
        }

        let selected = self.selected_index.unwrap_or(0);
        self.selected_index = Some(selected.saturating_sub(1));
        self.align_offset();
    }

    pub fn scroll_down(&mut self) {
        if self.items.is_empty() {
            return;
        }

        let selected = self.selected_index.unwrap_or(0);
        self.selected_index = Some((selected + 1).min(self.items.len().saturating_sub(1)));
        self.align_offset();
    }

    pub fn scroll_percent(&mut self, percent: i32) {
        let height = get_list_area().height as i32;
        if height == 0 || self.items.is_empty() {
            return;
        }

        let jump_amount = (height * percent) / 100;
        let current_index = self.selected_index.unwrap_or(0) as isize;
        let new_index =
            (current_index + jump_amount as isize).clamp(0, (self.items.len() - 1) as isize);

        self.selected_index = Some(new_index as usize);

        self.align_offset();
    }

    pub fn selected(&self) -> Option<&T> {
        Some(&self.items[self.selected_index?])
    }

    pub fn displayed(&self) -> impl Iterator<Item = (bool, &T)> + '_ {
        let height = get_list_area().height as usize;

        let max_offset = self.items.len().saturating_sub(height);
        let start = self.offset.min(max_offset);
        let end = (start + height).min(self.items.len());

        let selected_idx = self.selected_index;

        self.items[start..end]
            .iter()
            .enumerate()
            .map(move |(index, item)| {
                let actual_index = start + index;
                let is_selected = selected_idx == Some(actual_index);

                (is_selected, item)
            })
    }

    pub fn to_list_items<F>(&self, item_to_text: F) -> Vec<ListItem<'_>>
    where
        F: Fn(&T) -> String,
    {
        let width = get_list_area().width as usize;
        let highlight_color = Color::Rgb(137, 180, 250);
        let text_color = Color::Rgb(30, 30, 46);

        self.displayed()
            .map(|(is_selected, item)| {
                let text = item_to_text(item);
                if !is_selected {
                    return ListItem::new(format!("  {}", text));
                }

                let text = format!(" {} ", text);
                let padding_len = width.saturating_sub(text.chars().count() + 2);
                let padded_text = format!("{}{}", text, " ".repeat(padding_len));

                let line = Line::from(vec![
                    Span::styled("", Style::default().fg(highlight_color)),
                    Span::styled(
                        padded_text,
                        Style::default()
                            .bg(highlight_color)
                            .fg(text_color)
                            .add_modifier(Modifier::BOLD),
                    ),
                    Span::styled("", Style::default().fg(highlight_color)),
                ]);

                ListItem::new(line)
            })
            .collect()
    }
}