tix-tracker 0.5.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::Paragraph;

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

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

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

    /// The display name shown in the `Default` navigation state.
    pub const fn name(self) -> &'static str {
        match self {
            Self::Board => "Board",
            Self::Triage => "Triage",
            Self::Cycle => "Cycle",
            Self::NewIssue => "New Issue",
            Self::Config => "Config",
        }
    }

    /// The shell action that switches to this mode.
    pub const fn action(self) -> TuiAction {
        match self {
            Self::Board => TuiAction::SwitchToBoard,
            Self::Triage => TuiAction::SwitchToTriage,
            Self::Cycle => TuiAction::SwitchToCycle,
            Self::NewIssue => TuiAction::SwitchToNewIssue,
            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 column width this state needs, `0` when hidden.
    pub const fn width(self) -> u16 {
        match self {
            Self::Off => 0,
            // `F12` is the widest short label, plus one space of padding.
            Self::Small => 4,
            // `F12 New Issue` at the widest, plus padding.
            Self::Default => 14,
        }
    }
}

/// Draw the navigation into `area`, recording one click region per mode entry.
pub fn render(app: &App, f: &mut Frame, area: Rect) {
    if app.nav_display == NavDisplay::Off || area.width == 0 {
        return;
    }
    let mut lines: Vec<Line<'static>> = Vec::with_capacity(TopMode::ALL.len());
    for mode in TopMode::ALL {
        let chord = app.bindings.label(InputContext::Shell, mode.action());
        let text = match app.nav_display {
            NavDisplay::Small => chord,
            _ => format!("{chord} {}", mode.name()),
        };
        let style = if mode == app.top_mode {
            Style::default()
                .fg(Color::Black)
                .bg(Color::Cyan)
                .add_modifier(Modifier::BOLD)
        } else if app.is_hovered(&Action::SwitchMode(mode)) {
            // Hover highlight only — the active mode is untouched. Every mode
            // draws this rail, so the highlight is available everywhere.
            Style::default()
                .fg(Color::White)
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Gray)
        };
        lines.push(Line::from(Span::styled(text, style)));
    }
    f.render_widget(Paragraph::new(lines), area);

    // Record the regions *after* drawing, one row per mode, clipped to the area —
    // a short terminal must not record a target that was never painted.
    let rows = area.height.min(TopMode::ALL.len() as u16);
    for (index, mode) in TopMode::ALL.iter().enumerate() {
        if index as u16 >= rows {
            break;
        }
        let rect = Rect::new(area.x, area.y + index as u16, area.width, 1);
        app.record_region(rect, Action::SwitchMode(*mode));
    }
}

#[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:?}"
            );
        }
    }
}