tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl App {
    /// The cycles the F3 mode lists, in the order `Planner` knows them.
    ///
    /// Every name comes through `Planner` — the same read path the planning
    /// surface and the CLI use — and the implicit `Default` cycle is always
    /// present even when no file backs it, so a fresh project shows the cycle
    /// its new issues join. Progress comes from [`Planner::cycle_progress`],
    /// which counts by cycle membership and the board's completion rule, so the
    /// mode cannot disagree with `tix cycle list`.
    pub(in crate::app) fn cycle_entries(&self) -> Vec<crate::ui::cycle::CycleEntry> {
        let root = self.project_root();
        let planner = Planner::from_config(self.store.config.clone(), root);
        let mut names = planner.cycle_names();
        if !names
            .iter()
            .any(|name| name == crate::cycle_store::DEFAULT_CYCLE_NAME)
        {
            names.push(crate::cycle_store::DEFAULT_CYCLE_NAME.to_string());
        }
        names
            .into_iter()
            .map(|name| {
                let cycle = planner.cycle(&name);
                crate::ui::cycle::CycleEntry {
                    progress: planner.cycle_progress(&self.store, &name),
                    description: cycle
                        .and_then(|c| c.description.clone())
                        .unwrap_or_default(),
                    start_date: cycle.and_then(|c| c.start_date.clone()),
                    due_date: cycle.and_then(|c| c.due_date.clone()),
                    name,
                }
            })
            .collect()
    }

    /// The mode's rows grouped current / future / archived on today's date.
    pub(in crate::app) fn cycle_groups(&self) -> Vec<crate::ui::cycle::CycleGrouping> {
        crate::ui::cycle::group_entries(self.cycle_entries(), &today())
    }

    /// The name of the cycle the rail should mark active: the F3 Cycle mode's
    /// cursor target, or `None` when that mode is not the shell's top mode.
    ///
    /// Read from the **rendered** flat order ([`crate::ui::cycle::flat_entries`])
    /// — the same order the listing highlights and the issue table lists — so
    /// the bold limb names the cycle the mode is actually showing. Gated on the
    /// top mode so a cycle does not stay bold after the user has moved to
    /// another surface, and bounds-guarded so a stale cursor past the last entry
    /// names no cycle rather than panicking.
    pub fn active_cycle_name(&self) -> Option<String> {
        if self.top_mode != TopMode::Cycle {
            return None;
        }
        let selected = self.cycle_state.as_ref()?.selected;
        let groups = self.cycle_groups();
        crate::ui::cycle::flat_entries(&groups)
            .get(selected)
            .map(|entry| entry.name.clone())
    }

    /// The name of the milestone the rail should mark active: the F5 Milestones
    /// mode's cursor target, or `None` otherwise.
    ///
    /// Gated on `milestone_mode` **and** the top mode, so the Config mode's own
    /// milestones section never bolds a row through this path (its cursor is the
    /// same `planning.selected`, but the F12 surface is not the F5 mode).
    pub fn active_milestone_name(&self) -> Option<String> {
        if !self.milestone_mode || self.top_mode != TopMode::Milestones {
            return None;
        }
        let selected = self.planning.as_ref()?.selected;
        let (_cycles, milestones) = self.planning_rows();
        milestones.get(selected).map(|row| row.name.clone())
    }

    pub(in crate::app) fn cycle_entry_count(&self) -> usize {
        self.cycle_entries().len()
    }

    /// Select the cycle row a click landed on — mouse parity with `j`/`k`.
    pub fn select_cycle_row(&mut self, index: usize) {
        let len = self.cycle_entry_count();
        if len == 0 {
            return;
        }
        self.cycle_state
            .get_or_insert_with(crate::ui::cycle::CycleState::default)
            .selected = usize::min(index, len - 1);
    }

    /// The F3 Cycle mode's keys: move the cursor, jump between the group
    /// sections, open planning management (create/rename/delete cycles and
    /// milestones), open help, or close.
    pub(in crate::app) fn handle_cycle_key(&mut self, key: KeyEvent) -> Result<Action> {
        if !matches!(self.mode, Mode::Cycle) {
            self.mode = Mode::Board;
            return Ok(Action::None);
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
                self.cycle_state = None;
                self.mode = Mode::Board;
            }
            KeyCode::Up | KeyCode::Char('k') => {
                let len = self.cycle_entry_count();
                if let Some(state) = self.cycle_state.as_mut() {
                    state.move_selected(-1, len);
                }
            }
            KeyCode::Down | KeyCode::Char('j') => {
                let len = self.cycle_entry_count();
                if let Some(state) = self.cycle_state.as_mut() {
                    state.move_selected(1, len);
                }
            }
            // The listing's horizontal axis is its group sections
            // (tui-cycle-mode): `h`/`l` land on the first entry of the previous
            // / next **rendered** group and clamp at the ends. The letters are
            // listed beside the arrows because the bound keys canonicalize to
            // them; both spellings reach here.
            KeyCode::Left | KeyCode::Char('h') => {
                let groups = self.cycle_groups();
                if let Some(state) = self.cycle_state.as_mut() {
                    state.move_group(&groups, -1);
                }
            }
            KeyCode::Right | KeyCode::Char('l') => {
                let groups = self.cycle_groups();
                if let Some(state) = self.cycle_state.as_mut() {
                    state.move_group(&groups, 1);
                }
            }
            KeyCode::Char('m') | KeyCode::Char('M') => self.open_planning_modal(),
            KeyCode::Char('?') => self.open_help(Mode::Cycle),
            _ => {}
        }
        Ok(Action::None)
    }

    pub(in crate::app) fn handle_repos_key(&mut self, key: KeyEvent) -> Result<Action> {
        if !matches!(self.mode, Mode::Repos) {
            return Ok(Action::None);
        }
        let entries = self.repo_entries();
        let len = entries.len();
        if len > 0 {
            self.repos_cursor = self.repos_cursor.min(len - 1);
        } else {
            self.repos_cursor = 0;
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
                self.mode = Mode::Board;
            }
            KeyCode::Up | KeyCode::Char('k') => {
                self.repos_cursor = self.repos_cursor.saturating_sub(1);
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if len > 0 && self.repos_cursor + 1 < len {
                    self.repos_cursor += 1;
                }
            }
            KeyCode::Enter => {
                if let Some((_, path, _)) = entries.get(self.repos_cursor) {
                    return Ok(Action::OpenRepo(path.clone()));
                }
            }
            KeyCode::Char('?') => self.open_help(Mode::Repos),
            _ => {}
        }
        Ok(Action::None)
    }
}