tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The flat issue table shared by the Cycle and Milestones surfaces.
//!
//! Both surfaces answer the same question — which issues belong to this cycle /
//! milestone? — and both must answer it the same way: a header, one row per
//! issue showing `#number`, title, and status, and a click target per row that
//! opens the issue's detail. One [`render`] taking the rows and a title is what
//! keeps the two from drifting, and the two row builders ([`cycle_rows`],
//! [`milestone_rows`]) are the only places the membership rule lives: exact
//! string membership in `cycles` / `milestones`, the same rule the planner
//! counts with.
//!
//! Membership reads [`Store::list`], the active set the statistics above the
//! table are computed from, so a row and a count can never disagree about an
//! archived issue.

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::model::Issue;
use crate::storage::Store;

/// One issue as the flat table lists it: what a row shows and what a click on
/// it opens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueRow {
    pub number: u32,
    pub title: String,
    pub status: String,
    /// The issue's store-relative filename, carried by
    /// [`Action::OpenIssue`].
    pub filename: String,
}

impl IssueRow {
    /// Project a stored issue into a table row.
    pub fn from_issue(issue: &Issue) -> Self {
        IssueRow {
            number: issue.number,
            title: issue.title.clone(),
            status: issue.status.display().to_string(),
            filename: issue.filename.clone(),
        }
    }
}

/// Every issue whose `cycles` names `name` exactly, in store order.
pub fn cycle_rows(store: &Store, name: &str) -> Vec<IssueRow> {
    store
        .list()
        .unwrap_or_default()
        .iter()
        .filter(|issue| {
            issue
                .cycles
                .as_ref()
                .is_some_and(|cycles| cycles.iter().any(|c| c == name))
        })
        .map(IssueRow::from_issue)
        .collect()
}

/// Every issue whose `milestones` names `name` exactly, in store order.
pub fn milestone_rows(store: &Store, name: &str) -> Vec<IssueRow> {
    store
        .list()
        .unwrap_or_default()
        .iter()
        .filter(|issue| {
            issue
                .milestones
                .as_ref()
                .is_some_and(|milestones| milestones.iter().any(|m| m == name))
        })
        .map(IssueRow::from_issue)
        .collect()
}

/// `text`, cut to at most `width` characters (not bytes — a title may be
/// Unicode, and slicing it by bytes would panic).
fn clip(text: &str, width: usize) -> String {
    text.chars().take(width).collect()
}

/// Draw `rows` as a flat, hoverable, clickable table under a bordered `title`.
///
/// A hovered row is highlighted and records one [`Action::OpenIssue`] region
/// per row, so a click opens the issue exactly like a card on the board. An
/// empty list renders a short "No issues." line rather than an empty box.
pub fn render(app: &App, f: &mut Frame, area: Rect, title: &str, rows: &[IssueRow]) {
    let block = Block::default()
        .borders(Borders::ALL)
        .title(format!(" {title} "))
        .border_style(Style::default().fg(Color::Cyan));
    let inner = block.inner(area);
    f.render_widget(block, area);
    if inner.width == 0 || inner.height == 0 {
        return;
    }
    if rows.is_empty() {
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                "No issues.",
                Style::default().fg(Color::DarkGray),
            ))),
            inner,
        );
        return;
    }

    // Width-aware columns: the title absorbs whatever the number and status
    // columns leave, so the status never falls off the right edge.
    let inner_w = inner.width as usize;
    let num_w = 6usize;
    let status_w = 12usize.min(inner_w.saturating_sub(num_w + 2));
    let title_w = inner_w.saturating_sub(num_w + 1 + status_w);

    let header = Style::default()
        .fg(Color::Cyan)
        .add_modifier(Modifier::BOLD);
    let mut lines: Vec<Line> = Vec::with_capacity(rows.len() + 1);
    lines.push(Line::from(Span::styled(
        format!(
            "{:<num_w$} {:<title_w$} {}",
            "#",
            clip("Title", title_w),
            clip("Status", status_w)
        ),
        header,
    )));
    for row in rows {
        let style = if app.is_hovered(&Action::OpenIssue(row.filename.clone())) {
            Style::default()
                .fg(Color::White)
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::White)
        };
        lines.push(Line::from(Span::styled(
            format!(
                "{:<num_w$} {:<title_w$} {}",
                format!("#{}", row.number),
                clip(&row.title, title_w),
                clip(&row.status, status_w)
            ),
            style,
        )));
    }
    f.render_widget(Paragraph::new(lines), inner);

    // Rows sit below the header line; only the ones the area actually holds
    // get a click target, so a short pane never records an invisible row.
    for (index, row) in rows.iter().enumerate() {
        let line = 1 + index;
        if line >= inner.height as usize {
            break;
        }
        let rect = Rect {
            x: inner.x,
            y: inner.y + line as u16,
            width: inner.width,
            height: 1,
        };
        app.record_region(rect, Action::OpenIssue(row.filename.clone()));
    }
}

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

    fn row(number: u32, title: &str, filename: &str) -> IssueRow {
        IssueRow {
            number,
            title: title.to_string(),
            status: "todo".to_string(),
            filename: filename.to_string(),
        }
    }

    #[test]
    fn clip_counts_characters_not_bytes() {
        assert_eq!(clip("hello", 3), "hel");
        assert_eq!(clip("hi", 5), "hi");
        // Byte slicing would panic mid-codepoint; `chars` does not.
        assert_eq!(clip("Ünïcode", 2), "Ün");
        assert_eq!(clip("", 4), "");
    }

    #[test]
    fn a_row_projects_the_issue_number_title_status_and_filename() {
        let candidate = row(7, "Fix the thing", "alice/2026-01-01-fix.md");
        assert_eq!(candidate.number, 7);
        assert_eq!(candidate.title, "Fix the thing");
        assert_eq!(candidate.status, "todo");
        assert_eq!(candidate.filename, "alice/2026-01-01-fix.md");
    }
}