a3s-tui 0.1.0

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

pub struct MultiSelect {
    items: Vec<String>,
    cursor: usize,
    checked: Vec<bool>,
    focused: 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,
        }
    }

    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 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::Enter => {
                Some(MultiSelectMsg::Submit(self.selected_indices()))
            }
            _ => None,
        }
    }

    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 = 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),
        )
    }
}

#[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 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 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());
    }
}