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)> {
card_tokens_themed(card, &crate::ui::color::Theme::default())
}
pub fn card_tokens_themed(card: &Card, theme: &crate::ui::color::Theme) -> Vec<(String, Style)> {
let mut tokens = Vec::new();
let number = if card.depth == 0 {
format!("#{}", card.number)
} else {
format!("{}", card.number)
};
tokens.push((number, Style::default().fg(Color::Cyan)));
if card.related > 0 {
tokens.push((
format!("[↔ {}]", card.related),
Style::default().fg(Color::Magenta),
));
}
for label in card.labels.iter().take(3) {
let style = theme.tag(label).map_or_else(
|| Style::default().fg(Color::Yellow),
|color| Style::default().fg(color),
);
tokens.push((format!("[{label}]"), style));
}
if let Some(a) = &card.assignee {
let name = assignee_display(a);
if !name.is_empty() {
tokens.push((name, Style::default().fg(Color::Green)));
}
}
tokens.push(category_token(
card.category.as_deref(),
card.category_color,
));
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 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 let Some(health) = &card.health {
let (_, derived) = health
.split_once('→')
.map_or((health.as_str(), None), |(current, derived)| {
(current, Some(derived))
});
let color = match derived.or(Some(health.as_str())) {
Some("at_risk") => Color::Red,
Some("needs_attention") => Color::Yellow,
Some("on_track") => Color::Green,
_ => Color::DarkGray,
};
tokens.push((format!("[{health}]"), Style::default().fg(color)));
}
if card.archived {
tokens.push((
"[archived]".to_string(),
Style::default().fg(Color::DarkGray),
));
}
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>> {
card_lines_themed(
card,
width,
wrap,
max_lines,
&crate::ui::color::Theme::default(),
)
}
pub fn card_lines_themed(
card: &Card,
width: usize,
wrap: bool,
max_lines: usize,
theme: &crate::ui::color::Theme,
) -> Vec<Line<'static>> {
let tokens = card_tokens_themed(card, theme);
let mut lines = if wrap {
greedy_wrap(&tokens, width)
} else {
vec![tokens]
};
if lines.len() > max_lines {
lines.truncate(max_lines);
}
let mut rendered: Vec<Line<'static>> = lines
.into_iter()
.map(|line_tokens| Line::from(truncate_tokens(&line_tokens, width)))
.collect();
indent_sub_issue(card, &mut rendered);
rendered
}
fn indent_sub_issue(card: &Card, lines: &mut [Line<'static>]) {
if card.depth == 0 {
return;
}
for line in lines.iter_mut() {
let mut spans = vec![Span::raw(" ")];
spans.append(&mut line.spans);
*line = Line::from(spans);
}
}
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 {
health: None,
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,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}
}
#[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]"));
}
#[test]
fn card_shows_a_related_count_right_after_the_number() {
let none = card_tokens(&card(Priority::Medium));
assert!(
none.iter().all(|(text, _)| !text.starts_with("[↔")),
"a card with no related_issues has no marker: {none:?}"
);
let c = Card {
related: 3,
projects: vec![],
epics: Vec::new(),
..card(Priority::Medium)
};
let tokens = card_tokens(&c);
assert_eq!(tokens[0].0, "#1", "the number stays the first token");
assert_eq!(
tokens[1].0, "[↔ 3]",
"the related count sits immediately after the number, so title wrapping \
cannot hide it: {tokens:?}"
);
assert_eq!(
tokens[1].1.fg,
Some(Color::Magenta),
"the marker uses the detail pane's related accent"
);
}
#[test]
fn sub_issue_number_drops_the_hash_marker() {
let top = card(Priority::Medium);
assert_eq!(card_tokens(&top)[0].0, "#1");
let sub = Card {
depth: 1,
..card(Priority::Medium)
};
let tokens = card_tokens(&sub);
assert_eq!(tokens[0].0, "1", "a sub-issue's number is bare: {tokens:?}");
assert_eq!(
tokens[0].1.fg,
Some(Color::Cyan),
"dropping the `#` does not change the number's color"
);
}
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}"
);
}
}