pub mod background;
pub mod border;
pub mod cli_boxes;
pub mod colorize;
#[cfg(test)]
mod colorize_chalk_parity_tests;
pub mod grid;
pub mod walk;
pub use colorize::{ColorLevel, Kind as ColorKind, colorize, dim};
use crate::dom::{Arena, Kind};
use crate::layout::{LayoutEngine, Rect, TaffyEngine};
use crate::render::grid::Grid;
use crate::render::walk::{TransformAccessor, walk, walk_static};
use crate::text_measure::build_measure_fn_for;
pub fn render_to_string(arena: &Arena, root_id: u32, width: u16) -> String {
render_styled(arena, root_id, width, &|_| None, ColorLevel::Truecolor).0
}
pub fn render_styled<'a>(
arena: &'a Arena,
root_id: u32,
width: u16,
transform_of: &'a TransformAccessor<'a>,
color_level: ColorLevel,
) -> (String, u16) {
let Some((engine, root_rect)) = build_layout_engine(arena, root_id, width) else {
return (String::new(), 0);
};
let grid_rows = root_rect.height as usize;
let grid_cols = root_rect.width as usize;
if grid_rows == 0 || grid_cols == 0 {
return (String::new(), 0);
}
let mut grid = Grid::new(grid_rows, grid_cols);
let rect_fn = |id: u32| engine.computed(id);
walk(
arena,
root_id,
&rect_fn,
transform_of,
&mut grid,
color_level,
);
let (output, height) = grid.get();
(output, height as u16)
}
pub fn render_static<'a>(
arena: &'a Arena,
root_id: u32,
width: u16,
transform_of: &'a TransformAccessor<'a>,
color_level: ColorLevel,
) -> String {
let Some(static_id) = find_static_node(arena, root_id) else {
return String::new();
};
let Some((engine, _root_rect)) = build_layout_engine(arena, root_id, width) else {
return String::new();
};
let Some(static_rect) = engine.computed(static_id) else {
return String::new();
};
let grid_rows = static_rect.height as usize;
let grid_cols = static_rect.width as usize;
if grid_rows == 0 || grid_cols == 0 {
return "\n".to_owned();
}
let mut grid = Grid::new(grid_rows, grid_cols);
let rect_fn = |id: u32| engine.computed(id);
walk_static(
arena,
static_id,
&rect_fn,
transform_of,
&mut grid,
color_level,
);
let (body, _height) = grid.get();
format!("{body}\n")
}
fn find_static_node(arena: &Arena, id: u32) -> Option<u32> {
let node = arena.get(id)?;
if node.is_static {
return Some(id);
}
for &child_id in &node.children {
if let Some(found) = find_static_node(arena, child_id) {
return Some(found);
}
}
None
}
pub fn build_layout_engine(arena: &Arena, root_id: u32, width: u16) -> Option<(TaffyEngine, Rect)> {
let mut engine = TaffyEngine::new();
create_layout_nodes(arena, root_id, &mut engine);
if engine.calculate(root_id, width as f32, None).is_err() {
return None;
}
let root_rect = engine.computed(root_id).unwrap_or(Rect {
x: 0,
y: 0,
width,
height: 0,
});
Some((engine, root_rect))
}
fn create_layout_nodes(arena: &Arena, id: u32, engine: &mut TaffyEngine) {
let Some(node) = arena.get(id) else { return };
let _ = engine.create(id);
let style = match node.kind {
Kind::Root => root_style_with_ink_defaults(&node.style),
_ => node.style.clone(),
};
let _ = engine.apply_style(id, &style);
if matches!(node.kind, Kind::Text | Kind::VirtualText) {
engine.set_measure(id, build_measure_fn_for(arena, id));
return;
}
let children: Vec<u32> = node.children.clone();
for (idx, &child_id) in children.iter().enumerate() {
create_layout_nodes(arena, child_id, engine);
let _ = engine.insert_child(id, child_id, idx);
}
}
fn root_style_with_ink_defaults(s: &crate::dom::Style) -> crate::dom::Style {
let mut style = s.clone();
if style.flex_direction.is_none() {
style.flex_direction = Some(crate::dom::FlexDir::Column);
}
if style.align_items.is_none() {
style.align_items = Some(crate::dom::Align::Stretch);
}
style
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom::{Arena, BorderStyle, Dim, Display, Kind, Lp, Node, Overflow, Style, TextWrap};
fn make_root(arena: &mut Arena, id: u32) {
arena.insert(id, Node::new(Kind::Root));
}
fn make_box(arena: &mut Arena, id: u32, style: Style) {
let mut n = Node::new(Kind::Box);
n.style = style;
arena.insert(id, n);
}
fn make_text(arena: &mut Arena, id: u32, text: &str) {
let mut n = Node::new(Kind::Text);
n.text = Some(text.to_owned());
arena.insert(id, n);
}
fn make_text_styled(arena: &mut Arena, id: u32, text: &str, style: Style) {
let mut n = Node::new(Kind::Text);
n.text = Some(text.to_owned());
n.style = style;
arena.insert(id, n);
}
fn add_child(arena: &mut Arena, parent: u32, child: u32) {
arena.get_mut(parent).unwrap().children.push(child);
}
#[test]
fn e1_plain_text() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, "Hello");
add_child(&mut a, 0, 1);
assert_eq!(render_to_string(&a, 0, 80), "Hello");
}
#[test]
fn e2_single_border_empty_10x3() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
width: Some(Dim::Points(10.0)),
height: Some(Dim::Points(3.0)),
..Style::default()
},
);
add_child(&mut a, 0, 1);
assert_eq!(
render_to_string(&a, 0, 80),
"┌────────┐\n│ │\n└────────┘"
);
}
#[test]
fn e3_border_with_text() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
width: Some(Dim::Points(12.0)),
height: Some(Dim::Points(4.0)),
..Style::default()
},
);
make_text(&mut a, 2, "Hi");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
assert_eq!(
render_to_string(&a, 0, 80),
"┌──────────┐\n│Hi │\n│ │\n└──────────┘"
);
}
#[test]
fn e4_column_two_bordered_boxes() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
flex_direction: Some(crate::dom::FlexDir::Column),
width: Some(Dim::Points(10.0)),
..Style::default()
},
);
make_box(
&mut a,
2,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
..Style::default()
},
);
make_text(&mut a, 3, "A");
make_box(
&mut a,
4,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
..Style::default()
},
);
make_text(&mut a, 5, "B");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 2, 3);
add_child(&mut a, 1, 4);
add_child(&mut a, 4, 5);
assert_eq!(
render_to_string(&a, 0, 80),
"┌────────┐\n│A │\n└────────┘\n┌────────┐\n│B │\n└────────┘"
);
}
#[test]
fn e5_box_with_padding() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
padding: Some(Lp::Points(1.0)),
..Style::default()
},
);
make_text(&mut a, 2, "Hello");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
assert_eq!(render_to_string(&a, 0, 20), "\n Hello\n");
}
#[test]
fn e6_text_wrap() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(8.0)),
..Style::default()
},
);
make_text(&mut a, 2, "hello world");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
assert_eq!(render_to_string(&a, 0, 80), "hello\nworld");
}
#[test]
fn e7_overflow_hidden() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(5.0)),
height: Some(Dim::Points(1.0)),
overflow_x: Some(Overflow::Hidden),
overflow_y: Some(Overflow::Hidden),
..Style::default()
},
);
make_text(&mut a, 2, "hello world");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
assert_eq!(render_to_string(&a, 0, 80), "hello");
}
#[test]
fn e7b_border_with_overflow_hidden_insets_clip() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(7.0)),
height: Some(Dim::Points(3.0)),
border_style: Some(BorderStyle::Named("single".to_owned())),
overflow_x: Some(Overflow::Hidden),
overflow_y: Some(Overflow::Hidden),
..Style::default()
},
);
make_text(&mut a, 2, "hello world");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
assert_eq!(render_to_string(&a, 0, 80), "┌─────┐\n│hello│\n└─────┘");
}
#[test]
fn e8_display_none_skipped() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
flex_direction: Some(crate::dom::FlexDir::Column),
width: Some(Dim::Points(20.0)),
..Style::default()
},
);
make_box(
&mut a,
2,
Style {
display: Some(Display::None),
..Style::default()
},
);
make_text(&mut a, 3, "hidden");
make_text(&mut a, 4, "visible");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 2, 3);
add_child(&mut a, 1, 4);
assert_eq!(render_to_string(&a, 0, 80), "visible");
}
#[test]
fn e9_double_border() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("double".to_owned())),
width: Some(Dim::Points(10.0)),
height: Some(Dim::Points(3.0)),
..Style::default()
},
);
add_child(&mut a, 0, 1);
assert_eq!(
render_to_string(&a, 0, 80),
"╔════════╗\n║ ║\n╚════════╝"
);
}
#[test]
fn e10_text_truncate_end() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(8.0)),
..Style::default()
},
);
make_text_styled(
&mut a,
2,
"hello world",
Style {
text_wrap: Some(TextWrap::TruncateEnd),
..Style::default()
},
);
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
assert_eq!(render_to_string(&a, 0, 80), "hello w\u{2026}");
}
#[test]
fn e11_gap_column() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
flex_direction: Some(crate::dom::FlexDir::Column),
width: Some(Dim::Points(10.0)),
gap: Some(1.0),
..Style::default()
},
);
make_text(&mut a, 2, "line1");
make_text(&mut a, 3, "line2");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 1, 3);
assert_eq!(render_to_string(&a, 0, 80), "line1\n\nline2");
}
#[test]
fn e12_no_top_border() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
border_top: Some(false),
width: Some(Dim::Points(10.0)),
height: Some(Dim::Points(3.0)),
..Style::default()
},
);
add_child(&mut a, 0, 1);
assert_eq!(
render_to_string(&a, 0, 80),
"│ │\n│ │\n└────────┘"
);
}
#[test]
fn e13_nested_border_padding() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
padding: Some(Lp::Points(1.0)),
width: Some(Dim::Points(20.0)),
height: Some(Dim::Points(7.0)),
..Style::default()
},
);
make_box(
&mut a,
2,
Style {
border_style: Some(BorderStyle::Named("double".to_owned())),
..Style::default()
},
);
make_text(&mut a, 3, "inner");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 2, 3);
assert_eq!(
render_to_string(&a, 0, 80),
"┌──────────────────┐\n│ │\n│ ╔═════╗ │\n│ ║inner║ │\n│ ╚═════╝ │\n│ │\n└──────────────────┘"
);
}
#[test]
fn e14_no_left_border() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
border_left: Some(false),
width: Some(Dim::Points(10.0)),
height: Some(Dim::Points(3.0)),
..Style::default()
},
);
add_child(&mut a, 0, 1);
assert_eq!(
render_to_string(&a, 0, 80),
"─────────┐\n │\n─────────┘"
);
}
#[test]
fn e15_two_text_nodes_row() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(20.0)),
..Style::default()
},
);
make_text(&mut a, 2, "left");
make_text(&mut a, 3, "right");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 1, 3);
assert_eq!(render_to_string(&a, 0, 80), "leftright");
}
#[test]
fn m2d_colorless_frame_byte_equals_plain() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
width: Some(Dim::Points(12.0)),
height: Some(Dim::Points(4.0)),
..Style::default()
},
);
make_text(&mut a, 2, "Hi");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
let out = render_to_string(&a, 0, 80);
assert_eq!(
out,
"┌──────────┐\n│Hi │\n│ │\n└──────────┘"
);
assert!(
!out.contains('\u{1b}'),
"colorless frame must contain no SGR escape bytes"
);
}
use crate::dom::{Op, apply};
fn render_via_seam(arena: &Arena, root_id: u32, width: u16) -> String {
let Some((engine, root_rect)) = build_layout_engine(arena, root_id, width) else {
return String::new();
};
let grid_rows = root_rect.height as usize;
let grid_cols = root_rect.width as usize;
if grid_rows == 0 || grid_cols == 0 {
return String::new();
}
let mut grid = Grid::new(grid_rows, grid_cols);
let rect_fn = |id: u32| engine.computed(id);
walk(
arena,
root_id,
&rect_fn,
&|_| None,
&mut grid,
crate::render::colorize::ColorLevel::Truecolor,
);
grid.get().0
}
#[test]
fn a1_seam_settext_between_renders_remeasures() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(8.0)),
..Style::default()
},
);
make_text(&mut a, 2, "hi");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
let frame1 = render_via_seam(&a, 0, 80);
assert_eq!(
frame1, "hi",
"frame 1 should render the original short text"
);
apply(
&mut a,
&[Op::SetText {
id: 2,
text: "hello world".to_owned(),
}],
);
let frame2 = render_via_seam(&a, 0, 80);
assert_eq!(
frame2, "hello\nworld",
"frame 2 must reflect the new text's measurement (wrap at width 8) — \
a stale measure closure would still measure \"hi\""
);
}
#[test]
fn a2_seam_setstyle_width_change_between_renders() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(20.0)),
..Style::default()
},
);
make_text(&mut a, 2, "hello world");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
let frame1 = render_via_seam(&a, 0, 80);
assert_eq!(frame1, "hello world", "width 20 keeps the text on one line");
apply(
&mut a,
&[Op::SetStyle {
id: 1,
style: Box::new(Style {
width: Some(Dim::Points(8.0)),
..Style::default()
}),
}],
);
let frame2 = render_via_seam(&a, 0, 80);
assert_eq!(
frame2, "hello\nworld",
"shrinking the box to width 8 must wrap the text on the next render"
);
}
#[test]
fn a3_seam_node_removal_between_renders() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
flex_direction: Some(crate::dom::FlexDir::Column),
width: Some(Dim::Points(10.0)),
..Style::default()
},
);
make_text(&mut a, 2, "alpha");
make_text(&mut a, 3, "bravo");
make_text(&mut a, 4, "gamma");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 1, 3);
add_child(&mut a, 1, 4);
let frame1 = render_via_seam(&a, 0, 80);
assert_eq!(
frame1, "alpha\nbravo\ngamma",
"frame 1 renders all three column children"
);
apply(
&mut a,
&[
Op::RemoveChild {
parent: 1,
child: 3,
},
Op::Free { id: 3 },
],
);
let frame2 = render_via_seam(&a, 0, 80);
assert_eq!(
frame2, "alpha\ngamma",
"frame 2 must drop the removed middle node — fresh engine reads the \
post-removal arena with no orphan from a stale tree"
);
}
#[test]
fn a4_seam_matches_render_to_string() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
width: Some(Dim::Points(12.0)),
height: Some(Dim::Points(4.0)),
..Style::default()
},
);
make_text(&mut a, 2, "Hi");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
assert_eq!(render_via_seam(&a, 0, 80), render_to_string(&a, 0, 80));
}
use crate::render::colorize::{ColorLevel, Kind as ColorKind, colorize, dim};
use crate::render::walk::TransformAccessor;
type TextTransform = Box<dyn Fn(&str, usize) -> String>;
fn text_transform(
dim_color: bool,
color: Option<&'static str>,
bg_color: Option<&'static str>,
bold: bool,
) -> TextTransform {
Box::new(move |s: &str, _i: usize| {
let lvl = ColorLevel::Truecolor;
let mut out = if dim_color { dim(s, lvl) } else { s.to_owned() };
if let Some(c) = color {
out = colorize(&out, Some(c), ColorKind::Fg, lvl);
}
if let Some(bg) = bg_color {
out = colorize(&out, Some(bg), ColorKind::Bg, lvl);
}
if bold {
out = colorize(&out, Some("bold"), ColorKind::Fg, lvl);
}
out
})
}
fn render_single_text(text: &str, mk: fn() -> TextTransform) -> String {
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, text);
add_child(&mut a, 0, 1);
let t = mk();
let acc: &TransformAccessor<'_> = &|id: u32| match id {
1 => Some(Box::new(|s: &str, i: usize| t(s, i)) as _),
_ => None,
};
render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0
}
#[test]
fn m3b_named_color_fg() {
let out = render_single_text("Test", || text_transform(false, Some("green"), None, false));
assert_eq!(out, "\u{1b}[32mTest\u{1b}[39m");
}
#[test]
fn m3b_hex_color_fg() {
let out = render_single_text("Test", || {
text_transform(false, Some("#ff8800"), None, false)
});
assert_eq!(out, "\u{1b}[38;2;255;136;0mTest\u{1b}[39m");
}
#[test]
fn m3b_bg_color() {
let out = render_single_text("Test", || text_transform(false, None, Some("green"), false));
assert_eq!(out, "\u{1b}[42mTest\u{1b}[49m");
}
#[test]
fn m3b_dim() {
let out = render_single_text("Test", || text_transform(true, None, None, false));
assert_eq!(out, "\u{1b}[2mTest\u{1b}[22m");
}
#[test]
fn m3b_bold_color_combo() {
let out = render_single_text("Test", || text_transform(false, Some("red"), None, true));
assert_eq!(out, "\u{1b}[1m\u{1b}[31mTest\u{1b}[39m\u{1b}[22m");
}
#[test]
fn p6_2_clear_text_style_renders_plain_bytes() {
use crate::dom::{Op, TextStyle, apply};
let mut plain = Arena::new();
make_root(&mut plain, 0);
make_text(&mut plain, 1, "Test");
add_child(&mut plain, 0, 1);
let plain_bytes = render_styled(&plain, 0, 100, &|_| None, ColorLevel::Truecolor).0;
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, "Test");
add_child(&mut a, 0, 1);
apply(
&mut a,
&[Op::SetTextStyle {
id: 1,
style: TextStyle {
color: Some("red".into()),
..Default::default()
},
}],
);
let styled_bytes = render_styled(&a, 0, 100, &|_| None, ColorLevel::Truecolor).0;
assert!(
styled_bytes.contains("\u{1b}[31m"),
"precondition: the red-styled node renders the red SGR (\\x1b[31m); got {styled_bytes:?}"
);
assert_ne!(
styled_bytes, plain_bytes,
"the red-styled render must differ from the plain render"
);
apply(&mut a, &[Op::ClearTextStyle { id: 1 }]);
let cleared_bytes = render_styled(&a, 0, 100, &|_| None, ColorLevel::Truecolor).0;
assert_eq!(
cleared_bytes, plain_bytes,
"after ClearTextStyle the node renders BYTE-IDENTICAL to a never-styled plain node (P6.2)"
);
}
#[test]
fn m3b_transform_uppercase() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, ""); make_text(&mut a, 2, "hello"); add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
let acc: &TransformAccessor<'_> = &|id: u32| match id {
1 => Some(Box::new(|s: &str, _i: usize| s.to_uppercase())),
_ => None,
};
assert_eq!(
render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0,
"HELLO"
);
}
#[test]
fn m3b_nested_transformers_order() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, ""); make_text(&mut a, 2, ""); make_text(&mut a, 3, "x"); add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 2, 3);
let acc: &TransformAccessor<'_> = &|id: u32| match id {
1 => Some(Box::new(|s: &str, _i: usize| format!("O({s})"))),
2 => Some(Box::new(|s: &str, _i: usize| format!("I({s})"))),
_ => None,
};
assert_eq!(
render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0,
"O(I(x))"
);
}
#[test]
fn m3b_color_then_transform_interleave() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, ""); make_text(&mut a, 2, "test"); add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
let acc: &TransformAccessor<'_> = &|id: u32| match id {
1 => Some(Box::new(|s: &str, _i: usize| s.to_uppercase())),
2 => Some(Box::new(|s: &str, _i: usize| {
colorize(s, Some("green"), ColorKind::Fg, ColorLevel::Truecolor)
})),
_ => None,
};
assert_eq!(
render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0,
"\u{1b}[32MTEST\u{1b}[39M"
);
}
#[test]
fn m3b_height_return_multiline() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
flex_direction: Some(crate::dom::FlexDir::Column),
width: Some(Dim::Points(10.0)),
..Style::default()
},
);
make_text(&mut a, 2, "line1");
make_text(&mut a, 3, "line2");
make_text(&mut a, 4, "line3");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 1, 3);
add_child(&mut a, 1, 4);
let (out, height) = render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
assert_eq!(out, "line1\nline2\nline3");
assert_eq!(height, 3, "3 column text lines → height 3");
}
#[test]
fn m3b_drift_guard_noop_equals_plain() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
border_style: Some(BorderStyle::Named("single".to_owned())),
width: Some(Dim::Points(12.0)),
height: Some(Dim::Points(4.0)),
..Style::default()
},
);
make_text(&mut a, 2, "Hi");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
let plain = render_to_string(&a, 0, 80);
let (styled, height) = render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
assert_eq!(
styled, plain,
"no-op accessor must byte-equal render_to_string"
);
assert_eq!(
styled,
"┌──────────┐\n│Hi │\n│ │\n└──────────┘"
);
assert_eq!(height, 4, "4-row bordered box → height 4");
assert!(
!styled.contains('\u{1b}'),
"no-op accessor frame must carry no SGR"
);
}
#[test]
fn m3b_nested_styled_text_inner_color_applies_to_child() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, "a"); make_text(&mut a, 2, "b"); add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
let acc: &TransformAccessor<'_> = &|id: u32| match id {
1 => Some(Box::new(|s: &str, _i: usize| {
colorize(s, Some("red"), ColorKind::Fg, ColorLevel::Truecolor)
})),
2 => Some(Box::new(|s: &str, _i: usize| {
colorize(s, Some("blue"), ColorKind::Fg, ColorLevel::Truecolor)
})),
_ => None,
};
let out = render_styled(&a, 0, 100, acc, ColorLevel::Truecolor).0;
assert_eq!(
out, "\u{1b}[31ma\u{1b}[34mb\u{1b}[39m",
"inner blue must apply to the child span and match the oracle bytes"
);
}
#[test]
fn static_present_renders_with_trailing_newline_and_omitted_from_main() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
position: Some(crate::dom::Position::Absolute),
..Style::default()
},
);
make_text(&mut a, 2, "Done");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
let static_out = render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
assert_eq!(
static_out, "Done\n",
"static output is the static subtree's content plus a trailing newline"
);
let (plain, _h) = render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor);
assert_eq!(
plain, "",
"the static subtree must NOT appear in the live (main) output"
);
}
#[test]
fn no_static_node_returns_empty_string() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_text(&mut a, 1, "live");
add_child(&mut a, 0, 1);
assert_eq!(
render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
"",
"a tree with no static node yields an empty static output"
);
assert_eq!(
render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor).0,
"live"
);
}
#[test]
fn static_node_does_not_pull_live_sibling_into_static_output() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
position: Some(crate::dom::Position::Absolute),
..Style::default()
},
);
make_text(&mut a, 2, "Done");
make_text(&mut a, 3, "live");
add_child(&mut a, 0, 1); add_child(&mut a, 1, 2);
add_child(&mut a, 0, 3); apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
assert_eq!(
render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
"Done\n",
"static_output carries ONLY the static subtree; the live sibling must \
NOT appear (the walk starts at the static node, never ascends to root)"
);
assert_eq!(
render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor).0,
"live",
"the live (main) output carries the live sibling, with the static \
subtree omitted"
);
}
#[test]
fn static_multiline_subtree_single_trailing_newline() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
position: Some(crate::dom::Position::Absolute),
flex_direction: Some(crate::dom::FlexDir::Column),
width: Some(Dim::Points(10.0)),
..Style::default()
},
);
make_text(&mut a, 2, "a");
make_text(&mut a, 3, "b");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
add_child(&mut a, 1, 3);
apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
assert_eq!(
render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
"a\nb\n",
"multi-line static block keeps its lines and gets one trailing newline"
);
assert_eq!(
render_styled(&a, 0, 80, &|_| None, ColorLevel::Truecolor).0,
"",
"the static block is omitted from the live output"
);
}
#[test]
fn static_present_honors_styled_transform_child() {
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
position: Some(crate::dom::Position::Absolute),
..Style::default()
},
);
make_text(&mut a, 2, "Done");
add_child(&mut a, 0, 1);
add_child(&mut a, 1, 2);
apply(&mut a, &[Op::SetStatic { id: 1, value: true }]);
let acc: &crate::render::walk::TransformAccessor<'_> = &|id: u32| match id {
2 => Some(Box::new(|s: &str, _i: usize| {
crate::render::colorize::colorize(
s,
Some("green"),
crate::render::colorize::Kind::Fg,
ColorLevel::Truecolor,
)
})),
_ => None,
};
let static_out = render_static(&a, 0, 80, acc, ColorLevel::Truecolor);
assert_eq!(
static_out, "\u{1b}[32mDone\u{1b}[39m\n",
"the static pass honors a `<Text color>`/`<Transform>` child — the SGR \
escape MUST appear in static_output (transform_of forwarded to walk_static)"
);
assert_eq!(
render_static(&a, 0, 80, &|_| None, ColorLevel::Truecolor),
"Done\n",
"a no-op transform accessor yields the uncolored static body (mutation control)"
);
}
fn two_segment_truncate_arena(width: f32, mode: TextWrap, seg0: &str, seg1: &str) -> Arena {
use crate::dom::{Op, apply};
let mut a = Arena::new();
make_root(&mut a, 0);
make_box(
&mut a,
1,
Style {
width: Some(Dim::Points(width)),
..Style::default()
},
);
let mut text_root = Node::new(Kind::Text);
text_root.style.text_wrap = Some(mode);
a.insert(2, text_root);
let mut s0 = Node::new(Kind::VirtualText);
s0.text = Some(seg0.to_owned());
s0.style.text_wrap = Some(TextWrap::Wrap);
a.insert(3, s0);
let mut s1 = Node::new(Kind::VirtualText);
s1.text = Some(seg1.to_owned());
s1.style.text_wrap = Some(TextWrap::Wrap);
a.insert(4, s1);
apply(
&mut a,
&[
Op::AppendChild {
parent: 0,
child: 1,
},
Op::AppendChild {
parent: 1,
child: 2,
},
Op::AppendChild {
parent: 2,
child: 3,
},
Op::AppendChild {
parent: 2,
child: 4,
},
],
);
a
}
#[test]
fn task71_measure_two_segment_truncate_height_is_one() {
let a = two_segment_truncate_arena(3.0, TextWrap::TruncateEnd, "AAAA", "BBBB");
let (engine, _root) =
build_layout_engine(&a, 0, 80).expect("layout engine builds for the 2-segment tree");
let text_rect = engine
.computed(2)
.expect("the text-root (id 2) is laid out as the measured unit");
assert_eq!(
text_rect.height, 1,
"the squashed multi-segment unit truncates to ONE line (per-leaf Wrap measure inflates the extent to >=2)"
);
}
#[test]
fn task71_render_plain_overflowing_first_segment_one_row() {
let a = two_segment_truncate_arena(3.0, TextWrap::TruncateEnd, "AAAA", "BBBB");
assert_eq!(
render_to_string(&a, 0, 80),
"AA\u{2026}",
"plain multi-segment truncate squashes then truncates once → exactly one row, no trailing blank rows"
);
}
}