a3s-tui 0.1.4

TEA (The Elm Architecture) framework for terminal user interfaces
Documentation
use crate::element::{BoxElement, Element, FlexDirection, TextElement};
use crate::event::KeyEvent;
use crate::style::{truncate_visible, visible_len, Color, Style};
use crossterm::event::KeyCode;

pub struct MultiSelect {
    items: Vec<String>,
    cursor: usize,
    checked: Vec<bool>,
    focused: bool,
    number_shortcuts: bool,
}

#[derive(Debug, Clone)]
pub enum MultiSelectMsg {
    Toggle(usize),
    Submit(Vec<usize>),
}

impl MultiSelect {
    pub fn new(items: Vec<impl Into<String>>) -> Self {
        let items: Vec<String> = items.into_iter().map(|i| i.into()).collect();
        let checked = vec![false; items.len()];
        Self {
            items,
            cursor: 0,
            checked,
            focused: true,
            number_shortcuts: false,
        }
    }

    pub fn focus(&mut self) {
        self.focused = true;
    }
    pub fn blur(&mut self) {
        self.focused = false;
    }

    pub fn selected_indices(&self) -> Vec<usize> {
        self.checked
            .iter()
            .enumerate()
            .filter_map(|(i, &b)| if b { Some(i) } else { None })
            .collect()
    }

    pub fn with_checked(mut self, checked: Vec<bool>) -> Self {
        self.checked = self
            .items
            .iter()
            .enumerate()
            .map(|(idx, _)| checked.get(idx).copied().unwrap_or(false))
            .collect();
        self
    }

    pub fn with_number_shortcuts(mut self) -> Self {
        self.number_shortcuts = true;
        self
    }

    pub fn set_checked(&mut self, index: usize, checked: bool) {
        if let Some(slot) = self.checked.get_mut(index) {
            *slot = checked;
        }
    }

    pub fn is_checked(&self, index: usize) -> bool {
        self.checked.get(index).copied().unwrap_or(false)
    }

    pub fn checked(&self) -> &[bool] {
        &self.checked
    }

    pub fn cursor(&self) -> usize {
        self.cursor
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<MultiSelectMsg> {
        if !self.focused {
            return None;
        }
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                self.cursor = self.cursor.saturating_sub(1);
                None
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if self.cursor + 1 < self.items.len() {
                    self.cursor += 1;
                }
                None
            }
            KeyCode::Char(' ') => {
                self.checked[self.cursor] = !self.checked[self.cursor];
                Some(MultiSelectMsg::Toggle(self.cursor))
            }
            KeyCode::Char(c) if self.number_shortcuts => {
                let idx = number_shortcut_index(c)?;
                if idx < self.items.len() {
                    self.cursor = idx;
                    self.checked[idx] = !self.checked[idx];
                    Some(MultiSelectMsg::Toggle(idx))
                } else {
                    None
                }
            }
            KeyCode::Enter => Some(MultiSelectMsg::Submit(self.selected_indices())),
            _ => None,
        }
    }

    pub fn view(&self, width: u16, height: usize) -> String {
        let width = width as usize;
        if width == 0 || height == 0 {
            return String::new();
        }

        let start = if self.items.len() <= height {
            0
        } else {
            self.cursor
                .saturating_sub(height - 1)
                .min(self.items.len() - height)
        };

        self.items
            .iter()
            .enumerate()
            .skip(start)
            .take(height)
            .map(|(idx, item)| {
                let cursor = if idx == self.cursor { ">" } else { " " };
                let check = if self.checked[idx] { "[x]" } else { "[ ]" };
                let raw = if self.number_shortcuts {
                    match number_shortcut_label(idx) {
                        Some(label) => {
                            pad_or_truncate(&format!("{cursor} {label} {check} {item}"), width)
                        }
                        None => pad_or_truncate(&format!("{cursor}   {check} {item}"), width),
                    }
                } else {
                    pad_or_truncate(&format!("{cursor} {check} {item}"), width)
                };
                if idx == self.cursor && self.focused {
                    Style::new().fg(Color::Cyan).bold().render(&raw)
                } else {
                    raw
                }
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    pub fn element<Msg>(&self) -> Element<Msg> {
        let children: Vec<Element<Msg>> = self
            .items
            .iter()
            .enumerate()
            .map(|(i, item)| {
                let cursor_marker = if i == self.cursor { "â–¸" } else { " " };
                let check = if self.checked[i] { "[x]" } else { "[ ]" };
                let text = if self.number_shortcuts {
                    match number_shortcut_label(i) {
                        Some(label) => format!("{cursor_marker} {label} {check} {item}"),
                        None => format!("{cursor_marker}   {check} {item}"),
                    }
                } else {
                    format!("{} {} {}", cursor_marker, check, item)
                };
                if i == self.cursor && self.focused {
                    Element::Text(TextElement::new(text).bold().fg(Color::Cyan))
                } else {
                    Element::Text(TextElement::new(text))
                }
            })
            .collect();

        Element::Box(
            BoxElement::new()
                .direction(FlexDirection::Column)
                .children(children),
        )
    }
}

fn number_shortcut_index(c: char) -> Option<usize> {
    match c {
        '1'..='9' => Some((c as u8 - b'1') as usize),
        '0' => Some(9),
        _ => None,
    }
}

fn number_shortcut_label(idx: usize) -> Option<char> {
    match idx {
        0..=8 => Some((b'1' + idx as u8) as char),
        9 => Some('0'),
        _ => None,
    }
}

fn pad_or_truncate(value: &str, width: usize) -> String {
    let truncated = truncate_to_width(value, width);
    let len = visible_len(&truncated);
    if len >= width {
        truncated
    } else {
        format!("{truncated}{}", " ".repeat(width - len))
    }
}

fn truncate_to_width(value: &str, width: usize) -> String {
    truncate_visible(value, width)
}

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

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

    #[test]
    fn initial_state() {
        let ms = MultiSelect::new(vec!["a", "b", "c"]);
        assert!(ms.selected_indices().is_empty());
    }

    #[test]
    fn toggle_with_space() {
        let mut ms = MultiSelect::new(vec!["a", "b", "c"]);
        ms.handle_key(&key(KeyCode::Char(' ')));
        assert_eq!(ms.selected_indices(), vec![0]);
        ms.handle_key(&key(KeyCode::Down));
        ms.handle_key(&key(KeyCode::Char(' ')));
        assert_eq!(ms.selected_indices(), vec![0, 1]);
    }

    #[test]
    fn number_shortcuts_are_opt_in() {
        let mut ms = MultiSelect::new(vec!["one", "two", "three"]);

        let msg = ms.handle_key(&key(KeyCode::Char('2')));

        assert!(msg.is_none());
        assert_eq!(ms.cursor(), 0);
        assert!(ms.selected_indices().is_empty());
    }

    #[test]
    fn number_shortcuts_toggle_by_visible_index() {
        let mut ms = MultiSelect::new(vec![
            "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
        ])
        .with_number_shortcuts();

        let msg = ms.handle_key(&key(KeyCode::Char('3')));
        assert!(matches!(msg, Some(MultiSelectMsg::Toggle(2))));
        assert_eq!(ms.cursor(), 2);
        assert_eq!(ms.selected_indices(), vec![2]);

        let msg = ms.handle_key(&key(KeyCode::Char('0')));
        assert!(matches!(msg, Some(MultiSelectMsg::Toggle(9))));
        assert_eq!(ms.cursor(), 9);
        assert_eq!(ms.selected_indices(), vec![2, 9]);
    }

    #[test]
    fn untoggle() {
        let mut ms = MultiSelect::new(vec!["a", "b"]);
        ms.handle_key(&key(KeyCode::Char(' ')));
        ms.handle_key(&key(KeyCode::Char(' ')));
        assert!(ms.selected_indices().is_empty());
    }

    #[test]
    fn submit_returns_selected() {
        let mut ms = MultiSelect::new(vec!["x", "y", "z"]);
        ms.handle_key(&key(KeyCode::Char(' ')));
        ms.handle_key(&key(KeyCode::Down));
        ms.handle_key(&key(KeyCode::Down));
        ms.handle_key(&key(KeyCode::Char(' ')));
        let msg = ms.handle_key(&key(KeyCode::Enter));
        assert!(matches!(msg, Some(MultiSelectMsg::Submit(v)) if v == vec![0, 2]));
    }

    #[test]
    fn initializes_checked_state() {
        let ms = MultiSelect::new(vec!["a", "b", "c"]).with_checked(vec![true, false, true]);

        assert_eq!(ms.selected_indices(), vec![0, 2]);
        assert!(ms.is_checked(0));
        assert!(!ms.is_checked(9));
    }

    #[test]
    fn view_renders_checks_and_cursor() {
        let ms = MultiSelect::new(vec!["alpha", "beta"]).with_checked(vec![true, false]);
        let plain = crate::style::strip_ansi(&ms.view(20, 5));

        assert!(plain.contains("> [x] alpha"));
        assert!(plain.contains("  [ ] beta"));
    }

    #[test]
    fn view_renders_number_shortcuts_when_enabled() {
        let ms = MultiSelect::new(vec!["status", "name", "cid"])
            .with_checked(vec![true, false, true])
            .with_number_shortcuts();
        let plain = crate::style::strip_ansi(&ms.view(24, 5));

        assert!(plain.contains("> 1 [x] status"));
        assert!(plain.contains("  2 [ ] name"));
        assert!(plain.contains("  3 [x] cid"));
    }

    #[test]
    fn view_truncates_to_width() {
        let ms = MultiSelect::new(vec!["a very long option name"]);

        for line in crate::style::strip_ansi(&ms.view(10, 5)).lines() {
            assert!(visible_len(line) <= 10, "{line:?}");
        }
    }

    #[test]
    fn view_scrolls_to_cursor() {
        let mut ms = MultiSelect::new(vec!["one", "two", "three", "four"]);
        ms.handle_key(&KeyEvent {
            code: KeyCode::Down,
            modifiers: crossterm::event::KeyModifiers::empty(),
        });
        ms.handle_key(&KeyEvent {
            code: KeyCode::Down,
            modifiers: crossterm::event::KeyModifiers::empty(),
        });
        ms.handle_key(&KeyEvent {
            code: KeyCode::Down,
            modifiers: crossterm::event::KeyModifiers::empty(),
        });

        let plain = crate::style::strip_ansi(&ms.view(16, 2));

        assert!(!plain.contains("one"));
        assert!(plain.contains("> [ ] four"));
    }

    #[test]
    fn navigation_bounds() {
        let mut ms = MultiSelect::new(vec!["a", "b"]);
        ms.handle_key(&key(KeyCode::Up));
        assert_eq!(ms.cursor, 0);
        ms.handle_key(&key(KeyCode::Down));
        ms.handle_key(&key(KeyCode::Down));
        assert_eq!(ms.cursor, 1);
    }

    #[test]
    fn blur_ignores_input() {
        let mut ms = MultiSelect::new(vec!["a"]);
        ms.blur();
        ms.handle_key(&key(KeyCode::Char(' ')));
        assert!(ms.selected_indices().is_empty());
    }
}