use taffy::geometry::Size;
use taffy::style::AvailableSpace;
use crate::dom::{Arena, Kind, TextWrap};
use crate::layout::MeasureFn;
use crate::text::cli_truncate::{TruncateOptions, TruncatePosition, cli_truncate_with};
use crate::text::string_width::string_width;
use crate::text::wrap_ansi::{WrapOptions, wrap_ansi_with};
pub(crate) fn squash_text(arena: &Arena, id: u32) -> String {
let mut buf = String::new();
squash_into(arena, id, &mut buf);
crate::text::sanitize_ansi::sanitize_ansi(buf)
}
fn squash_into(arena: &Arena, id: u32, buf: &mut String) {
let Some(node) = arena.get(id) else { return };
if let Some(ref t) = node.text {
buf.push_str(t);
}
for &child_id in &node.children {
let Some(child) = arena.get(child_id) else {
continue;
};
if matches!(child.kind, Kind::Text | Kind::VirtualText) {
buf.push_str(&squash_text(arena, child_id));
}
}
}
pub(crate) fn squash_styled(
arena: &Arena,
id: u32,
transform_of: &crate::render::walk::TransformAccessor<'_>,
) -> String {
let mut buf = String::new();
squash_styled_into(arena, id, transform_of, &mut buf);
crate::text::sanitize_ansi::sanitize_ansi(buf)
}
fn squash_styled_into(
arena: &Arena,
id: u32,
transform_of: &crate::render::walk::TransformAccessor<'_>,
buf: &mut String,
) {
let Some(node) = arena.get(id) else { return };
if let Some(ref t) = node.text {
buf.push_str(t);
}
for (index, &child_id) in node.children.iter().enumerate() {
let Some(child) = arena.get(child_id) else {
continue;
};
if !matches!(child.kind, Kind::Text | Kind::VirtualText) {
continue;
}
let child_text = squash_styled(arena, child_id, transform_of);
let transformed = match transform_of(child_id) {
Some(t) if !child_text.is_empty() => t(&child_text, index),
_ => child_text,
};
buf.push_str(&transformed);
}
}
pub(crate) fn measure_text(text: &str) -> (f32, f32) {
if text.is_empty() {
return (0.0, 0.0);
}
let width = text.split('\n').map(string_width).max().unwrap_or(0) as f32;
let height = text.split('\n').count() as f32;
(width, height)
}
pub(crate) fn wrap_text_with_mode(text: &str, width: usize, mode: TextWrap) -> String {
match mode {
TextWrap::Wrap => wrap_ansi_with(
text,
width,
WrapOptions {
hard: true,
word_wrap: true,
trim: false,
},
),
TextWrap::Hard => wrap_ansi_with(
text,
width,
WrapOptions {
hard: true,
word_wrap: false,
trim: false,
},
),
TextWrap::TruncateEnd => cli_truncate_with(
text,
width,
&TruncateOptions {
position: TruncatePosition::End,
..TruncateOptions::default()
},
),
TextWrap::TruncateMiddle => cli_truncate_with(
text,
width,
&TruncateOptions {
position: TruncatePosition::Middle,
..TruncateOptions::default()
},
),
TextWrap::TruncateStart => cli_truncate_with(
text,
width,
&TruncateOptions {
position: TruncatePosition::Start,
..TruncateOptions::default()
},
),
}
}
pub fn build_measure_fn(squashed_text: String, wrap_mode: TextWrap) -> Box<MeasureFn> {
Box::new(
move |known: Size<Option<f32>>, available: Size<AvailableSpace>| {
let constraint: Option<f32> = match known.width {
Some(w) => Some(w),
None => match available.width {
AvailableSpace::Definite(w) => Some(w),
AvailableSpace::MaxContent => None,
AvailableSpace::MinContent => Some(1.0),
},
};
let (iw, ih) = measure_text(&squashed_text);
let effective_w = match constraint {
None => {
return Size {
width: iw,
height: ih,
};
}
Some(w) => w,
};
if iw <= effective_w {
return Size {
width: iw,
height: ih,
};
}
if iw >= 1.0 && effective_w > 0.0 && effective_w < 1.0 {
return Size {
width: iw,
height: ih,
};
}
let cols = effective_w.max(1.0) as usize;
let wrapped = wrap_text_with_mode(&squashed_text, cols, wrap_mode);
let (ww, wh) = measure_text(&wrapped);
Size {
width: ww,
height: wh,
}
},
)
}
fn effective_text_wrap(arena: &Arena, id: u32) -> TextWrap {
if let Some(w) = arena.get(id).and_then(|n| n.style.text_wrap) {
return w;
}
let mut current = arena.get(id).and_then(|n| n.parent);
while let Some(pid) = current {
let Some(parent) = arena.get(pid) else { break };
if !matches!(parent.kind, Kind::Text | Kind::VirtualText) {
break;
}
if let Some(w) = parent.style.text_wrap {
return w;
}
current = parent.parent;
}
TextWrap::default()
}
pub fn build_measure_fn_for(arena: &Arena, id: u32) -> Box<MeasureFn> {
let text = squash_text(arena, id);
let wrap_mode = effective_text_wrap(arena, id);
build_measure_fn(text, wrap_mode)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom::{Arena, Kind, Node, apply};
use crate::layout::{LayoutEngine, TaffyEngine};
fn text_node(text: &str) -> Node {
let mut n = Node::new(Kind::Text);
n.text = Some(text.to_owned());
n
}
fn vtext_node(text: &str) -> Node {
let mut n = Node::new(Kind::VirtualText);
n.text = Some(text.to_owned());
n
}
fn arena_single(text: &str) -> Arena {
let mut a = Arena::new();
a.insert(1, text_node(text));
a
}
#[test]
fn squash_single_text_node() {
let a = arena_single("hello");
assert_eq!(squash_text(&a, 1), "hello");
}
#[test]
fn squash_three_virtual_children() {
let mut a = Arena::new();
a.insert(0, Node::new(Kind::Text));
a.insert(1, vtext_node("hello"));
a.insert(2, vtext_node(" "));
a.insert(3, vtext_node("world"));
let parent = a.get_mut(0).unwrap();
parent.children = vec![1, 2, 3];
assert_eq!(squash_text(&a, 0), "hello world");
}
#[test]
fn squash_nested_virtual_text() {
let mut a = Arena::new();
a.insert(0, Node::new(Kind::Text)); a.insert(1, Node::new(Kind::VirtualText)); a.insert(2, vtext_node("deep"));
a.get_mut(0).unwrap().children = vec![1];
a.get_mut(1).unwrap().children = vec![2];
assert_eq!(squash_text(&a, 0), "deep");
}
#[test]
fn squash_skips_box_children() {
let mut a = Arena::new();
a.insert(0, Node::new(Kind::Text));
a.insert(1, Node::new(Kind::Box));
a.get_mut(0).unwrap().children = vec![1];
assert_eq!(squash_text(&a, 0), "");
}
#[test]
fn squash_transform_flag_does_not_affect_output() {
let mut a = Arena::new();
let mut n = text_node("raw");
n.has_transform = true;
a.insert(1, n);
assert_eq!(squash_text(&a, 1), "raw");
}
#[test]
fn squash_strips_embedded_clear_screen() {
let a = arena_single("A\x1b[2JB");
assert_eq!(squash_text(&a, 1), "AB");
}
#[test]
fn squash_keeps_sgr_verbatim() {
let a = arena_single("A\x1b[31mR\x1b[39mB");
assert_eq!(squash_text(&a, 1), "A\x1b[31mR\x1b[39mB");
}
#[test]
fn squash_sanitizes_each_nested_level_like_ink() {
let mut a = Arena::new();
a.insert(0, Node::new(Kind::Text));
a.insert(1, vtext_node("A\x1b]x")); a.insert(2, vtext_node("B"));
a.get_mut(0).unwrap().children = vec![1, 2];
assert_eq!(squash_text(&a, 0), "AB");
}
#[test]
fn squash_styled_strips_and_keeps_like_squash_text() {
let a = arena_single("A\x1b[2J\x1b[31mR\x1b[39mB");
assert_eq!(squash_styled(&a, 1, &|_| None), "A\x1b[31mR\x1b[39mB");
}
#[test]
fn squash_styled_nested_transform_nonsgr_restripped_by_parent() {
let mut a = Arena::new();
a.insert(0, Node::new(Kind::Text));
a.insert(1, vtext_node("s")); a.insert(2, vtext_node("Z")); a.get_mut(0).unwrap().children = vec![1, 2];
let accessor = |id: u32| -> Option<crate::render::walk::LineTransform<'_>> {
(id == 1).then(|| {
Box::new(|_s: &str, _i: usize| "X\x1b[2JY".to_owned())
as crate::render::walk::LineTransform<'_>
})
};
assert_eq!(squash_styled(&a, 0, &accessor), "XYZ");
}
#[test]
fn squash_styled_nested_transform_sgr_survives_parent() {
let mut a = Arena::new();
a.insert(0, Node::new(Kind::Text));
a.insert(1, vtext_node("R")); a.insert(2, vtext_node("B")); a.get_mut(0).unwrap().children = vec![1, 2];
let accessor = |id: u32| -> Option<crate::render::walk::LineTransform<'_>> {
(id == 1).then(|| {
Box::new(|s: &str, _i: usize| format!("\x1b[31m{s}\x1b[39m"))
as crate::render::walk::LineTransform<'_>
})
};
assert_eq!(squash_styled(&a, 0, &accessor), "\x1b[31mR\x1b[39mB");
}
#[test]
fn measure_empty() {
assert_eq!(measure_text(""), (0.0, 0.0));
}
#[test]
fn measure_single_line() {
assert_eq!(measure_text("hello"), (5.0, 1.0));
}
#[test]
fn measure_multi_line() {
assert_eq!(measure_text("hello\nworld foo"), (9.0, 2.0));
}
#[test]
fn measure_ansi_styled_width_3() {
assert_eq!(measure_text("\x1b[31mred\x1b[39m"), (3.0, 1.0));
}
#[test]
fn measure_cjk_wide_chars() {
assert_eq!(measure_text("中文"), (4.0, 1.0));
}
#[test]
fn measure_emoji_width_2() {
assert_eq!(measure_text("\u{1F600}"), (2.0, 1.0));
}
#[test]
fn measure_widest_not_first_line() {
assert_eq!(measure_text("hi\nhello world"), (11.0, 2.0));
}
#[test]
fn closure_unconstrained_returns_intrinsic() {
let f = build_measure_fn("hello".to_owned(), TextWrap::Wrap);
let mut f = f;
let size = f(
Size {
width: None,
height: None,
},
Size {
width: AvailableSpace::MaxContent,
height: AvailableSpace::MaxContent,
},
);
assert_eq!(
size,
Size {
width: 5.0,
height: 1.0
}
);
}
#[test]
fn closure_constrained_fits_returns_intrinsic() {
let f = build_measure_fn("hi".to_owned(), TextWrap::Wrap);
let mut f = f;
let size = f(
Size {
width: None,
height: None,
},
Size {
width: AvailableSpace::Definite(10.0),
height: AvailableSpace::MaxContent,
},
);
assert_eq!(
size,
Size {
width: 2.0,
height: 1.0
}
);
}
#[test]
fn closure_sub_pixel_guard_returns_intrinsic() {
let f = build_measure_fn("hello".to_owned(), TextWrap::Wrap);
let mut f = f;
let size = f(
Size {
width: None,
height: None,
},
Size {
width: AvailableSpace::Definite(0.5),
height: AvailableSpace::MaxContent,
},
);
assert_eq!(
size,
Size {
width: 5.0,
height: 1.0
}
);
}
#[test]
fn closure_wrap_mode_wraps_text() {
let f = build_measure_fn("hello world".to_owned(), TextWrap::Wrap);
let mut f = f;
let size = f(
Size {
width: None,
height: None,
},
Size {
width: AvailableSpace::Definite(5.0),
height: AvailableSpace::MaxContent,
},
);
assert_eq!(size.height, 3.0, "wrap:true,trim:false produces 3 lines");
assert_eq!(size.width, 5.0);
}
#[test]
fn closure_known_width_takes_precedence() {
let f = build_measure_fn("hello world".to_owned(), TextWrap::Wrap);
let mut f = f;
let size = f(
Size {
width: Some(5.0),
height: None,
},
Size {
width: AvailableSpace::MaxContent,
height: AvailableSpace::MaxContent,
},
);
assert_eq!(size.height, 3.0);
}
#[test]
fn e2e_constrained_wrapped_height() {
use crate::dom::Dim;
let mut a = Arena::new();
a.insert(0, Node::new(Kind::Root));
a.insert(1, Node::new(Kind::Box));
a.insert(2, {
let mut n = Node::new(Kind::Text);
n.text = Some("hello world".to_owned());
n
});
a.get_mut(1).unwrap().children = vec![2];
a.get_mut(0).unwrap().children = vec![1];
a.get_mut(2).unwrap().parent = Some(1);
a.get_mut(1).unwrap().parent = Some(0);
let mut e = TaffyEngine::new();
e.create(0).unwrap();
e.create(1).unwrap();
e.create(2).unwrap();
e.insert_child(0, 1, 0).unwrap();
e.insert_child(1, 2, 0).unwrap();
let root_style = crate::dom::Style {
width: Some(Dim::Points(80.0)),
height: Some(Dim::Points(24.0)),
..Default::default()
};
let box_style = crate::dom::Style {
width: Some(Dim::Points(10.0)),
align_items: Some(crate::dom::Align::FlexStart),
..Default::default()
};
e.apply_style(0, &root_style).unwrap();
e.apply_style(1, &box_style).unwrap();
e.set_measure(2, build_measure_fn_for(&a, 2));
e.calculate(0, 80.0, Some(24.0)).unwrap();
let text_rect = e.computed(2).unwrap();
assert_eq!(text_rect.height, 2, "wrapped height should be 2");
}
#[test]
fn e2e_unconstrained_intrinsic_width() {
let mut a = Arena::new();
a.insert(0, {
let mut n = Node::new(Kind::Root);
n.children = vec![1];
n
});
a.insert(1, {
let mut n = Node::new(Kind::Text);
n.text = Some("hello".to_owned());
n.parent = Some(0);
n
});
let mut e = TaffyEngine::new();
e.create(0).unwrap();
e.create(1).unwrap();
e.insert_child(0, 1, 0).unwrap();
let root_style = crate::dom::Style {
width: Some(crate::dom::Dim::Points(80.0)),
height: Some(crate::dom::Dim::Points(24.0)),
align_items: Some(crate::dom::Align::FlexStart),
..Default::default()
};
e.apply_style(0, &root_style).unwrap();
e.set_measure(1, build_measure_fn_for(&a, 1));
e.calculate(0, 80.0, Some(24.0)).unwrap();
let r = e.computed(1).unwrap();
assert_eq!(r.width, 5, "intrinsic width should be 5");
assert_eq!(r.height, 1, "intrinsic height should be 1");
}
#[test]
fn wrap_mode_hard_pins_mid_word_break() {
let derived = wrap_ansi_with(
"hello world",
8,
WrapOptions {
hard: true,
word_wrap: false,
trim: false,
},
);
let got = wrap_text_with_mode("hello world", 8, TextWrap::Hard);
assert_eq!(got, derived, "Hard mode must use word_wrap:false");
assert_eq!(got, "hello wo\nrld");
}
#[test]
fn wrap_mode_truncate_end_pins_literal() {
let derived = cli_truncate_with(
"hello world",
8,
&TruncateOptions {
position: TruncatePosition::End,
..TruncateOptions::default()
},
);
let got = wrap_text_with_mode("hello world", 8, TextWrap::TruncateEnd);
assert_eq!(got, derived, "TruncateEnd must use position:End");
assert_eq!(got, "hello w\u{2026}");
}
#[test]
fn wrap_mode_truncate_middle_pins_literal() {
let derived = cli_truncate_with(
"hello world",
8,
&TruncateOptions {
position: TruncatePosition::Middle,
..TruncateOptions::default()
},
);
let got = wrap_text_with_mode("hello world", 8, TextWrap::TruncateMiddle);
assert_eq!(got, derived, "TruncateMiddle must use position:Middle");
assert_eq!(got, "hell\u{2026}rld");
}
#[test]
fn wrap_mode_truncate_start_pins_literal() {
let derived = cli_truncate_with(
"hello world",
8,
&TruncateOptions {
position: TruncatePosition::Start,
..TruncateOptions::default()
},
);
let got = wrap_text_with_mode("hello world", 8, TextWrap::TruncateStart);
assert_eq!(got, derived, "TruncateStart must use position:Start");
assert_eq!(got, "\u{2026}o world");
}
#[test]
fn measure_truncate_end_height_1_width_le_w() {
let f = build_measure_fn("hello world".to_owned(), TextWrap::TruncateEnd);
let mut f = f;
let size = f(
Size {
width: None,
height: None,
},
Size {
width: AvailableSpace::Definite(8.0),
height: AvailableSpace::MaxContent,
},
);
assert_eq!(size.height, 1.0, "truncate must not wrap: height must be 1");
assert_eq!(size.width, 8.0, "truncated width must equal 8");
}
#[test]
fn measure_sgr_and_osc8_hyperlink_width_equals_visible_width() {
let s = "\x1b[31mhi\x1b[39m\x1b]8;;https://example.com\x07ok\x1b]8;;\x07";
assert_eq!(string_width(s), 4, "string_width must strip SGR and OSC 8");
let (w, h) = measure_text(s);
assert_eq!(w, 4.0, "measured width must equal visible width 4");
assert_eq!(h, 1.0, "no newline → height 1");
}
#[test]
fn build_measure_fn_for_reads_text_wrap_style() {
let mut a = arena_single("hello world");
a.get_mut(1).unwrap().style.text_wrap = Some(TextWrap::TruncateEnd);
let mut f = build_measure_fn_for(&a, 1);
let size = f(
Size {
width: None,
height: None,
},
Size {
width: AvailableSpace::Definite(8.0),
height: AvailableSpace::MaxContent,
},
);
assert_eq!(size.height, 1.0, "style read must select the truncate path");
assert_eq!(size.width, 8.0);
}
fn two_node_text_arena(parent_wrap: Option<TextWrap>, text: &str) -> Arena {
use crate::dom::Op;
let mut a = Arena::new();
let mut parent = Node::new(Kind::Text);
parent.style.text_wrap = parent_wrap;
a.insert(1, parent);
a.insert(2, text_node(text));
apply(
&mut a,
&[Op::AppendChild {
parent: 1,
child: 2,
}],
);
a
}
fn measure_at(a: &Arena, id: u32, width: f32) -> Size<f32> {
let mut f = build_measure_fn_for(a, id);
f(
Size {
width: None,
height: None,
},
Size {
width: AvailableSpace::Definite(width),
height: AvailableSpace::MaxContent,
},
)
}
#[test]
fn child_text_inherits_truncate_from_parent_ink_text() {
let a = two_node_text_arena(Some(TextWrap::TruncateEnd), "hello world");
let size = measure_at(&a, 2, 8.0);
assert_eq!(
size.height, 1.0,
"the #text child must inherit truncate from its ink-text parent (height 1, not the Wrap-folded 2)"
);
assert_eq!(size.width, 8.0, "truncated width equals the constraint");
}
#[test]
fn child_text_inherits_hard_from_parent_ink_text() {
let a = two_node_text_arena(Some(TextWrap::Hard), "hello world");
let size = measure_at(&a, 2, 8.0);
assert_eq!(size.height, 2.0, "Hard mode reaches the leaf (2 lines)");
assert_eq!(
size.width, 8.0,
"Hard breaks mid-word → widest line is the full 8 cols (Wrap would give 6)"
);
}
#[test]
fn child_text_inherits_wrap_from_parent_is_unchanged() {
let a = two_node_text_arena(Some(TextWrap::Wrap), "hello world");
let size = measure_at(&a, 2, 8.0);
assert_eq!(
size.height, 2.0,
"Wrap parent still wraps the child (height 2)"
);
assert_eq!(size.width, 6.0, "Wrap word-boundary widest line is 6");
}
#[test]
fn child_text_with_no_ancestor_wrap_defaults_to_wrap() {
let a = two_node_text_arena(None, "hello world");
let size = measure_at(&a, 2, 8.0);
assert_eq!(
size.height, 2.0,
"absent ancestor wrap → default Wrap (height 2)"
);
assert_eq!(size.width, 6.0);
}
#[test]
fn child_text_own_wrap_overrides_parent() {
let mut a = two_node_text_arena(Some(TextWrap::TruncateEnd), "hello world");
a.get_mut(2).unwrap().style.text_wrap = Some(TextWrap::Wrap);
let size = measure_at(&a, 2, 8.0);
assert_eq!(
size.height, 2.0,
"the child's own Wrap must win over the parent's truncate"
);
assert_eq!(size.width, 6.0);
}
}