tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};

use crate::assignee::assignee_display;
use crate::model::Priority;

use super::board::Card;

pub fn priority_glyph(priority: &Priority) -> &'static str {
    match priority {
        Priority::High => "!",
        Priority::Other(s) if s.eq_ignore_ascii_case("urgent") => "!!",
        _ => "",
    }
}

pub fn priority_style(priority: &Priority) -> Style {
    match priority {
        Priority::High => Style::default().fg(Color::Red),
        Priority::Other(s) if s.eq_ignore_ascii_case("urgent") => {
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
        }
        Priority::Low => Style::default().fg(Color::DarkGray),
        _ => Style::default(),
    }
}

/// Single-line card render (title wrapping disabled), truncated to `width`.
/// Kept for callers and tests that want today's one-line behavior.
pub fn card_line(card: &Card, width: usize) -> Line<'static> {
    let mut lines = card_lines(card, width, false, 1);
    lines.remove(0)
}

/// The styled word tokens making up a card's rendered content: `#N`, the
/// title words, priority glyph, assignee, and up to three labels. Wrapping and
/// truncation operate on this token stream so suffix segments (assignee,
/// labels) flow onto continuation lines instead of overflowing the column.
fn card_tokens(card: &Card) -> Vec<(String, Style)> {
    let mut tokens = Vec::new();
    tokens.push((
        format!("#{}", card.number),
        Style::default().fg(Color::Cyan),
    ));
    tokens.extend(
        card.title
            .split_whitespace()
            .map(|w| (w.to_string(), Style::default())),
    );
    let glyph = priority_glyph(&card.priority);
    if !glyph.is_empty() {
        tokens.push((glyph.to_string(), priority_style(&card.priority)));
    }
    if let Some(a) = &card.assignee {
        let name = assignee_display(a);
        if !name.is_empty() {
            tokens.push((name, Style::default().fg(Color::Green)));
        }
    }
    for label in card.labels.iter().take(3) {
        tokens.push((format!("[{label}]"), Style::default().fg(Color::Yellow)));
    }
    tokens
}

/// Render a card as up to `max_lines` lines at `width` columns. With `wrap`
/// on, tokens are greedily wrapped onto the next line when they no longer fit;
/// the whole card body is clamped to `max_lines` and any surplus is clipped.
/// With `wrap` off the card is a single line truncated to `width`. Widths are
/// measured per Unicode column (CJK chars count 2) so wrapped lines never poke
/// through the column border.
pub fn card_lines(card: &Card, width: usize, wrap: bool, max_lines: usize) -> Vec<Line<'static>> {
    let tokens = card_tokens(card);
    let mut lines = if wrap {
        greedy_wrap(&tokens, width)
    } else {
        vec![tokens]
    };
    if lines.len() > max_lines {
        lines.truncate(max_lines);
    }
    lines
        .into_iter()
        .map(|line_tokens| Line::from(truncate_tokens(&line_tokens, width)))
        .collect()
}

fn greedy_wrap(tokens: &[(String, Style)], width: usize) -> Vec<Vec<(String, Style)>> {
    let mut lines: Vec<Vec<(String, Style)>> = Vec::new();
    let mut cur: Vec<(String, Style)> = Vec::new();
    let mut cur_width = 0usize;
    for (word, style) in tokens {
        let w = Span::raw(word).width();
        if !cur.is_empty() && cur_width + 1 + w > width {
            lines.push(std::mem::take(&mut cur));
            cur_width = 0;
        }
        cur.push((word.clone(), *style));
        cur_width += if cur.len() == 1 { w } else { w + 1 };
    }
    if !cur.is_empty() {
        lines.push(cur);
    }
    if lines.is_empty() {
        lines.push(Vec::new());
    }
    lines
}

fn truncate_tokens(tokens: &[(String, Style)], width: usize) -> Vec<Span<'static>> {
    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut used = 0usize;
    let mut first = true;
    for (word, style) in tokens {
        let w = Span::raw(word).width();
        let sep = if first { 0 } else { 1 };
        first = false;
        if used + sep + w <= width {
            if sep == 1 {
                spans.push(Span::raw(" "));
                used += 1;
            }
            spans.push(Span::styled(word.clone(), *style));
            used += w;
        } else if used < width {
            let clipped = clip_to_width(word, width - used);
            if !clipped.is_empty() {
                spans.push(Span::styled(clipped, *style));
            }
            break;
        } else {
            break;
        }
    }
    spans
}

fn clip_to_width(word: &str, target: usize) -> String {
    let mut acc = String::new();
    for ch in word.chars() {
        let cand = format!("{acc}{ch}");
        if Span::raw(&cand).width() <= target {
            acc.push(ch);
        } else {
            break;
        }
    }
    acc
}

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

    fn card(priority: Priority) -> Card {
        Card {
            number: 1,
            title: "Test".into(),
            priority,
            labels: vec![],
            assignee: None,
            filename: "test.md".into(),
            commit: None,
            created: "2026-01-01".into(),
        }
    }

    #[test]
    fn glyph_high_is_bang() {
        assert_eq!(priority_glyph(&Priority::High), "!");
    }

    #[test]
    fn glyph_urgent_is_double_bang() {
        assert_eq!(priority_glyph(&Priority::Other("urgent".into())), "!!");
    }

    #[test]
    fn glyph_medium_is_empty() {
        assert_eq!(priority_glyph(&Priority::Medium), "");
    }

    #[test]
    fn card_line_renders_number_and_title() {
        let c = card(Priority::Medium);
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("#1"));
        assert!(text.contains("Test"));
    }

    #[test]
    fn card_line_high_priority_shows_glyph() {
        let c = card(Priority::High);
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("!"));
    }

    #[test]
    fn card_line_assignee_shows_at_prefix() {
        let c = Card {
            assignee: Some("alice".into()),
            ..card(Priority::Medium)
        };
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("@alice"));
    }

    #[test]
    fn card_line_identity_shows_name_only() {
        let c = Card {
            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
            ..card(Priority::Medium)
        };
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("Herbert Maschke"));

        assert!(
            !text.contains("thyseus@pm.me"),
            "identity email must not leak into the board line: {text:?}"
        );
    }

    #[test]
    fn card_line_shows_first_three_labels() {
        let c = Card {
            labels: vec!["a".into(), "b".into(), "c".into(), "d".into()],
            ..card(Priority::Medium)
        };
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("[a]"));
        assert!(text.contains("[b]"));
        assert!(text.contains("[c]"));
        assert!(!text.contains("[d]"));
    }

    fn long_title() -> Card {
        Card {
            title: "very long title that definitely exceeds a narrow column width".into(),
            ..card(Priority::Medium)
        }
    }

    #[test]
    fn no_wrap_is_single_truncated_line() {
        let c = long_title();
        let lines = card_lines(&c, 20, false, 3);
        assert_eq!(lines.len(), 1, "wrap off always yields one line");
        let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.len() <= 20, "line is truncated to width: {text:?}");
    }

    #[test]
    fn wrap_produces_multiple_lines() {
        let c = long_title();
        let lines = card_lines(&c, 20, true, 10);
        assert!(lines.len() > 1, "long title wraps across lines");
        for line in &lines {
            let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
            let width = line.width();
            assert!(width <= 20, "wrapped line fits width: {text:?} ({width})");
        }
        let joined: String = lines
            .iter()
            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
            .collect();
        assert!(joined.contains("long") && joined.contains("column"));
    }

    #[test]
    fn wrapped_card_clamped_to_line_limit() {
        let c = long_title();
        let lines = card_lines(&c, 15, true, 3);
        assert!(lines.len() <= 3, "clamped at max_lines");
        let some_lines = card_lines(&c, 15, true, 5);
        assert!(
            some_lines.len() > lines.len(),
            "clamping removes surplus lines"
        );
    }

    #[test]
    fn overlong_single_token_is_truncated() {
        let c = Card {
            title: "supercalifragilisticexpialidocious".into(),
            ..card(Priority::Medium)
        };
        let lines = card_lines(&c, 10, true, 3);
        let first = &lines[0];
        let text: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
        let width = first.width();
        assert!(width <= 10, "token clipped to width: {text:?} ({width})");
    }

    #[test]
    fn cjk_widths_count_two_columns_each() {
        let c = Card {
            title: "思出 头辞失 音州".into(),
            ..card(Priority::Medium)
        };
        // Each CJK char is 2 columns: `#1` (2) + "思出" (4) + space = 7 fits
        // width 8; the following 6-column word must wrap to the next line.
        let lines = card_lines(&c, 8, true, 5);
        assert!(lines.len() > 1, "CJK title wraps on the 2-column boundary");
        for line in &lines {
            assert!(
                line.width() <= 8,
                "CJK line fits width: {line:?} ({})",
                line.width()
            );
        }
        let joined: String = lines
            .iter()
            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
            .collect();
        assert!(
            joined.contains("思出头辞失音州"),
            "no characters lost: {joined}"
        );
    }
}