use super::*;
use crate::base::{Rgba, Size};
use crate::render::cell::{Attrs, Cell};
fn bold() -> Style {
Style::new().attrs(Attrs::BOLD)
}
fn red() -> Style {
Style::new().fg(Rgba::rgb(255, 0, 0))
}
fn surf(w: i32, h: i32) -> Surface {
Surface::new(Size::new(w, h), Cell::EMPTY)
}
fn line(spans: Vec<Span>) -> RichLine {
let mut l = RichLine::new();
for s in spans {
l.push(s);
}
l
}
fn row_text(s: &Surface, y: i32) -> String {
(0..s.width())
.filter_map(|x| {
let c = s.get(x, y).unwrap();
if c.is_continuation() {
return None;
}
let t = s.glyph_str(c);
Some(if t.is_empty() {
".".to_string()
} else {
t.to_string()
})
})
.collect()
}
#[test]
fn push_coalesces_same_ink() {
let mut l = RichLine::new();
l.push(Span::plain("ab"));
l.push(Span::plain("cd"));
l.push(Span::new("EF", bold()));
assert_eq!(l.spans.len(), 2, "same-ink spans merge: {:?}", l.spans);
assert_eq!(l.plain(), "abcdEF");
assert_eq!(l.width(), 6);
}
#[test]
fn wrap_preserves_spans_across_boundaries() {
let rt = RichText::from_lines(vec![line(vec![
Span::new("bold br", bold()),
Span::plain("ave new world"),
])]);
let wrapped = rt.wrap(10);
assert_eq!(
wrapped
.lines
.iter()
.map(RichLine::plain)
.collect::<Vec<_>>(),
vec!["bold brave", "new world"],
);
let l0 = &wrapped.lines[0];
assert_eq!(l0.spans.len(), 2, "{:?}", l0.spans);
assert!(l0.spans[0].style.add.contains(Attrs::BOLD));
assert_eq!(l0.spans[0].text, "bold br");
assert_eq!(l0.spans[1].text, "ave");
assert!(!l0.spans[1].style.add.contains(Attrs::BOLD));
let l1 = &wrapped.lines[1];
assert_eq!(l1.spans.len(), 1);
assert!(!l1.spans[0].style.add.contains(Attrs::BOLD));
let narrow = rt.wrap(6);
assert_eq!(
narrow.lines.iter().map(RichLine::plain).collect::<Vec<_>>(),
vec!["bold", "brave", "new", "world"],
);
let brave = &narrow.lines[1];
assert_eq!(
brave.spans.len(),
2,
"bold 'br' + plain 'ave': {:?}",
brave.spans
);
assert!(brave.spans[0].style.add.contains(Attrs::BOLD));
assert_eq!(brave.spans[0].text, "br");
assert_eq!(brave.spans[1].text, "ave");
}
#[test]
fn wrap_matches_plain_text_wrap() {
let cases = [
"the quick brown fox jumps",
"a b c",
"abcdefghij",
"城市化进程加快 ok",
"one\n\ntwo lines",
"",
];
for case in cases {
for width in [1, 3, 5, 9] {
let rich: Vec<String> = RichText::plain(case, Style::EMPTY)
.wrap(width)
.lines
.iter()
.map(RichLine::plain)
.collect();
let plain = crate::text::wrap(case, width);
assert_eq!(rich, plain, "case {case:?} width {width}");
}
}
}
#[test]
fn wrap_mixed_width_content() {
let rt = RichText::from_lines(vec![line(vec![
Span::new("警告", red()),
Span::plain(" disk full 世界"),
])]);
let wrapped = rt.wrap(8);
for l in &wrapped.lines {
assert!(l.width() <= 8, "line overflows: {:?}", l.plain());
}
assert!(wrapped.lines[0].spans[0].style.fg.is_some());
assert_eq!(wrapped.lines[0].spans[0].text, "警告");
}
#[test]
fn wrap_keeps_link_runs() {
let rt = RichText::from_lines(vec![line(vec![
Span::plain("see "),
Span::new("the documentation page", Style::new()).with_link("https://example.com/docs"),
])]);
let wrapped = rt.wrap(10);
let mut linked = Vec::new();
for l in &wrapped.lines {
for s in &l.spans {
if s.link.as_deref() == Some("https://example.com/docs") {
linked.push(s.text.trim().to_string());
}
}
}
let linked: Vec<&str> = linked
.iter()
.map(|s| s.as_str())
.filter(|s| !s.is_empty())
.collect();
assert_eq!(linked, vec!["the", "documentat", "ion page"]);
let all_linked_text: String = linked.join(" ");
assert_eq!(all_linked_text, "the documentat ion page");
assert!(wrapped.lines[0].spans[0].link.is_none());
}
#[test]
fn draw_aligns_and_patches_over_panel() {
let mut s = surf(12, 3);
s.fill_rect(s.bounds(), Cell::EMPTY.with_bg(Rgba::rgb(10, 10, 30)));
let rt = RichText::from_lines(vec![
line(vec![Span::new("hi", bold())]),
line(vec![Span::plain("mid")]),
line(vec![Span::plain("end")]),
]);
let mut left = s;
rt.draw(&mut left, Rect::new(0, 0, 12, 3), HAlign::Left);
assert_eq!(row_text(&left, 0)[..2].to_string(), "hi");
assert_eq!(
left.get(0, 0).unwrap().bg,
Rgba::rgb(10, 10, 30),
"panel bg shows through"
);
assert!(left.get(0, 0).unwrap().attrs.contains(Attrs::BOLD));
let mut center = surf(12, 3);
rt.draw(&mut center, Rect::new(0, 0, 12, 3), HAlign::Center);
assert!(!center.get(5, 0).unwrap().is_continuation());
assert_eq!(row_text(¢er, 0), ".....hi.....");
let mut right = surf(12, 3);
rt.draw(&mut right, Rect::new(0, 0, 12, 3), HAlign::Right);
assert_eq!(row_text(&right, 1), ".........mid");
}
#[test]
fn draw_truncates_overwide_lines_with_ellipsis() {
let mut s = surf(8, 1);
let rt = RichText::from_lines(vec![line(vec![
Span::plain("abc"),
Span::new("defghijk", red()),
])]);
rt.draw(&mut s, Rect::new(0, 0, 8, 1), HAlign::Left);
assert_eq!(row_text(&s, 0), "abcdefg…");
assert_eq!(s.get(7, 0).unwrap().fg, Rgba::rgb(255, 0, 0));
s.debug_validate().unwrap();
let mut s = surf(6, 1);
let rt = RichText::from_lines(vec![line(vec![Span::plain("ab世界人")])]);
rt.draw(&mut s, Rect::new(0, 0, 6, 1), HAlign::Left);
assert_eq!(row_text(&s, 0), "ab世…."); s.debug_validate().unwrap();
}
#[test]
fn draw_clips_height_and_registers_links() {
let mut s = surf(20, 2);
let rt = RichText::from_lines(vec![
line(vec![
Span::new("click here", Style::new()).with_link("https://a.example")
]),
line(vec![Span::plain("second")]),
line(vec![Span::plain("dropped")]),
]);
rt.draw(&mut s, Rect::new(0, 0, 20, 2), HAlign::Left);
let cell = s.get(0, 0).unwrap();
assert_eq!(
s.link_uri(cell.link),
Some("https://a.example"),
"URL registered at draw"
);
assert_eq!(row_text(&s, 1)[..6].to_string(), "second");
assert!(s.get(0, 1).is_some());
}
#[test]
fn from_highlighted_bridges_the_lexer() {
use crate::text::{CLikeLexer, TokenKind};
let kw = Style::new().fg(Rgba::rgb(200, 120, 255));
let stringy = Style::new().fg(Rgba::rgb(120, 200, 120));
let base = Style::new().fg(Rgba::rgb(220, 220, 220));
let l =
RichLine::from_highlighted(r#"let s = "hi";"#, &CLikeLexer::rust(), base, |k| match k {
TokenKind::Keyword => kw,
TokenKind::String => stringy,
_ => base,
});
assert_eq!(l.plain(), r#"let s = "hi";"#, "no bytes lost");
assert_eq!(l.spans[0].text, "let");
assert_eq!(l.spans[0].style, kw);
assert!(l
.spans
.iter()
.any(|s| s.text == r#""hi""# && s.style == stringy));
}
#[test]
fn wrap_scales_linearly_in_span_count_structurally() {
let mut l = RichLine::new();
for i in 0..1000 {
let style = if i % 2 == 0 { bold() } else { Style::EMPTY };
l.push(Span::new(format!("w{i:03} "), style));
}
let rt = RichText::from_lines(vec![l]);
let wrapped = rt.wrap(40);
let total_spans: usize = wrapped.lines.iter().map(|l| l.spans.len()).sum();
let total_clusters: usize = wrapped.lines.iter().map(|l| l.plain().len()).sum();
assert!(
total_spans * 2 <= total_clusters,
"spans stay far below clusters (coalescing works): {total_spans} vs {total_clusters}"
);
let uniform = RichText::plain(&"word ".repeat(2000), Style::EMPTY).wrap(50);
assert!(uniform.lines.iter().all(|l| l.spans.len() <= 1));
}
#[test]
fn empty_lines_and_zero_rect_are_inert() {
let rt = RichText::plain("a\n\nb", Style::EMPTY);
assert_eq!(rt.height(), 3);
let wrapped = rt.wrap(5);
assert_eq!(wrapped.height(), 3, "empty line survives wrap");
let mut s = surf(4, 2);
wrapped.draw(&mut s, Rect::ZERO, HAlign::Left); assert_eq!(row_text(&s, 0), "....");
}