tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use std::collections::BTreeSet;

use crossterm::event::KeyCode;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerItem {
    pub id: String,
    pub label: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectionModel {
    Single(Option<String>),
    Multi(BTreeSet<String>),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerMode {
    Idle,
    Filtering,
    InlineCreate,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickerEvent {
    None,
    Selected(Vec<String>),
    Created(String),
    Cancelled,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DropdownState {
    pub options: Vec<PickerItem>,
    pub selection: SelectionModel,
    pub filter: String,
    pub cursor: usize,
    pub mode: PickerMode,
    pub create_buffer: String,
}

impl DropdownState {
    pub fn single(options: Vec<PickerItem>) -> Self {
        Self {
            options,
            selection: SelectionModel::Single(None),
            filter: String::new(),
            cursor: 0,
            mode: PickerMode::Idle,
            create_buffer: String::new(),
        }
    }

    pub fn multi(options: Vec<PickerItem>) -> Self {
        Self {
            options,
            selection: SelectionModel::Multi(BTreeSet::new()),
            filter: String::new(),
            cursor: 0,
            mode: PickerMode::Idle,
            create_buffer: String::new(),
        }
    }

    pub fn visible_indices(&self) -> Vec<usize> {
        let q = self.filter.trim().to_lowercase();
        self.options
            .iter()
            .enumerate()
            .filter(|(_, item)| q.is_empty() || item.label.to_lowercase().contains(&q))
            .map(|(idx, _)| idx)
            .collect()
    }

    /// Seed a single-select dropdown on `value`: record it as the selection and put
    /// the cursor on its option. A no-op when `value` is empty or names no option, so
    /// a caller can seed from a criterion that is unset or stale.
    pub fn seed_single(&mut self, value: &str) {
        if value.is_empty() {
            return;
        }
        let Some(index) = self.options.iter().position(|option| option.id == value) else {
            return;
        };
        self.selection = SelectionModel::Single(Some(value.to_string()));
        if let Some(position) = self
            .visible_indices()
            .iter()
            .position(|visible| *visible == index)
        {
            self.cursor = position;
        }
    }

    fn selected_ids(&self) -> Vec<String> {
        match &self.selection {
            SelectionModel::Single(id) => id.iter().cloned().collect(),
            SelectionModel::Multi(ids) => ids.iter().cloned().collect(),
        }
    }

    fn current_option(&self) -> Option<&PickerItem> {
        let visible = self.visible_indices();
        let idx = *visible.get(self.cursor)?;
        self.options.get(idx)
    }

    fn move_up(&mut self) {
        let total = self.visible_indices().len();
        if total == 0 {
            self.cursor = 0;
        } else if self.cursor == 0 {
            self.cursor = total - 1;
        } else {
            self.cursor -= 1;
        }
    }

    fn move_down(&mut self) {
        let total = self.visible_indices().len();
        if total == 0 {
            self.cursor = 0;
        } else {
            self.cursor = (self.cursor + 1) % total;
        }
    }

    fn clamp_cursor(&mut self) {
        let total = self.visible_indices().len();
        if total == 0 {
            self.cursor = 0;
        } else if self.cursor >= total {
            self.cursor = total - 1;
        }
    }

    fn toggle_current(&mut self) {
        let current = self.current_option().map(|item| item.id.clone());
        let Some(current) = current else {
            return;
        };
        match &mut self.selection {
            SelectionModel::Single(selected) => *selected = Some(current),
            SelectionModel::Multi(selected) => {
                if !selected.insert(current.clone()) {
                    selected.remove(&current);
                }
            }
        }
    }

    pub fn handle_key(&mut self, key: KeyCode) -> PickerEvent {
        match self.mode {
            PickerMode::InlineCreate => match key {
                KeyCode::Esc => {
                    self.mode = PickerMode::Idle;
                    self.create_buffer.clear();
                    PickerEvent::Cancelled
                }
                KeyCode::Enter => {
                    let created = self.create_buffer.trim().to_string();
                    self.mode = PickerMode::Idle;
                    self.create_buffer.clear();
                    if created.is_empty() {
                        PickerEvent::Cancelled
                    } else {
                        PickerEvent::Created(created)
                    }
                }
                KeyCode::Backspace => {
                    self.create_buffer.pop();
                    PickerEvent::None
                }
                KeyCode::Char(ch) => {
                    self.create_buffer.push(ch);
                    PickerEvent::None
                }
                _ => PickerEvent::None,
            },
            PickerMode::Idle | PickerMode::Filtering => match key {
                KeyCode::Up => {
                    self.move_up();
                    PickerEvent::None
                }
                KeyCode::Down => {
                    self.move_down();
                    PickerEvent::None
                }
                KeyCode::Enter => {
                    self.toggle_current();
                    PickerEvent::Selected(self.selected_ids())
                }
                KeyCode::Esc => {
                    self.mode = PickerMode::Idle;
                    self.filter.clear();
                    self.cursor = 0;
                    PickerEvent::Cancelled
                }
                KeyCode::Char('+') | KeyCode::Insert => {
                    self.mode = PickerMode::InlineCreate;
                    self.create_buffer.clear();
                    PickerEvent::None
                }
                KeyCode::Backspace => {
                    self.mode = PickerMode::Filtering;
                    self.filter.pop();
                    self.clamp_cursor();
                    PickerEvent::None
                }
                KeyCode::Char(ch) => {
                    self.mode = PickerMode::Filtering;
                    self.filter.push(ch);
                    self.clamp_cursor();
                    PickerEvent::None
                }
                _ => PickerEvent::None,
            },
        }
    }
}

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

    fn options() -> Vec<PickerItem> {
        vec![
            PickerItem {
                id: "a".into(),
                label: "Alpha".into(),
            },
            PickerItem {
                id: "b".into(),
                label: "Beta".into(),
            },
            PickerItem {
                id: "g".into(),
                label: "Gamma".into(),
            },
        ]
    }

    #[test]
    fn navigation_wraps() {
        let mut state = DropdownState::single(options());
        state.handle_key(KeyCode::Up);
        assert_eq!(state.cursor, 2);
        state.handle_key(KeyCode::Down);
        assert_eq!(state.cursor, 0);
    }

    #[test]
    fn typeahead_filters_results() {
        let mut state = DropdownState::single(options());
        state.handle_key(KeyCode::Char('g'));
        assert_eq!(state.visible_indices(), vec![2]);
    }

    #[test]
    fn multi_mode_toggles_selection() {
        let mut state = DropdownState::multi(options());
        let first = state.handle_key(KeyCode::Enter);
        assert_eq!(first, PickerEvent::Selected(vec!["a".into()]));
        let second = state.handle_key(KeyCode::Enter);
        assert_eq!(second, PickerEvent::Selected(Vec::new()));
    }

    #[test]
    fn inline_create_emits_created_event() {
        let mut state = DropdownState::single(options());
        state.handle_key(KeyCode::Char('+'));
        state.handle_key(KeyCode::Char('N'));
        state.handle_key(KeyCode::Char('e'));
        state.handle_key(KeyCode::Char('w'));
        let event = state.handle_key(KeyCode::Enter);
        assert_eq!(event, PickerEvent::Created("New".into()));
    }
}