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(),
}
}
pub fn card_line(card: &Card, width: usize) -> Line<'static> {
let mut lines = card_lines(card, width, false, 1);
lines.remove(0)
}
pub 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)));
}
}
if card.blocked.is_some() {
tokens.push(("[~]".to_string(), Style::default().fg(Color::Red)));
}
if let Some(children) = &card.children {
tokens.push((format!("[{children}]"), Style::default().fg(Color::Cyan)));
}
if card.archived {
tokens.push((
"[archived]".to_string(),
Style::default().fg(Color::DarkGray),
));
}
for label in card.labels.iter().take(3) {
tokens.push((format!("[{label}]"), Style::default().fg(Color::Yellow)));
}
tokens.push(category_token(
card.category.as_deref(),
card.category_color,
));
if card
.due_date
.as_deref()
.is_some_and(|due| due < crate::model::today().as_str())
&& !card.done
{
tokens.push(("[overdue]".to_string(), Style::default().fg(Color::Red)));
}
tokens
}
pub fn category_token(
label: Option<&str>,
hint: Option<crate::workflow::ColorHint>,
) -> (String, Style) {
let text = format!("[{}]", label.unwrap_or("unmapped"));
let style = match hint {
Some(crate::workflow::ColorHint::Dim) => Style::default().fg(Color::DarkGray),
Some(crate::workflow::ColorHint::Accent) => Style::default().fg(Color::Cyan),
Some(crate::workflow::ColorHint::Warn) => Style::default().fg(Color::Yellow),
Some(crate::workflow::ColorHint::Ok) => Style::default().fg(Color::Green),
Some(crate::workflow::ColorHint::Muted) => Style::default().fg(Color::Gray),
None => Style::default(),
};
(text, style)
}
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()
}
pub fn card_line_count(card: &Card, width: usize, wrap: bool, max_lines: usize) -> u16 {
let count = if wrap {
greedy_wrap(&card_tokens(card), width).len()
} else {
1
};
let count = count.min(max_lines).max(1);
u16::try_from(count).unwrap_or(u16::MAX)
}
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(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
archived: false,
}
}
#[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]"));
}
#[test]
fn card_line_shows_overdue_tag_for_open_past_due() {
let c = Card {
due_date: Some("2000-01-01".into()),
status: "todo".into(),
..card(Priority::Medium)
};
let line = card_line(&c, 80);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("[overdue]"));
}
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)
};
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}"
);
}
}