tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Single command registry shared by the persistent bottom hint bar and the
//! `?` help overlay (FR-009/FR-010, C-003).
//!
//! One source of truth: the hint bar renders a compact `key desc` slice
//! filtered to the currently active [`mode`]s, and the overlay renders the
//! full list with a mode column. Both derive from [`COMMANDS`], so the two
//! views can never drift.

use std::fmt::Write;

/// A stable mode-name tag. The registry filters by these string tags; the
/// runtime [`Mode`](crate::app::Mode) maps each screen to one or more tags.
pub const MODE_BOARD: &str = "board";
pub const MODE_DETAIL: &str = "detail";
pub const MODE_HELP: &str = "help";
pub const MODE_COMMENT: &str = "comment";
pub const MODE_ASSET: &str = "asset";
pub const MODE_FILTER: &str = "filter";

/// One bindable action. `modes` selects which screen the hint bar advertises
/// it on; `mode` is the static label the help overlay prints.
pub struct Cmd {
    pub key: &'static str,
    pub desc: &'static str,
    pub modes: &'static [&'static str],
    pub mode: &'static str,
}

/// Every bindable action in the TUI. The `?` overlay and the hint bar both
/// iterate this list, so adding a command here (and one binding in
/// `app::handle_key`) is all a new action costs.
pub const COMMANDS: &[Cmd] = &[
    Cmd {
        key: "j/k",
        desc: "nav",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "n",
        desc: "new",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "?",
        desc: "help",
        modes: &[MODE_BOARD, MODE_DETAIL],
        mode: "board/detail",
    },
    Cmd {
        key: "h/l",
        desc: "column",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "enter",
        desc: "open",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "d",
        desc: "delete",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "< / >",
        desc: "move",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "r",
        desc: "refresh",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "/",
        desc: "filter",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "f",
        desc: "criteria",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "Tab",
        desc: "switch",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "c",
        desc: "toggle",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "s",
        desc: "settings",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "b",
        desc: "new board",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "e",
        desc: "edit",
        modes: &[MODE_BOARD, MODE_DETAIL],
        mode: "board/detail",
    },
    Cmd {
        key: "q",
        desc: "quit",
        modes: &[MODE_BOARD],
        mode: MODE_BOARD,
    },
    Cmd {
        key: "c",
        desc: "comment",
        modes: &[MODE_DETAIL],
        mode: MODE_DETAIL,
    },
    Cmd {
        key: "a",
        desc: "asset",
        modes: &[MODE_DETAIL],
        mode: MODE_DETAIL,
    },
    Cmd {
        key: "pgu/pgd",
        desc: "scroll",
        modes: &[MODE_DETAIL],
        mode: MODE_DETAIL,
    },
];

/// Sane display width for the single-line hint bar (NFR-007).
const MAX_HINT_CHARS: usize = 80;

/// The hint-bar text for the given active mode tags: compact `key desc`
/// entries joined with a separator, truncated to [`MAX_HINT_CHARS`] chars
/// with an ellipsis when it would overflow (FR-009). Only commands whose
/// `modes` intersect `active_modes` are shown.
pub fn hint_bar(active_modes: &[&str]) -> String {
    let mut text = String::new();
    for cmd in COMMANDS {
        if !cmd.modes.iter().any(|m| active_modes.contains(m)) {
            continue;
        }
        let _ = write!(text, "{} {} · ", cmd.key, cmd.desc);
    }
    // Strip the trailing separator, then truncate.
    let joined = text.trim_end_matches(" · ");
    truncate(joined, MAX_HINT_CHARS)
}

/// Every overlay row as `(key, desc, mode)` — one per registry entry, in
/// registry order (FR-010). All rows are `'static` from the constants above.
pub fn help_overlay_list() -> Vec<(&'static str, &'static str, &'static str)> {
    COMMANDS
        .iter()
        .map(|cmd| (cmd.key, cmd.desc, cmd.mode))
        .collect()
}

fn truncate(s: &str, max_chars: usize) -> String {
    if max_chars == 0 || s.chars().count() <= max_chars {
        return s.to_string();
    }
    // Reserve one char for the ellipsis so the total stays within the limit.
    let take = max_chars.saturating_sub(1);
    let head: String = s.chars().take(take).collect();
    format!("{}…", head.trim_end())
}

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

    #[test]
    fn hint_bar_contains_help_binding() {
        let bar = hint_bar(&[MODE_BOARD]);
        assert!(bar.contains("? help"), "got {bar:?}");
        assert!(bar.contains("j/k nav"), "got {bar:?}");
    }

    #[test]
    fn hint_bar_filters_by_active_mode() {
        let board_bar = hint_bar(&[MODE_BOARD]);
        assert!(board_bar.contains("n new"));
        assert!(!board_bar.contains("c comment"), "got {board_bar:?}");

        let detail_bar = hint_bar(&[MODE_DETAIL]);
        assert!(detail_bar.contains("c comment"));
        assert!(detail_bar.contains("a asset"));
        assert!(!detail_bar.contains("n new"), "got {detail_bar:?}");
    }

    #[test]
    fn hint_bar_truncates_overflow_with_ellipsis() {
        let bar = hint_bar(&[MODE_BOARD]);
        assert!(
            bar.chars().count() <= MAX_HINT_CHARS,
            "board hint ran {}-wide, max {MAX_HINT_CHARS}",
            bar.chars().count()
        );
        assert!(
            bar.ends_with('…'),
            "a populated board hint must overflow and truncate: {bar:?}"
        );
    }

    #[test]
    fn hint_bar_unknown_mode_is_empty() {
        assert_eq!(hint_bar(&["nope"]), "");
    }

    #[test]
    fn overlay_lists_every_registry_entry_exactly_once() {
        let rows = help_overlay_list();
        assert_eq!(rows.len(), COMMANDS.len());
        let mut pairs: Vec<(&str, &str)> = rows.iter().map(|(k, _, m)| (*k, *m)).collect();
        pairs.sort_unstable();
        pairs.dedup();
        assert_eq!(
            pairs.len(),
            COMMANDS.len(),
            "every (key, mode) pair must be unique"
        );
        for cmd in COMMANDS {
            let found = rows.iter().any(|(k, _, m)| *k == cmd.key && *m == cmd.mode);
            assert!(found, "overlay must list {} ({})", cmd.key, cmd.mode);
        }
    }
}