tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The mention surfaces: the persistent unread indicator, the per-issue notice, and the
//! mentions view (OpenSpec change `issue-mentions`, design D7).
//!
//! Three presentations, one store read behind them:
//!
//! | surface | drawn when | answers to |
//! |---------|-----------|------------|
//! | [`render_indicator`] — one row above the hint bar | the local user has unread mentions | the board's `open_mentions` chord |
//! | [`render_notice`] — a modal over the board | an opened issue has unread mentions for the local user | jump / ignore / mark read |
//! | [`render_view`] — a modal listing every pending mention | the chord was pressed | open issue / ignore / mark read |
//!
//! # Ignore writes nothing
//!
//! The three answers are (a) jump, (b) ignore, (c) mark read. Only (c) writes: "ignore"
//! is deliberately the **absence** of a state change, which is what makes the mention
//! reappear the next time the issue is opened — the same non-mutating shape the recurring
//! warning's dismiss has, and the reason no `ignored` state exists in the store.
//!
//! # The keys are raw
//!
//! Both modals handle their own keys in `App::handle_mention_*_key` rather than binding
//! them, exactly as the recurring warning does: `j`/`k` are navigation here, and the mention
//! text is never typed into, so a binding would only obscure what the footer already says.

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

use crate::app::App;
use crate::mentions::Mention;

/// The notice's keys, in the order the footer advertises them.
pub const NOTICE_KEYS: &str = "j/k: select  enter: jump  i: ignore  r: read  esc: ignore all";

/// The view's keys, in the order the footer advertises them.
pub const VIEW_KEYS: &str = "j/k: select  enter: open  i: ignore  r: read  esc: close";

/// The mention rows a surface is showing, and the one the cursor is on.
///
/// One type for both modals: the notice and the view differ in where their rows come from
/// and in what `for_issue` says, not in their shape. `for_issue` is `Some` for a notice
/// (the issue being opened) and `None` for the view (every issue), which is what lets the
/// two footers differ while the row rendering is one implementation.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MentionsView {
    /// The pending mentions, in the store's display order.
    pub rows: Vec<Mention>,
    /// The selected row, clamped into range.
    pub selected: usize,
    /// The issue the notice is about; `None` for the all-issues view.
    pub for_issue: Option<u32>,
}

impl MentionsView {
    /// A view over `rows`, on the first row.
    #[must_use]
    pub fn new(rows: Vec<Mention>, for_issue: Option<u32>) -> Self {
        let mut view = MentionsView {
            rows,
            selected: 0,
            for_issue,
        };
        view.clamp();
        view
    }

    /// Whether there is nothing to show.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }

    /// The row under the cursor.
    #[must_use]
    pub fn current(&self) -> Option<&Mention> {
        self.rows.get(self.selected)
    }

    /// Keep the cursor inside the rows.
    pub fn clamp(&mut self) {
        self.selected = self.selected.min(self.rows.len().saturating_sub(1));
    }

    /// Move the cursor by `delta`, clamped at both ends.
    pub fn move_by(&mut self, delta: isize) {
        if self.rows.is_empty() {
            self.selected = 0;
            return;
        }
        let last = (self.rows.len() - 1) as isize;
        let next = (self.selected as isize + delta).clamp(0, last);
        self.selected = next as usize;
    }

    /// Drop the row under the cursor, for a mention that is no longer pending.
    pub fn drop_current(&mut self) {
        if self.selected < self.rows.len() {
            self.rows.remove(self.selected);
        }
        self.clamp();
    }
}

/// The persistent indicator row: how many unread mentions the local user has, and how to
/// open them. Drawn immediately above the hint bar whenever the count is non-zero.
pub fn render_indicator(app: &App, f: &mut Frame, area: Rect) {
    let chord = app
        .bindings
        .chords(
            crate::keybindings::InputContext::Board,
            crate::keybindings::TuiAction::OpenMentions,
        )
        .first()
        .map_or_else(|| "i".to_string(), crate::keybindings::KeyChord::display);
    let count = app.unread_mentions;
    let text = format!(
        "@ {count} unread mention{}  —  press {chord} to open",
        if count == 1 { "" } else { "s" }
    );
    let line = Line::from(Span::styled(
        text,
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD),
    ));
    f.render_widget(Paragraph::new(line), area);
}

/// The per-issue notice, drawn over the board when an opened issue has unread mentions for
/// the local user.
pub fn render_notice(app: &App, f: &mut Frame, area: Rect) {
    let Some(view) = app.mentions.as_ref() else {
        return;
    };
    render_rows(f, area, view, "Mentions for you", NOTICE_KEYS);
}

/// The all-issues view, drawn over the board when the indicator chord is pressed.
pub fn render_view(app: &App, f: &mut Frame, area: Rect) {
    let Some(view) = app.mentions_view.as_ref() else {
        return;
    };
    render_rows(f, area, view, "Pending mentions", VIEW_KEYS);
}

/// Draw one row per mention plus a footer, in a centered modal.
fn render_rows(f: &mut Frame, area: Rect, view: &MentionsView, title: &str, keys: &str) {
    if area.width == 0 || area.height == 0 {
        return;
    }
    let rect = crate::ui::modal::modal_rect(area);
    f.render_widget(Clear, rect);
    let header = format!("{title} ({})", view.rows.len());
    let block = Block::default().borders(Borders::ALL).title(header);
    let inner = block.inner(rect);
    f.render_widget(block, rect);

    // One footer line, and one blank line before it.
    let footer_h: u16 = if inner.height > 2 { 2 } else { 0 };
    let body_h = inner.height.saturating_sub(footer_h);
    if body_h == 0 {
        return;
    }
    if view.is_empty() {
        f.render_widget(
            Paragraph::new("Nothing is pending. Press Esc to close."),
            Rect { height: 1, ..inner },
        );
        render_footer(f, inner, keys);
        return;
    }

    let mut lines: Vec<Line> = Vec::new();
    for (index, mention) in view.rows.iter().enumerate() {
        let selected = index == view.selected;
        let marker = if selected { ">" } else { " " };
        let head = format!("{marker} #{:<4} {}", mention.issue_number, mention.body);
        let head_style = if selected {
            Style::default()
                .fg(Color::Black)
                .bg(Color::Cyan)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Cyan)
        };
        lines.push(Line::from(Span::styled(head, head_style)));
        let context_style = if selected {
            Style::default().add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Gray)
        };
        lines.push(Line::from(Span::styled(
            format!("    {}", mention.context),
            context_style,
        )));
    }
    f.render_widget(
        Paragraph::new(lines),
        Rect {
            height: body_h,
            ..inner
        },
    );
    render_footer(f, inner, keys);
}

/// The keys line, on the last inner row.
fn render_footer(f: &mut Frame, inner: Rect, keys: &str) {
    if inner.height < 2 {
        return;
    }
    let row = Rect {
        y: inner.y + inner.height - 1,
        height: 1,
        ..inner
    };
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            keys,
            Style::default().fg(Color::DarkGray),
        ))),
        row,
    );
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::mentions::{Mention, MentionState};
    use std::collections::BTreeMap;

    fn mention(number: u32, body: &str, context: &str) -> Mention {
        Mention {
            id: format!("M{number}"),
            key: format!("k{number}"),
            issue_number: number,
            issue_id: format!("ID{number}"),
            issue_file: format!("a/b/{number}.md"),
            body: body.to_string(),
            context: context.to_string(),
            created: "2026-10-10T00:00:00Z".to_string(),
            state: MentionState::Unread,
            extra: BTreeMap::new(),
        }
    }

    #[test]
    fn cursor_moves_within_the_rows_and_clamps() {
        let mut view = MentionsView::new(
            vec![mention(1, "alice", "a"), mention(2, "bob", "b")],
            Some(1),
        );
        assert_eq!(view.current().map(|m| m.issue_number), Some(1));
        view.move_by(1);
        assert_eq!(view.current().map(|m| m.issue_number), Some(2));
        view.move_by(5);
        assert_eq!(view.current().map(|m| m.issue_number), Some(2), "clamped");
        view.move_by(-9);
        assert_eq!(view.current().map(|m| m.issue_number), Some(1), "clamped");
    }

    #[test]
    fn dropping_the_current_row_keeps_the_cursor_in_range() {
        let mut view =
            MentionsView::new(vec![mention(1, "alice", "a"), mention(2, "bob", "b")], None);
        view.move_by(1);
        view.drop_current();
        assert_eq!(view.rows.len(), 1);
        assert_eq!(view.current().map(|m| m.issue_number), Some(1));
        view.drop_current();
        assert!(view.is_empty());
        assert_eq!(view.current(), None);
        // Moving on an empty view is not a panic.
        view.move_by(1);
    }

    #[test]
    fn a_new_view_clamps_a_stale_selection() {
        let view = MentionsView {
            rows: vec![mention(1, "alice", "a")],
            selected: 9,
            for_issue: None,
        };
        let mut view = view;
        view.clamp();
        assert_eq!(view.selected, 0);
    }
}