use unicode_width::UnicodeWidthStr;
use crate::timeline::{Timeline, TimelineSection};
const INDENT: &str = " ";
const BULLET_CONN: &str = " \u{25cf}\u{2500}\u{2500} "; const CONT_CONN: &str = " \u{2514}\u{2500}\u{2500} "; const SECTION_RULE_MIN: usize = 4;
const SECTION_RULE_TARGET: usize = 50;
pub fn render(diag: &Timeline, max_width: Option<usize>) -> String {
let mut out = String::new();
if let Some(title) = diag.title.as_deref() {
out.push_str("Timeline: ");
out.push_str(title);
out.push('\n');
}
for section in &diag.sections {
out.push('\n');
render_section(&mut out, section, max_width);
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn render_section(out: &mut String, section: &TimelineSection, max_width: Option<usize>) {
if let Some(name) = section.name.as_deref() {
out.push_str("\u{2500}\u{2500} "); out.push_str(name);
out.push(' ');
let used = 3 + UnicodeWidthStr::width(name) + 1;
let dashes = SECTION_RULE_TARGET
.saturating_sub(used)
.max(SECTION_RULE_MIN);
for _ in 0..dashes {
out.push('\u{2500}'); }
out.push('\n');
}
let period_col = section
.entries
.iter()
.map(|e| UnicodeWidthStr::width(e.period.as_str()))
.max()
.unwrap_or(0);
let bullet_conn_w = UnicodeWidthStr::width(BULLET_CONN);
let prefix_w = INDENT.len() + period_col + bullet_conn_w;
let cont_conn_w = UnicodeWidthStr::width(CONT_CONN);
let cont_pad_w = (INDENT.len() + period_col).saturating_sub(0);
for entry in §ion.entries {
let period_w = UnicodeWidthStr::width(entry.period.as_str());
let pad = period_col.saturating_sub(period_w);
let first_event = entry.events.first().map(String::as_str).unwrap_or("");
let first_truncated = maybe_truncate(first_event, max_width, prefix_w);
out.push_str(INDENT);
out.push_str(&entry.period);
for _ in 0..pad {
out.push(' ');
}
out.push_str(BULLET_CONN);
out.push_str(&first_truncated);
out.push('\n');
for event in entry.events.iter().skip(1) {
let continuation_prefix_w = cont_pad_w + cont_conn_w;
let event_truncated = maybe_truncate(event, max_width, continuation_prefix_w);
for _ in 0..INDENT.len() + period_col {
out.push(' ');
}
out.push_str(CONT_CONN);
out.push_str(&event_truncated);
out.push('\n');
}
}
}
fn maybe_truncate(text: &str, max_width: Option<usize>, prefix_cols: usize) -> String {
let Some(budget) = max_width else {
return text.to_string();
};
let available = budget.saturating_sub(prefix_cols);
let text_w = UnicodeWidthStr::width(text);
if text_w <= available {
return text.to_string();
}
let target = available.saturating_sub(1);
let mut result = String::with_capacity(target * 3 + 3);
let mut used = 0;
for ch in text.chars() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if used + w > target {
break;
}
result.push(ch);
used += w;
}
result.push('\u{2026}'); result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::timeline::parse;
#[test]
fn renders_title_when_present() {
let diag = parse(
"timeline\n\
title History of Social Media\n\
2002 : LinkedIn",
)
.unwrap();
let out = render(&diag, None);
assert!(
out.starts_with("Timeline: History of Social Media"),
"got: {out:?}"
);
}
#[test]
fn renders_multi_event_period_with_all_events() {
let diag = parse(
"timeline\n\
2004 : Facebook : Google goes public",
)
.unwrap();
let out = render(&diag, None);
assert!(out.contains("Facebook"), "got: {out:?}");
assert!(out.contains("Google goes public"), "got: {out:?}");
assert!(out.contains('\u{2514}'), "└ connector missing in:\n{out}");
}
#[test]
fn multiple_sections_are_visually_separated() {
let diag = parse(
"timeline\n\
section 2002-2004\n\
2002 : LinkedIn\n\
section 2005-2008\n\
2005 : YouTube",
)
.unwrap();
let out = render(&diag, None);
assert!(out.contains("2002-2004"), "first section header missing");
assert!(out.contains("2005-2008"), "second section header missing");
assert!(out.contains("\n\n"), "no blank line between sections");
}
#[test]
fn max_width_truncates_long_event_text() {
let long_event = "A".repeat(80);
let src = format!("timeline\n2002 : {long_event}");
let diag = parse(&src).unwrap();
let out = render(&diag, Some(40));
for line in out.lines() {
let w = UnicodeWidthStr::width(line);
assert!(w <= 40, "line exceeds max_width=40 ({w} cells): {line:?}");
}
assert!(out.contains('\u{2026}'), "ellipsis not inserted");
}
#[test]
fn renders_unnamed_section_without_header_rule() {
let diag = parse("timeline\n2002 : LinkedIn").unwrap();
let out = render(&diag, None);
let has_rule_line = out.lines().any(|l| l.starts_with("\u{2500}\u{2500} "));
assert!(!has_rule_line, "unexpected section rule in:\n{out}");
assert!(out.contains("2002"));
assert!(out.contains("LinkedIn"));
}
}