aven 0.1.6

Local-first task manager CLI and sync server
Documentation
use anyhow::Result;

use crate::tui::app::{App, Focus};
use crate::tui::overlay::{
    HeaderMenuAction, HeaderMenuItem, HeaderMenuKind, HeaderMenuState, OrderMenuState,
    OverlayRoute, OverlayState,
};
use crate::tui::store::{TaskOrder, TaskScope, TaskScopeTarget, TaskView};

pub(crate) const FILTER_LABEL_TITLE: &str = "Filter: label";
pub(crate) const FILTER_PRIORITY_TITLE: &str = "Filter: priority";
pub(crate) const SCOPE_PROJECT_TITLE: &str = "Scope: project";
pub(crate) const SWITCH_WORKSPACE_TITLE: &str = "Switch workspace";

impl App {
    pub(super) fn begin_filter_label(&mut self) {
        self.pending_shortcut.clear();
        let mut items = self.store.label_picker_items();
        for item in &mut items {
            item.selected =
                Some(&item.value) == self.store.view_state.filter_modifiers.label.as_ref();
        }
        self.open_picker_overlay(OverlayRoute::FilterLabel, FILTER_LABEL_TITLE, items, false);
    }

    pub(super) fn begin_filter_priority(&mut self) {
        self.pending_shortcut.clear();
        let selected = self
            .store
            .view_state
            .filter_modifiers
            .priority
            .as_deref()
            .unwrap_or_default();
        let items = self.store.priority_picker_items(selected);
        self.open_picker_overlay(
            OverlayRoute::FilterPriority,
            FILTER_PRIORITY_TITLE,
            items,
            false,
        );
    }

    pub(super) async fn begin_switch_workspace(&mut self) -> Result<()> {
        self.pending_shortcut.clear();
        self.store.refresh(None).await?;
        let items = self.store.workspace_picker_items();
        self.open_picker_overlay(
            OverlayRoute::SwitchWorkspace,
            SWITCH_WORKSPACE_TITLE,
            items,
            false,
        );
        Ok(())
    }

    pub(super) fn begin_scope_project(&mut self) {
        self.pending_shortcut.clear();
        let selected = match &self.store.view_state.scope {
            TaskScope::Project(project) => project.as_str(),
            TaskScope::Workspace => "",
        };
        let items = self.store.existing_project_picker_items(selected);
        self.open_picker_overlay(
            OverlayRoute::ScopeProject,
            SCOPE_PROJECT_TITLE,
            items,
            false,
        );
    }

    pub(super) async fn show_view(&mut self, view: TaskView) -> Result<()> {
        let previous = self.store.view_state.clone();
        let selected = self.store.show_view(view).await?;
        self.push_navigation_state(previous);
        self.apply_filter_selection(selected);
        self.set_info("view updated");
        Ok(())
    }

    pub(super) async fn show_scope(&mut self, scope: TaskScopeTarget) -> Result<()> {
        let previous = self.store.view_state.clone();
        let selected = self.store.show_scope(scope).await?;
        self.push_navigation_state(previous);
        self.apply_filter_selection(selected);
        self.set_info("scope updated");
        Ok(())
    }

    pub(super) async fn show_workspace_menu(&mut self, column: u16, row: u16) -> Result<()> {
        self.pending_shortcut.clear();
        self.store.refresh(None).await?;
        let items = self
            .store
            .workspace_picker_items()
            .into_iter()
            .enumerate()
            .map(|(index, item)| HeaderMenuItem {
                key: (index + 1).to_string(),
                label: item.label,
                selected: item.selected,
                action: HeaderMenuAction::Workspace(item.value),
            })
            .collect();
        self.overlay = Some(OverlayState::header_menu(
            HeaderMenuKind::Workspace,
            column,
            row,
            items,
        ));
        Ok(())
    }

    pub(super) fn show_scope_menu(&mut self, column: u16, row: u16) {
        self.pending_shortcut.clear();
        let selected_project = match &self.store.view_state.scope {
            TaskScope::Project(project) => project.as_str(),
            TaskScope::Workspace => "",
        };
        let mut items = vec![HeaderMenuItem {
            key: "w".to_string(),
            label: "workspace".to_string(),
            selected: matches!(self.store.view_state.scope, TaskScope::Workspace),
            action: HeaderMenuAction::WorkspaceScope,
        }];
        items.extend(
            self.store
                .existing_project_picker_items(selected_project)
                .into_iter()
                .enumerate()
                .map(|(index, item)| HeaderMenuItem {
                    key: (index + 1).to_string(),
                    label: item.label,
                    selected: item.selected,
                    action: HeaderMenuAction::ProjectScope(item.value),
                }),
        );
        self.overlay = Some(OverlayState::header_menu(
            HeaderMenuKind::Scope,
            column,
            row,
            items,
        ));
    }

    pub(super) fn show_view_menu(&mut self, column: u16, row: u16) {
        self.pending_shortcut.clear();
        let selected = self.store.view_state.view;
        let items = [
            ("q", "queue", TaskView::Queue),
            ("o", "open", TaskView::Open),
            ("t", "todo", TaskView::Todo),
            ("i", "inbox", TaskView::Inbox),
            ("a", "active", TaskView::Active),
            ("b", "backlog", TaskView::Backlog),
            ("d", "done", TaskView::Done),
            ("e", "epics", TaskView::Epics),
            ("r", "recent", TaskView::RecentActions),
            ("c", "conflicts", TaskView::Conflicts),
            ("s", "search", TaskView::Search),
            ("l", "columns", TaskView::Columns),
        ]
        .into_iter()
        .map(|(key, label, view)| HeaderMenuItem {
            key: key.to_string(),
            label: label.to_string(),
            selected: selected == view,
            action: HeaderMenuAction::View(view),
        })
        .collect();
        self.overlay = Some(OverlayState::header_menu(
            HeaderMenuKind::View,
            column,
            row,
            items,
        ));
    }

    pub(super) fn show_order_menu(&mut self, column: u16, row: u16) {
        self.pending_shortcut.clear();
        self.overlay = Some(OverlayState::order_menu(
            column,
            row,
            self.store.view_state.order,
        ));
    }

    pub(super) async fn submit_header_menu(&mut self, action: HeaderMenuAction) -> Result<()> {
        self.overlay = None;
        match action {
            HeaderMenuAction::Workspace(workspace) => {
                let (message, selected) = self.store.switch_workspace(workspace).await?;
                self.clear_navigation_history();
                self.apply_filter_selection(selected);
                self.set_success(message);
                Ok(())
            }
            HeaderMenuAction::WorkspaceScope => self.show_scope(TaskScopeTarget::Workspace).await,
            HeaderMenuAction::ProjectScope(project) => {
                self.show_scope(TaskScopeTarget::Project(project)).await
            }
            HeaderMenuAction::View(view) => self.show_view(view).await,
        }
    }

    pub(super) async fn submit_header_menu_at(
        &mut self,
        state: HeaderMenuState,
        column: u16,
        row: u16,
        terminal_size: ratatui::layout::Size,
    ) -> Result<()> {
        let Some(action) = header_menu_action_at(&state, column, row, terminal_size) else {
            self.overlay = None;
            return Ok(());
        };
        self.submit_header_menu(action).await
    }

    pub(super) async fn submit_order_menu(&mut self, order: TaskOrder) -> Result<()> {
        self.overlay = None;
        self.set_sort(order).await
    }

    pub(super) async fn submit_order_menu_at(
        &mut self,
        state: OrderMenuState,
        column: u16,
        row: u16,
        terminal_size: ratatui::layout::Size,
    ) -> Result<()> {
        let Some(order) = order_menu_order_at(state, column, row, terminal_size) else {
            self.overlay = None;
            return Ok(());
        };
        self.submit_order_menu(order).await
    }

    pub(super) fn apply_filter_selection(&mut self, selected: Option<usize>) {
        self.widgets.table.select(selected);
        self.widgets.sidebar.select(self.store.sidebar_selection());
        self.prune_task_marks();
        self.focus = Focus::Tasks;
        self.overlay = None;
    }

    pub(super) async fn clear_filters(&mut self) -> Result<()> {
        let previous = self.store.view_state.clone();
        let selected = self.store.clear_filters().await?;
        self.push_navigation_state(previous);
        self.apply_filter_selection(selected);
        self.set_success("filters cleared");
        Ok(())
    }

    pub(super) async fn toggle_deleted_filter(&mut self) -> Result<()> {
        let previous = self.store.view_state.clone();
        let selected = self.store.toggle_deleted_filter().await?;
        self.push_navigation_state(previous);
        self.apply_filter_selection(selected);
        let modifiers = &self.store.view_state.filter_modifiers;
        let message = if modifiers.deleted_only {
            "showing deleted tasks only"
        } else if modifiers.include_deleted {
            "showing deleted tasks"
        } else {
            "hiding deleted tasks"
        };
        self.set_info(message);
        Ok(())
    }

    fn filter_value_or_reopen(
        &mut self,
        values: Vec<String>,
        empty_message: &str,
        reopen: fn(&mut Self),
    ) -> Option<String> {
        let Some(value) = self.require_picker_value(values, empty_message) else {
            reopen(self);
            return None;
        };
        Some(value)
    }

    pub(super) async fn submit_filter_label(&mut self, values: Vec<String>) -> Result<()> {
        let Some(label) =
            self.filter_value_or_reopen(values, "no matching label", Self::begin_filter_label)
        else {
            return Ok(());
        };
        let previous = self.store.view_state.clone();
        let selected = self.store.filter_label(label).await?;
        self.push_navigation_state(previous);
        self.apply_filter_selection(selected);
        self.set_success("label filter applied");
        Ok(())
    }

    pub(super) async fn submit_filter_priority(&mut self, values: Vec<String>) -> Result<()> {
        let Some(priority) = self.filter_value_or_reopen(
            values,
            "no matching priority",
            Self::begin_filter_priority,
        ) else {
            return Ok(());
        };
        let previous = self.store.view_state.clone();
        let selected = self.store.filter_priority(priority).await?;
        self.push_navigation_state(previous);
        self.apply_filter_selection(selected);
        self.set_success("priority filter applied");
        Ok(())
    }

    pub(super) async fn submit_scope_project(&mut self, values: Vec<String>) -> Result<()> {
        let Some(project) = self.require_picker_value(values, "no matching project") else {
            self.begin_scope_project();
            return Ok(());
        };
        self.show_scope(TaskScopeTarget::Project(project)).await
    }

    pub(super) async fn submit_switch_workspace(&mut self, values: Vec<String>) -> Result<()> {
        let Some(workspace) = self.require_picker_value(values, "no matching workspace") else {
            self.begin_switch_workspace().await?;
            return Ok(());
        };
        let (message, selected) = self.store.switch_workspace(workspace).await?;
        self.clear_navigation_history();
        self.apply_filter_selection(selected);
        self.set_success(message);
        Ok(())
    }
}

fn header_menu_action_at(
    state: &HeaderMenuState,
    column: u16,
    row: u16,
    terminal_size: ratatui::layout::Size,
) -> Option<HeaderMenuAction> {
    let area = state.area(terminal_size.width, terminal_size.height);
    if column < area.x
        || column >= area.x.saturating_add(area.width)
        || row < area.y
        || row >= area.y.saturating_add(area.height)
    {
        return None;
    }
    let item_index = row.saturating_sub(area.y).checked_sub(1)? as usize;
    state.items.get(item_index).map(|item| item.action.clone())
}

fn order_menu_order_at(
    state: OrderMenuState,
    column: u16,
    row: u16,
    terminal_size: ratatui::layout::Size,
) -> Option<TaskOrder> {
    let area = state.area(terminal_size.width, terminal_size.height);
    if column < area.x
        || column >= area.x.saturating_add(area.width)
        || row < area.y
        || row >= area.y.saturating_add(area.height)
    {
        return None;
    }
    match row.saturating_sub(area.y) {
        1 => Some(TaskOrder::Created),
        2 => Some(TaskOrder::Updated),
        3 => Some(TaskOrder::Priority),
        4 => Some(TaskOrder::Project),
        5 => Some(TaskOrder::Title),
        _ => None,
    }
}