tix-tracker 0.12.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 navigation tree: each trunk mode with the limbs drawn under it
    /// (tui-mode-navigation).
    ///
    /// Built from the live config and registry, so the rail cannot disagree with
    /// the surfaces a branch activates. Boards, cycles and templates come from
    /// `Config`; only the milestone names touch the filesystem (a directory
    /// listing of tiny records), and an empty group renders no rows.
    pub fn nav_tree(&self) -> Vec<crate::ui::nav::NavNode> {
        use crate::ui::nav::{BranchKind, NavBranch, NavNode};

        let active_board = self.active_board_name().to_string();
        let active_cycle = self.active_cycle_name();
        let active_milestone = self.active_milestone_name();
        let planner = || Planner::from_config(self.store.config.clone(), self.project_root());
        TopMode::ALL
            .iter()
            .map(|mode| {
                let branches = match mode {
                    // Every configured board is a limb; the active one is marked
                    // so the rail bolds it. The horizontal board tab bar stays as
                    // the always-visible active-board indicator on Board mode.
                    TopMode::Board => self
                        .store
                        .config
                        .configs()
                        .map(|(name, _)| NavBranch {
                            label: name.to_string(),
                            action: Action::SelectBoard(name.to_string()),
                            kind: BranchKind::Board,
                            active: name == active_board,
                        })
                        .collect(),
                    TopMode::Cycle => planner()
                        .cycle_names()
                        .into_iter()
                        .map(|name| NavBranch {
                            label: name.clone(),
                            active: active_cycle.as_deref() == Some(name.as_str()),
                            action: Action::SelectCycle(name),
                            kind: BranchKind::Cycle,
                        })
                        .collect(),
                    TopMode::Milestones => planner()
                        .list_milestones()
                        .unwrap_or_default()
                        .into_iter()
                        .map(|milestone| NavBranch {
                            active: active_milestone.as_deref() == Some(milestone.name.as_str()),
                            label: milestone.name.clone(),
                            action: Action::SelectMilestone(milestone.name),
                            kind: BranchKind::Milestone,
                        })
                        .collect(),
                    TopMode::NewIssue => self
                        .store
                        .config
                        .templates
                        .keys()
                        .map(|name| NavBranch {
                            label: name.clone(),
                            action: Action::NewFromTemplate(name.clone()),
                            kind: BranchKind::Template,
                            active: false,
                        })
                        .collect(),
                    // #1529: one limb per configured child, plus the parent when
                    // one is known. Activating a limb runs tix there.
                    TopMode::Repos => self
                        .repo_entries()
                        .into_iter()
                        .map(|(label, path, _is_parent)| NavBranch {
                            label,
                            action: Action::OpenRepo(path),
                            kind: BranchKind::Repo,
                            active: false,
                        })
                        .collect(),
                    // WP06: the Portfolio page's own tab, as a limb. Unlike the
                    // Repos limb this costs no read of the issue set — the rail
                    // would otherwise re-read every issue file per frame, and the
                    // project rows are the page's body rather than the rail's.
                    TopMode::Portfolio => vec![NavBranch {
                        label: "Projects".to_string(),
                        action: Action::SwitchMode(TopMode::Portfolio),
                        kind: BranchKind::Project,
                        active: matches!(self.mode, Mode::Portfolio),
                    }],
                    // The Stats page owns no registry, so it has no limbs — the same
                    // shape Triage, Config, Schedule and Library have.
                    TopMode::Stats
                    | TopMode::Triage
                    | TopMode::Config
                    | TopMode::Schedule
                    | TopMode::Library => Vec::new(),
                };
                NavNode {
                    mode: *mode,
                    branches,
                }
            })
            .collect()
    }

    /// The repositories the F6 Repos mode lists, in display order: the
    /// configured children (`[repos]`, in declaration order) followed by the
    /// parent when one is known (tix #1529).
    ///
    /// One level deep by construction: the children come from **this** config and
    /// the parent from `parent_repo` (or the `TIX_PARENT_REPO` the parent set
    /// when spawning us). A child's own children are reached by running tix
    /// there, never by walking from here.
    pub fn repo_entries(&self) -> Vec<(String, PathBuf, bool)> {
        let mut out: Vec<(String, PathBuf, bool)> = self
            .store
            .config
            .repos()
            .iter()
            .map(|(name, path)| (name.clone(), path.clone(), false))
            .collect();
        if let Some(parent) = self.store.config.parent_repo() {
            out.push(("parent".to_string(), parent.to_path_buf(), true));
        }
        out
    }

    /// The column width the rail needs for its current content.
    ///
    /// Grown past [`NavDisplay::width`] so the widest trunk or branch line is
    /// drawn whole (one space of separation from the content area) rather than
    /// clipped — that is what lets `Default` show a full branch name.
    pub fn nav_width(&self) -> u16 {
        if self.nav_display == NavDisplay::Off {
            return 0;
        }
        let mut width = self.nav_display.width();
        for node in self.nav_tree() {
            let chord = self.bindings.label(InputContext::Shell, node.mode.action());
            let trunk = if self.nav_display == NavDisplay::Small {
                chord.chars().count()
            } else {
                chord.chars().count() + 1 + self.mode_label(node.mode).chars().count()
            };
            width = width.max(trunk as u16 + 1);
            if self.nav_display != NavDisplay::Small {
                for branch in &node.branches {
                    width = width.max(2 + branch.label.chars().count() as u16 + 1);
                }
            }
        }
        width
    }

    /// The trunk label for `mode`: the singular name, or the plural when the
    /// mode owns **more than one** record — boards by configured count, cycles
    /// by [`Planner::cycle_names`], milestones by [`Planner::list_milestones`].
    /// The chord is never affected; only the noun moves.
    pub fn mode_label(&self, mode: TopMode) -> &'static str {
        let planner = || Planner::from_config(self.store.config.clone(), self.project_root());
        let plural = match mode {
            TopMode::Board => self.store.config.configs().count() > 1,
            TopMode::Cycle => planner().cycle_names().len() > 1,
            TopMode::Milestones => planner().list_milestones().unwrap_or_default().len() > 1,
            // "Repos" is already plural; the count only matters for consistency.
            TopMode::Repos => self.repo_entries().len() > 1,
            TopMode::NewIssue
            | TopMode::Triage
            | TopMode::Config
            | TopMode::Stats
            | TopMode::Schedule
            | TopMode::Library
            | TopMode::Portfolio => false,
        };
        mode.label(plural)
    }

    /// The rail's rows in draw order — each trunk followed by its limbs, exactly
    /// as [`crate::ui::nav::render`] paints them. The keyboard cursor indexes
    /// this list; the pointer's hit regions come from the same tree.
    pub fn nav_rows(&self) -> Vec<Action> {
        let mut rows = Vec::new();
        for node in self.nav_tree() {
            rows.push(Action::SwitchMode(node.mode));
            rows.extend(node.branches.iter().map(|branch| branch.action.clone()));
        }
        rows
    }

    /// The index of the rail row holding the keyboard cursor, `None` when the
    /// mode content owns the keyboard.
    pub fn nav_focus_index(&self) -> Option<usize> {
        self.nav_focus
    }

    /// Whether rail row `index` holds the keyboard cursor. The rail draws such a
    /// row exactly like a hovered one.
    pub fn nav_row_focused(&self, index: usize) -> bool {
        self.nav_focus == Some(index)
    }

    /// Whether `h`/`Left` means "enter the rail" in the current view.
    ///
    /// A page with its own horizontal cursor moves left first and enters the
    /// rail at its left edge. A page with no horizontal cursor enters directly.
    /// Text editors keep `h` and `Left` for the text; the New Issue form can
    /// still enter the rail with `Left` when the active editor is at column 0.
    pub(in crate::app) fn rail_entry_available(&self, key: KeyCode) -> bool {
        if self.nav_display == NavDisplay::Off {
            return false;
        }
        match &self.mode {
            Mode::Board => self.board.real_cols() == 0 || self.board.active_col == 0,
            Mode::TriageInbox => self.triage.as_ref().is_some_and(|state| {
                state.modal.is_none()
                    && state.dropdown.is_none()
                    && !state.creating
                    && state.focus == TriageControl::Queue
            }),
            Mode::Cycle => self.cycle_state.as_ref().map_or(0, |state| state.selected) == 0,
            Mode::Settings => {
                self.settings
                    .as_ref()
                    .is_none_or(|modal| modal.prompt.is_none())
                    && self
                        .planning
                        .as_ref()
                        .is_none_or(|modal| modal.prompt.is_none())
                    && (self.milestone_mode
                        || self
                            .settings
                            .as_ref()
                            .is_some_and(|modal| modal.level == ConfigLevel::Sections))
            }
            Mode::Repos | Mode::Stats => true,
            Mode::Schedule => self.schedule.as_ref().is_some_and(|state| {
                state.prompt.is_none() && state.form.is_none() && state.series_form.is_none()
            }),
            Mode::Library => self
                .library
                .as_ref()
                .is_some_and(|state| state.tab == crate::ui::library::LibraryTab::Views),
            Mode::Portfolio => {
                self.portfolio_roadmap_overlay.is_none()
                    && self.portfolio.as_ref().is_some_and(|state| {
                        state.tab != crate::ui::portfolio::PortfolioTab::Epics || state.member == 0
                    })
            }
            Mode::Detail => self.related_picker.is_none(),
            Mode::Form => {
                matches!(key, KeyCode::Left)
                    && self.form_picker.is_none()
                    && self.form.as_ref().is_some_and(|form| form.cursor == 0)
            }
            _ => false,
        }
    }

    /// Put the keyboard cursor on the rail, at the current top mode's trunk row
    /// (or the first row when that mode has no trunk). Idempotent.
    pub(in crate::app) fn focus_rail(&mut self) {
        if self.nav_focus.is_some() {
            return;
        }
        let rows = self.nav_rows();
        let index = rows
            .iter()
            .position(|action| *action == Action::SwitchMode(self.top_mode))
            .unwrap_or(0);
        self.nav_focus = Some(index);
    }

    /// The rail's keys while it owns the keyboard.
    ///
    /// `j`/`k` and the arrows walk the flattened trunk+limb rows; `Enter`, `l`
    /// and `→` activate the focused row — a trunk switches modes, a limb runs
    /// exactly the action a click on it runs — and hand the keyboard to the
    /// opened content on the right, the same result a click has; `Esc` (and `←`)
    /// leave the rail without activating. Anything else is swallowed: the rail
    /// has focus, and a half-applied board key would act on a surface the user is
    /// not looking at.
    pub(in crate::app) fn handle_nav_key(&mut self, key: KeyEvent) -> Result<Action> {
        let rows = self.nav_rows();
        let last = rows.len().saturating_sub(1);
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                if let Some(index) = self.nav_focus.as_mut() {
                    *index = index.saturating_sub(1);
                }
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if let Some(index) = self.nav_focus.as_mut() {
                    *index = usize::min(index.saturating_add(1), last);
                }
            }
            KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
                self.activate_focused_rail_row()?;
                // Opening a row hands the keyboard to the surface on the right,
                // exactly like a click; the rail keeps the cursor only while the
                // user is walking it.
                self.nav_focus = None;
            }
            KeyCode::Left | KeyCode::Esc => self.nav_focus = None,
            _ => {}
        }
        Ok(Action::None)
    }

    /// Run the rail row under the keyboard cursor: a trunk switches modes, a limb
    /// runs exactly the action a click on it runs. A missing cursor (or one that
    /// drifted past the end) does nothing.
    pub(in crate::app) fn activate_focused_rail_row(&mut self) -> Result<()> {
        let action = self
            .nav_focus
            .and_then(|index| self.nav_rows().get(index).cloned());
        match action {
            Some(Action::SwitchMode(mode)) => {
                self.switch_top_mode(mode)?;
            }
            // Branch limbs: the same transition their click runs.
            Some(other) => {
                self.activate_branch(&other)?;
            }
            None => {}
        }
        Ok(())
    }

    /// The category resolution for the board the TUI is currently showing.
    ///
    /// WP05. Derived from [`Self::active_board_config`], so it is the resolution of
    /// the board on screen rather than of whichever board is `default` — the TUI can
    /// display any configured board, and a card showing another board's categories is
    /// a wrong answer rather than a missing one.
    pub(in crate::app) fn workflow_resolution(&self) -> crate::workflow::CategoryResolution {
        let name = self
            .store
            .config
            .configs()
            .nth(self.active_config)
            .map(|(name, _)| name)
            .unwrap_or("default");
        crate::workflow::resolution(&self.store.config, self.active_board_config(), name)
    }

    /// The category label and color hint for `issue`, as the detail view shows them.
    ///
    /// WP05 T023. Resolved through the service against the board on screen, and
    /// derived rather than stored — an issue's category changes when its column
    /// changes or when the board's `category_map` is edited, and a value captured
    /// when the detail was opened would be stale for exactly as long as the view
    /// stayed open.
    pub(in crate::app) fn category_of_issue(
        &self,
        issue: &crate::model::Issue,
    ) -> Option<(String, Option<crate::workflow::ColorHint>)> {
        let resolution = self.workflow_resolution();
        crate::workflow::category_of(issue, &resolution)
            .map(|c| (c.label().to_string(), c.color_hint()))
    }
}