use ratatui::{
style::{Color, Modifier, Style},
text::Span,
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::api::types::{Label, Priority, StateType, User, WorkflowState, hex_color};
use crate::config::Theme;
pub fn user_name(user: &User) -> &str {
user.display_name.as_deref().unwrap_or(&user.name)
}
pub fn state_color(state: Option<&WorkflowState>, theme: &Theme) -> Color {
let Some(state) = state else {
return theme.muted;
};
state
.color
.as_deref()
.and_then(hex_color)
.or_else(|| state.state_type.map(|t| t.color()))
.unwrap_or(theme.text_dim)
}
pub fn state_glyph(state: Option<&WorkflowState>, theme: &Theme) -> Span<'static> {
let glyph = state
.and_then(|s| s.state_type)
.unwrap_or(StateType::Unknown)
.glyph();
Span::styled(glyph, Style::default().fg(state_color(state, theme)))
}
pub fn priority_glyph(priority: Priority, theme: &Theme) -> Span<'static> {
let style = Style::default().fg(priority.color(theme));
let style = if priority == Priority::Urgent {
style.add_modifier(Modifier::BOLD)
} else {
style
};
Span::styled(priority.glyph(), style)
}
pub fn label_chip(label: &Label, theme: &Theme) -> Vec<Span<'static>> {
let dot = label
.color
.as_deref()
.and_then(hex_color)
.unwrap_or(theme.muted);
vec![
Span::styled(" \u{25cf}", Style::default().fg(dot).bg(theme.chip_bg)),
Span::styled(
format!(" {} ", label.name),
Style::default().fg(theme.text_dim).bg(theme.chip_bg),
),
Span::raw(" "),
]
}
pub fn initials(name: &str) -> String {
let mut parts = name
.split(|c: char| c.is_whitespace() || c == '.' || c == '_' || c == '-')
.filter(|p| !p.is_empty());
let first = parts.next().and_then(|p| p.chars().next());
let second = parts.next().and_then(|p| p.chars().next());
match (first, second) {
(Some(a), Some(b)) => format!("{}{}", a, b).to_uppercase(),
(Some(_), None) => name.chars().take(2).collect::<String>().to_uppercase(),
_ => "?".to_string(),
}
}
pub fn person_color(name: &str) -> Color {
const PALETTE: [Color; 8] = [
Color::Rgb(240, 130, 110),
Color::Rgb(240, 180, 90),
Color::Rgb(150, 200, 110),
Color::Rgb(90, 190, 170),
Color::Rgb(100, 170, 240),
Color::Rgb(150, 140, 240),
Color::Rgb(210, 130, 220),
Color::Rgb(230, 120, 160),
];
let hash = name
.bytes()
.fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32));
PALETTE[(hash % PALETTE.len() as u32) as usize]
}
pub fn person(user: Option<&User>, theme: &Theme) -> Vec<Span<'static>> {
match user {
Some(user) => {
let name = user_name(user).to_string();
vec![
Span::styled(
initials(&name),
Style::default()
.fg(Color::Black)
.bg(person_color(&name))
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(name, Style::default().fg(theme.text)),
]
}
None => vec![
Span::styled("\u{25cc} ", Style::default().fg(theme.muted)),
Span::styled("Unassigned", Style::default().fg(theme.muted)),
],
}
}
pub fn truncate(text: &str, width: usize) -> String {
if text.width() <= width {
return text.to_string();
}
if width == 0 {
return String::new();
}
let mut out = String::new();
let mut used = 0;
for c in text.chars() {
let w = c.width().unwrap_or(0);
if used + w + 1 > width {
break;
}
out.push(c);
used += w;
}
out.push('\u{2026}');
out
}
pub fn fit(text: &str, width: usize) -> String {
let cut = truncate(text, width);
let pad = width.saturating_sub(cut.width());
format!("{cut}{}", " ".repeat(pad))
}
pub fn short_date(ts: Option<&str>) -> String {
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let Some(ts) = ts else {
return String::new();
};
let month = ts.get(5..7).and_then(|m| m.parse::<usize>().ok());
let day = ts.get(8..10).and_then(|d| d.parse::<u32>().ok());
match (month, day) {
(Some(m @ 1..=12), Some(d)) => format!("{} {}", MONTHS[m - 1], d),
_ => ts.get(..10).unwrap_or(ts).to_string(),
}
}
pub fn progress_bar(progress: f64, width: usize, fill: Color, theme: &Theme) -> Vec<Span<'static>> {
const EIGHTHS: [&str; 8] = [
"", "\u{258f}", "\u{258e}", "\u{258d}", "\u{258c}", "\u{258b}", "\u{258a}", "\u{2589}",
];
let progress = progress.clamp(0.0, 1.0);
let total = (progress * width as f64 * 8.0).round() as usize;
let full = total / 8;
let part = EIGHTHS[total % 8];
let used = full + usize::from(!part.is_empty());
vec![
Span::styled(
"\u{2588}".repeat(full),
Style::default().fg(fill).bg(theme.chip_bg),
),
Span::styled(part, Style::default().fg(fill).bg(theme.chip_bg)),
Span::styled(
" ".repeat(width.saturating_sub(used)),
Style::default().bg(theme.chip_bg),
),
]
}
pub fn row(
mut left: Vec<Span<'static>>,
right: Vec<Span<'static>>,
width: usize,
selected: bool,
theme: &Theme,
) -> ratatui::text::Line<'static> {
let used: usize = left.iter().chain(right.iter()).map(|s| s.width()).sum();
left.insert(
0,
if selected {
Span::styled("\u{258c}", Style::default().fg(theme.accent))
} else {
Span::raw(" ")
},
);
left.push(Span::raw(" ".repeat(width.saturating_sub(used + 1))));
left.extend(right);
if selected {
for span in &mut left {
span.style = span.style.bg(theme.selection_bg);
}
}
ratatui::text::Line::from(left)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncation_counts_terminal_cells() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 6), "hello\u{2026}");
assert_eq!(truncate("日本語テキスト", 7), "日本語\u{2026}");
assert_eq!(truncate("anything", 0), "");
}
#[test]
fn fit_pads_to_the_exact_width() {
assert_eq!(fit("ab", 4), "ab ");
assert_eq!(fit("日本", 5).width(), 5);
}
#[test]
fn initials_take_one_letter_per_name_part() {
assert_eq!(initials("Shun Kimura"), "SK");
assert_eq!(initials("masakazu.ishida"), "MI");
assert_eq!(initials("k1c"), "K1");
assert_eq!(initials(""), "?");
}
#[test]
fn a_person_always_gets_the_same_colour() {
assert_eq!(person_color("alice"), person_color("alice"));
}
#[test]
fn a_progress_bar_is_exactly_as_wide_as_asked() {
let th = Theme::from_name(crate::config::ThemeName::Default);
for p in [0.0, 0.13, 0.5, 0.99, 1.0, 7.0] {
let bar = progress_bar(p, 10, Color::Green, &th);
let w: usize = bar.iter().map(|s| s.width()).sum();
assert_eq!(w, 10, "progress {p}");
}
}
#[test]
fn short_dates_read_like_linear() {
assert_eq!(short_date(Some("2026-09-10T12:00:00.000Z")), "Sep 10");
assert_eq!(short_date(Some("2026-01-02")), "Jan 2");
assert_eq!(short_date(None), "");
}
}