qqq-cli 0.7.0

Local-first task queue for coding agent sessions
use super::render::{PopupKind, PopupRow};
use crate::views::SavedView;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

pub(super) enum Action {
    Apply(Option<SavedView>),
    Cancel,
}

pub(super) struct Picker {
    views: Vec<SavedView>,
    selected: usize,
    preview_top: usize,
    error: String,
}

impl Picker {
    pub fn new(views: Vec<SavedView>, active: Option<&str>) -> Self {
        let selected = active
            .and_then(|name| views.iter().position(|view| view.name == name))
            .map_or(0, |index| index + 1);
        Self {
            views,
            selected,
            preview_top: 0,
            error: String::new(),
        }
    }
    pub fn set_error(&mut self, error: String) {
        self.error = error;
        self.preview_top = 0;
    }
    pub fn key(&mut self, key: KeyEvent) -> Option<Action> {
        if key.code == KeyCode::Esc
            || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'))
        {
            return Some(Action::Cancel);
        }
        if key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
        {
            return None;
        }
        let previous = self.selected;
        match key.code {
            KeyCode::Up => self.selected = self.selected.saturating_sub(1),
            KeyCode::Down => self.selected = (self.selected + 1).min(self.views.len()),
            KeyCode::Home => self.selected = 0,
            KeyCode::End => self.selected = self.views.len(),
            KeyCode::PageUp => self.preview_top = self.preview_top.saturating_sub(5),
            KeyCode::PageDown => self.preview_top = self.preview_top.saturating_add(5),
            KeyCode::Enter => {
                return Some(Action::Apply(
                    self.selected
                        .checked_sub(1)
                        .map(|index| self.views[index].clone()),
                ));
            }
            _ => (),
        }
        if previous != self.selected {
            self.preview_top = 0;
            self.error.clear();
        }
        None
    }
    pub fn rows(&mut self, width: usize, height: usize) -> Vec<PopupRow> {
        let height = height.max(3);
        let count = self.views.len() + 1;
        let visible = (height / 3).max(1).min(count);
        let start = self
            .selected
            .saturating_sub(visible - 1)
            .min(count - visible);
        let mut rows = vec![PopupRow::new("Choose view", PopupKind::Heading)];
        for index in start..start + visible {
            let name = if index == 0 {
                "All tasks (no view)"
            } else {
                &self.views[index - 1].name
            };
            rows.push(PopupRow::new(
                name,
                if index == self.selected {
                    PopupKind::SelectedAction
                } else {
                    PopupKind::Action
                },
            ));
        }
        let preview = if !self.error.is_empty() {
            vec![self.error.clone()]
        } else if self.selected == 0 {
            vec!["Use startup selectors and visibility.".to_owned()]
        } else {
            crate::views::describe(&self.views[self.selected - 1])
        };
        let preview: Vec<_> = preview
            .iter()
            .flat_map(|line| super::wrap_modal(line, width))
            .collect();
        let available = height.saturating_sub(rows.len() + 1);
        self.preview_top = self
            .preview_top
            .min(preview.len().saturating_sub(available.max(1)));
        let kind = if self.error.is_empty() {
            PopupKind::Body
        } else {
            PopupKind::Error
        };
        rows.extend(
            preview
                .into_iter()
                .skip(self.preview_top)
                .take(available)
                .map(|line| PopupRow::new(line, kind)),
        );
        rows.push(PopupRow::new(
            if width >= 44 {
                "Up/Down pick Enter apply PgUp/PgDn details Esc"
            } else {
                "↑↓ Enter Esc Pg↑↓"
            },
            PopupKind::Hint,
        ));
        rows
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }
    fn definitions() -> Vec<SavedView> {
        (0..30)
            .map(|index| SavedView {
                name: format!("View {index:02}"),
                criteria: crate::selection::Selectors {
                    tags: vec!["界 面".into()],
                    filter: Some("priority > 5".into()),
                    ..Default::default()
                },
                include_archived: false,
                max_completed: None,
            })
            .collect()
    }
    #[test]
    fn selection_boundaries_and_cancel_stay_local() {
        let mut picker = Picker::new(Vec::new(), None);
        picker.key(key(KeyCode::Down));
        assert!(matches!(
            picker.key(key(KeyCode::Enter)),
            Some(Action::Apply(None))
        ));
        assert!(matches!(
            picker.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            Some(Action::Cancel)
        ));
        let mut picker = Picker::new(definitions(), Some("View 29"));
        picker.key(key(KeyCode::Down));
        assert!(
            matches!(picker.key(key(KeyCode::Enter)), Some(Action::Apply(Some(view))) if view.name == "View 29")
        );
        picker.key(key(KeyCode::Home));
        assert!(matches!(
            picker.key(key(KeyCode::Enter)),
            Some(Action::Apply(None))
        ));
    }
    #[test]
    fn narrow_rows_keep_selected_and_preview_scroll_bounded() {
        let mut picker = Picker::new(definitions(), Some("View 29"));
        let rows = picker.rows(12, 7);
        assert!(rows.len() <= 7);
        assert!(
            rows.iter()
                .any(|row| row.text == "View 29" && row.kind == PopupKind::SelectedAction)
        );
        picker.key(key(KeyCode::PageDown));
        assert!(
            picker
                .rows(12, 7)
                .iter()
                .any(|row| row.text.contains("Filter") || row.text.contains("priority"))
        );
        picker.set_error("Load failed".into());
        assert!(
            picker
                .rows(20, 12)
                .iter()
                .any(|row| row.kind == PopupKind::Error)
        );
        picker.key(key(KeyCode::Up));
        assert!(picker.error.is_empty());
    }
}