tix-tracker 0.7.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The vertical top-level mode navigation (tui-mode-navigation).
//!
//! One column listing the five modes with their effective switch chords, drawn to
//! the left of the active mode's content. Each entry records its [`Rect`] through
//! [`App::record_region`], so a click is resolved by the same hit-region mechanism
//! every other widget uses and the geometry cannot drift from what is drawn.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::app::{Action, App};
use crate::keybindings::{InputContext, TuiAction};

/// The six top-level modes the shell hosts.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum TopMode {
    /// The new-issue form (F1).
    NewIssue,
    /// The triage inbox (F2).
    Triage,
    /// The board (F3). Active on launch.
    #[default]
    Board,
    /// Cycle management (F4).
    Cycle,
    /// The milestone registry (F5).
    Milestones,
    /// The child-repository list (F6, tix #1529).
    Repos,
    /// Settings/configuration (F12).
    Config,
}

impl TopMode {
    /// The modes in navigation order.
    pub const ALL: [Self; 7] = [
        Self::NewIssue,
        Self::Triage,
        Self::Board,
        Self::Cycle,
        Self::Milestones,
        Self::Repos,
        Self::Config,
    ];

    /// The singular display name shown in the `Default` navigation state.
    pub const fn name(self) -> &'static str {
        match self {
            Self::NewIssue => "New Issue",
            Self::Triage => "Triage",
            Self::Board => "Board",
            Self::Cycle => "Cycle",
            Self::Milestones => "Milestone",
            // The issue names the entry "Repos"; the noun is already the plural.
            Self::Repos => "Repos",
            Self::Config => "Config",
        }
    }

    /// The trunk label with `plural` applied where the mode owns a countable
    /// set — boards, cycles, milestones. The chord is untouched: only the noun
    /// moves, so `F3 Board` becomes `F3 Boards` with more than one board
    /// configured and back again with one. The other modes have no plural.
    pub const fn label(self, plural: bool) -> &'static str {
        match (self, plural) {
            (Self::Board, true) => "Boards",
            (Self::Cycle, true) => "Cycles",
            (Self::Milestones, true) => "Milestones",
            _ => self.name(),
        }
    }

    /// The shell action that switches to this mode.
    pub const fn action(self) -> TuiAction {
        match self {
            Self::NewIssue => TuiAction::SwitchToNewIssue,
            Self::Triage => TuiAction::SwitchToTriage,
            Self::Board => TuiAction::SwitchToBoard,
            Self::Cycle => TuiAction::SwitchToCycle,
            Self::Milestones => TuiAction::SwitchToMilestones,
            Self::Repos => TuiAction::SwitchToRepos,
            Self::Config => TuiAction::SwitchToConfig,
        }
    }
}

/// How much horizontal space the vertical navigation takes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NavDisplay {
    /// Hidden; mode content uses the full width.
    Off,
    /// Compact: only the `F<n>` chord labels.
    Small,
    /// Full: `F<n> <Mode Name>`.
    #[default]
    Default,
}

impl NavDisplay {
    /// The next state in the toggle cycle (`Off` → `Small` → `Default` → `Off`).
    pub const fn next(self) -> Self {
        match self {
            Self::Off => Self::Small,
            Self::Small => Self::Default,
            Self::Default => Self::Off,
        }
    }

    /// The *minimum* column width this state needs, `0` when hidden.
    ///
    /// [`App::nav_width`] grows this to fit the widest line actually drawn, so a
    /// long branch name is shown whole in `Default` rather than clipped.
    pub const fn width(self) -> u16 {
        match self {
            Self::Off => 0,
            // The branch form `"  Xx"` (indent + two letters) plus one space of
            // padding, which also fits the widest `Small` trunk chord (`F12`).
            Self::Small => 5,
            // `F12 New Issue` at the widest, plus padding.
            Self::Default => 14,
        }
    }
}

/// What kind of quick-access limb a branch row is.
///
/// The [`NavBranch::action`] alone does not say whether a row names a board or a
/// template, so the kind is carried explicitly — it is what the rail's tests and
/// any future branch-specific affordance read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchKind {
    /// A board under the Board trunk (every configured board; the active one is
    /// bold).
    Board,
    /// A cycle under the Cycles trunk.
    Cycle,
    /// A milestone under the Milestones trunk.
    Milestone,
    /// A `[templates.<name>]` entry under the New Issue trunk.
    Template,
    /// A `[repos]` entry (or the parent) under the Repos trunk (tix #1529).
    Repo,
}

/// One indented limb row drawn under a trunk mode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavBranch {
    /// The full name shown in `Default` (and abbreviated in `Small`).
    pub label: String,
    /// What activating the row does.
    pub action: Action,
    /// Which kind of limb this is.
    pub kind: BranchKind,
    /// Whether this row is the currently active target (e.g. the active board);
    /// drawn in bold.
    pub active: bool,
}

/// One trunk mode and the limbs drawn under it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavNode {
    /// The trunk mode.
    pub mode: TopMode,
    /// The limb rows; empty when the mode owns none.
    pub branches: Vec<NavBranch>,
}

/// The hover/focus highlight: one style for both, so a rail row under the
/// keyboard cursor reads exactly like the row under the pointer.
fn highlight_style() -> Style {
    Style::default()
        .fg(Color::White)
        .bg(Color::DarkGray)
        .add_modifier(Modifier::BOLD)
}

/// The style of a trunk row: a hovered or keyboard-focused row is highlighted —
/// and that highlight outranks the active-mode colour, so entering the rail with
/// `h`/`Left` on the active trunk is always visible. Otherwise the active mode is
/// highlighted and every other row is gray.
fn trunk_style(app: &App, mode: TopMode, index: usize) -> Style {
    if app.is_hovered(&Action::SwitchMode(mode)) || app.nav_row_focused(index) {
        // Every mode draws this rail, so the cursor/hover highlight is available
        // everywhere; the cursor wins over the active-mode cyan.
        highlight_style()
    } else if mode == app.top_mode {
        Style::default()
            .fg(Color::Black)
            .bg(Color::Cyan)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::Gray)
    }
}

/// The style of a branch row: the active target is bold, a hovered or
/// keyboard-focused row gets a lighter background, every other row is dimmed.
fn branch_style(app: &App, branch: &NavBranch, index: usize) -> Style {
    if app.is_hovered(&branch.action) || app.nav_row_focused(index) {
        highlight_style()
    } else if branch.active {
        Style::default()
            .fg(Color::Gray)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::DarkGray)
    }
}

/// The abbreviation `NavDisplay::Small` shows for a branch: the first two
/// characters (Unicode-aware, case preserved) of its label, or the label whole
/// when it is shorter. Duplicates are allowed — the adjacent trunk and the hover
/// highlight disambiguate.
fn branch_abbrev(label: &str) -> String {
    label.chars().take(2).collect()
}

/// Draw the navigation tree into `area`, recording one click region per painted
/// row: trunk rows carry `Action::SwitchMode`, branch rows their own action.
pub fn render(app: &App, f: &mut Frame, area: Rect) {
    if app.nav_display == NavDisplay::Off || area.width == 0 {
        return;
    }
    let nodes = app.nav_tree();
    let mut lines: Vec<Line<'static>> = Vec::new();
    // One target per painted row, in order; the row index is the line index.
    let mut targets: Vec<Action> = Vec::new();
    for node in &nodes {
        if lines.len() as u16 >= area.height {
            break;
        }
        let chord = app.bindings.label(InputContext::Shell, node.mode.action());
        let trunk = match app.nav_display {
            NavDisplay::Small => chord,
            _ => format!("{chord} {}", app.mode_label(node.mode)),
        };
        lines.push(Line::from(Span::styled(
            trunk,
            trunk_style(app, node.mode, lines.len()),
        )));
        targets.push(Action::SwitchMode(node.mode));
        for branch in &node.branches {
            if lines.len() as u16 >= area.height {
                break;
            }
            let text = match app.nav_display {
                NavDisplay::Small => format!("  {}", branch_abbrev(&branch.label)),
                _ => format!("  {}", branch.label),
            };
            lines.push(Line::from(Span::styled(
                text,
                branch_style(app, branch, lines.len()),
            )));
            targets.push(branch.action.clone());
        }
    }
    // When the rail owns the keyboard, mark it as focused with a right-edge
    // accent (the same yellow the Config surface uses for its focused pane), so
    // the keyboard cursor is never lost among the rows.
    let block = if app.nav_focus_index().is_some() {
        Block::default()
            .borders(Borders::RIGHT)
            .border_style(Style::default().fg(Color::Yellow))
    } else {
        Block::default()
    };
    f.render_widget(Paragraph::new(lines).block(block), area);

    // Record the regions *after* drawing, one row per painted line, clipped to the
    // area — a short terminal must not record a target that was never painted.
    for (index, action) in targets.into_iter().enumerate() {
        let rect = Rect::new(area.x, area.y + index as u16, area.width, 1);
        app.record_region(rect, action);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn display_states_cycle_and_shrink() {
        assert_eq!(NavDisplay::Off.next(), NavDisplay::Small);
        assert_eq!(NavDisplay::Small.next(), NavDisplay::Default);
        assert_eq!(NavDisplay::Default.next(), NavDisplay::Off);
        assert_eq!(NavDisplay::Off.width(), 0);
        assert!(NavDisplay::Small.width() < NavDisplay::Default.width());
        assert_eq!(NavDisplay::Default, NavDisplay::default());
    }

    #[test]
    fn every_mode_maps_to_a_distinct_shell_action() {
        let actions: Vec<TuiAction> = TopMode::ALL.iter().map(|mode| mode.action()).collect();
        for (index, action) in actions.iter().enumerate() {
            assert!(
                !actions[index + 1..].contains(action),
                "two modes share the switch action {action:?}"
            );
        }
    }

    /// The mode order the shell presents (tui-mode-navigation): F1 New Issue
    /// through F5 Milestones, the F6 Repos list (#1529), then F12 Config.
    #[test]
    fn modes_are_ordered_new_issue_to_config() {
        assert_eq!(
            TopMode::ALL,
            [
                TopMode::NewIssue,
                TopMode::Triage,
                TopMode::Board,
                TopMode::Cycle,
                TopMode::Milestones,
                TopMode::Repos,
                TopMode::Config,
            ]
        );
    }

    /// `Small` abbreviates a branch to its first two characters, case preserved
    /// and Unicode-aware; a shorter label is shown whole.
    #[test]
    fn small_branch_abbreviation_is_two_characters() {
        assert_eq!(branch_abbrev("Default"), "De");
        assert_eq!(branch_abbrev("October 2026"), "Oc");
        assert_eq!(branch_abbrev("Backlog"), "Ba");
        assert_eq!(branch_abbrev("X"), "X");
        assert_eq!(branch_abbrev(""), "");
        // Byte-count would split this; `chars` does not.
        assert_eq!(branch_abbrev("Ünïcode"), "Ün");
    }

    /// `Small` must fit the `"  Xx"` branch form (indent + two letters).
    #[test]
    fn small_width_fits_the_branch_form() {
        assert!(NavDisplay::Small.width() >= "  Xx".chars().count() as u16);
    }

    /// Only the countable modes have a plural; the chord is never part of the
    /// label, and `name()` is the singular default.
    #[test]
    fn label_pluralizes_only_the_countable_modes() {
        assert_eq!(TopMode::Board.label(false), "Board");
        assert_eq!(TopMode::Board.label(true), "Boards");
        assert_eq!(TopMode::Cycle.label(false), "Cycle");
        assert_eq!(TopMode::Cycle.label(true), "Cycles");
        assert_eq!(TopMode::Milestones.label(false), "Milestone");
        assert_eq!(TopMode::Milestones.label(true), "Milestones");
        assert_eq!(TopMode::NewIssue.label(true), "New Issue");
        assert_eq!(TopMode::Triage.label(true), "Triage");
        assert_eq!(TopMode::Config.label(true), "Config");
        assert_eq!(TopMode::Cycle.name(), "Cycle");
    }
}