aven 0.1.6

Local-first task manager CLI and sync server
Documentation
use ratatui::layout::Rect;
use ratatui::widgets::TableState;

use crate::tui::store::TuiStore;

use super::view_model::{TaskListRow, TaskListView, task_list_scroll, task_list_visible_rows};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TaskListHit {
    pub(crate) task_index: usize,
    pub(crate) task_id: String,
    pub(crate) viewport_row: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct TaskListHitCandidate {
    pub(super) task_index: usize,
    pub(super) viewport_row: u16,
}

pub(super) fn task_list_hit_in_view(
    view: &TaskListView,
    table_state: &TableState,
    table_area: Rect,
    column: u16,
    row: u16,
) -> Option<TaskListHitCandidate> {
    if column < table_area.x || column >= table_area.x.saturating_add(table_area.width) {
        return None;
    }
    if row <= table_area.y || row >= table_area.y.saturating_add(table_area.height) {
        return None;
    }
    let viewport_rows = table_area.height.saturating_sub(1) as usize;
    if view.rows.len() > viewport_rows
        && column
            == table_area
                .x
                .saturating_add(table_area.width)
                .saturating_sub(1)
    {
        return None;
    }
    if viewport_rows == 0 {
        return None;
    }
    let scroll = task_list_scroll(
        table_state.offset(),
        table_state
            .selected()
            .map(|selected| view.visual_row(selected))
            .unwrap_or(0),
        view,
        viewport_rows,
    );

    let visual_row = row - table_area.y - 1;
    let visual_row = usize::from(visual_row);
    if visual_row >= viewport_rows {
        return None;
    }

    let viewport_rows = task_list_visible_rows(view, scroll, viewport_rows);
    let (_, row) = *viewport_rows.get(visual_row)?;
    let viewport_row = u16::try_from(visual_row).ok()?;
    match row {
        TaskListRow::Task { task_index } | TaskListRow::EpicChild { task_index, .. } => {
            Some(TaskListHitCandidate {
                task_index: *task_index,
                viewport_row,
            })
        }
        TaskListRow::Group(_) => None,
    }
}

pub(super) fn task_list_hit(
    store: &TuiStore,
    candidate: TaskListHitCandidate,
) -> Option<TaskListHit> {
    let task_id = store
        .tasks
        .get(candidate.task_index)
        .map(|item| item.task.id.clone())?;
    Some(TaskListHit {
        task_index: candidate.task_index,
        task_id,
        viewport_row: candidate.viewport_row,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::choices::{TaskPriority, TaskStatus};
    use crate::queue::QueueBand;
    use crate::tui::store::TaskListRenderMode;
    use std::collections::BTreeSet;

    fn task_item(title: &str) -> TaskListItem {
        TaskListItem {
            task: crate::types::Task {
                id: "task-1".to_string(),
                workspace_id: "workspace-1".to_string(),
                title: title.to_string(),
                description: String::new(),
                project_id: "project-id".to_string(),
                project_key: "app".to_string(),
                project_prefix: "APP".to_string(),
                status: TaskStatus::Todo,
                priority: TaskPriority::None,
                created_at: "2026-06-20T00:00:00Z".to_string(),
                updated_at: "2026-06-20T00:00:00Z".to_string(),
                queue_activity_at: "2026-06-20T00:00:00Z".to_string(),
                deleted: false,
                is_epic: false,
            },
            display_ref: "APP-1".to_string(),
            labels: Vec::new(),
            notes: Vec::new(),
            has_conflict: false,
            unresolved_blocker_count: 0,
            dependent_count: 0,
            depends_on: Vec::new(),
            blocks: Vec::new(),
            epic_children: Vec::new(),
            epic_parent: None,
            queue: Default::default(),
        }
    }

    fn task_item_with(title: &str, status: &str, band: QueueBand) -> TaskListItem {
        let mut item = task_item(title);
        item.task.title = title.to_string();
        item.task.status = TaskStatus::parse(status).expect("valid status");
        item.queue.band = band;
        item
    }

    fn task_id(task: &mut TaskListItem, id: &str) {
        task.task.id = id.to_string();
    }

    use crate::query::TaskListItem;

    #[test]
    fn task_at_position_skips_queue_group_rows() {
        let mut tasks = vec![
            task_item_with("todo high", "todo", QueueBand::Focus),
            task_item_with("todo medium", "todo", QueueBand::Focus),
            task_item_with("inbox", "inbox", QueueBand::Triage),
        ];
        task_id(&mut tasks[0], "task-1");
        task_id(&mut tasks[1], "task-2");
        task_id(&mut tasks[2], "task-3");
        let view = TaskListView::from_tasks(TaskListRenderMode::Queue, &tasks, &BTreeSet::new());
        let table_area = Rect::new(0, 0, 80, 10);
        let table_state = TableState::default();

        let header_hit = task_list_hit_in_view(
            &view,
            &table_state,
            table_area,
            table_area.x + 1,
            table_area.y + 1,
        );
        assert!(header_hit.is_none());

        let first_task = task_list_hit_in_view(
            &view,
            &table_state,
            table_area,
            table_area.x + 1,
            table_area.y + 2,
        )
        .unwrap();
        assert_eq!(first_task.task_index, 0);
        assert_eq!(first_task.viewport_row, 1);
    }

    #[test]
    fn task_at_position_respects_scroll_position() {
        let mut tasks = Vec::new();
        for index in 0..20 {
            let mut item = task_item(&format!("task {index}"));
            task_id(&mut item, &format!("task-{index:02}"));
            tasks.push(item);
        }
        let view = TaskListView::from_tasks(TaskListRenderMode::Flat, &tasks, &BTreeSet::new());
        let mut table_state = TableState::default();
        table_state.select(Some(10));

        let hit = task_list_hit_in_view(&view, &table_state, Rect::new(0, 0, 80, 5), 1, 4).unwrap();
        assert_eq!(hit.task_index, 10);
        assert_eq!(hit.viewport_row, 3);
    }

    #[test]
    fn task_at_position_ignores_scrollbar_column() {
        let tasks = (0..20)
            .map(|index| task_item(&format!("task {index}")))
            .collect::<Vec<_>>();
        let view = TaskListView::from_tasks(TaskListRenderMode::Flat, &tasks, &BTreeSet::new());
        let mut table_state = TableState::default();
        table_state.select(Some(10));

        let hit = task_list_hit_in_view(&view, &table_state, Rect::new(0, 0, 80, 5), 79, 4);

        assert!(hit.is_none());
    }
}