#![allow(clippy::print_stderr)]
#![allow(clippy::unwrap_used)]
use super::tree::{ContainerConfig, default_container_config};
use super::*;
#[test]
fn wrap_empty() {
assert_eq!(wrap_lines("", 10), vec![""]);
}
#[test]
fn wrap_fits() {
assert_eq!(wrap_lines("hello", 10), vec!["hello"]);
}
#[test]
fn wrap_word_boundary() {
assert_eq!(wrap_lines("hello world", 7), vec!["hello", "world"]);
}
#[test]
fn wrap_multiple_words() {
assert_eq!(
wrap_lines("one two three four", 9),
vec!["one two", "three", "four"]
);
}
#[test]
fn wrap_long_word() {
assert_eq!(wrap_lines("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
}
#[test]
fn wrap_zero_width() {
assert_eq!(wrap_lines("hello", 0), vec!["hello"]);
}
#[test]
fn wrap_lines_single_word_exact_fit() {
assert_eq!(wrap_lines("hello", 5), vec!["hello"]);
}
#[test]
fn wrap_lines_cjk_wide_chars() {
assert_eq!(wrap_lines("日本語文字", 4), vec!["日本", "語文", "字"]);
}
#[test]
fn wrap_lines_cjk_with_space() {
assert_eq!(wrap_lines("日本 hello", 5), vec!["日本", "hello"]);
}
#[test]
fn wrap_lines_consecutive_spaces_collapse() {
assert_eq!(wrap_lines("a b c", 10), vec!["a b c"]);
}
#[test]
fn wrap_lines_combining_mark_preserved() {
let input = "e\u{0301}llo";
assert_eq!(wrap_lines(input, 10), vec![input]);
}
#[test]
fn wrap_lines_single_wide_char_over_width() {
assert_eq!(wrap_lines("日", 1), vec!["日"]);
}
#[test]
fn wrap_lines_only_spaces() {
assert_eq!(wrap_lines(" ", 5), vec![""]);
}
#[test]
fn wrap_lines_hard_break_basic() {
assert_eq!(wrap_lines("a\nb", 10), vec!["a", "b"]);
}
#[test]
fn wrap_lines_hard_break_blank_line() {
assert_eq!(wrap_lines("a\n\nb", 10), vec!["a", "", "b"]);
}
#[test]
fn wrap_lines_hard_break_leading_newline() {
assert_eq!(wrap_lines("\nb", 10), vec!["", "b"]);
}
#[test]
fn wrap_lines_hard_break_trailing_newline() {
assert_eq!(wrap_lines("a\n", 10), vec!["a", ""]);
}
#[test]
fn wrap_lines_hard_break_then_soft_wrap() {
assert_eq!(
wrap_lines("hello world\nfoo", 7),
vec!["hello", "world", "foo"]
);
}
#[test]
fn wrap_lines_hard_break_cjk() {
assert_eq!(wrap_lines("日本\nhello", 5), vec!["日本", "hello"]);
}
#[test]
fn wrap_lines_crlf_normalized() {
assert_eq!(wrap_lines("a\r\nb", 10), vec!["a", "b"]);
}
#[test]
fn wrap_lines_no_literal_control_char_in_output() {
for line in wrap_lines("alpha\nbeta\r\ngamma", 80) {
assert!(!line.contains('\n') && !line.contains('\r'));
}
}
#[test]
fn wrap_lines_zero_width_honors_hard_break() {
assert_eq!(wrap_lines("a\nb", 0), vec!["a", "b"]);
}
#[test]
fn wrap_segments_empty_returns_single_empty_line() {
let segs: Vec<(String, Style)> = Vec::new();
assert_eq!(
wrap_segments(&segs, 10),
vec![Vec::<(String, Style)>::new()]
);
}
#[test]
fn wrap_segments_zero_width_returns_single_empty_line() {
let segs = vec![("hello".to_string(), Style::new())];
assert_eq!(wrap_segments(&segs, 0), vec![Vec::<(String, Style)>::new()]);
}
#[test]
fn wrap_segments_single_style_greedy_break() {
let style = Style::new();
let segs = vec![("hello world foo bar".to_string(), style)];
let lines = wrap_segments(&segs, 9);
let joined: Vec<String> = lines
.iter()
.map(|l| l.iter().map(|(t, _)| t.as_str()).collect())
.collect();
assert_eq!(joined, vec!["hello", "world", "foo bar"]);
}
#[test]
fn wrap_segments_mixed_styles_preserve_run_boundaries() {
let s1 = Style::new();
let s2 = Style::new().fg(Color::Red);
let segs = vec![("abc".to_string(), s1), ("def".to_string(), s2)];
let lines = wrap_segments(&segs, 10);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].len(), 2);
assert_eq!(lines[0][0].0, "abc");
assert_eq!(lines[0][1].0, "def");
assert_eq!(lines[0][0].1, s1);
assert_eq!(lines[0][1].1, s2);
}
#[test]
fn wrap_segments_trailing_whitespace_trimmed() {
let s1 = Style::new();
let s2 = Style::new().fg(Color::Red);
let segs = vec![("abc ".to_string(), s1), (" ".to_string(), s2)];
let lines = wrap_segments(&segs, 20);
assert_eq!(lines, vec![vec![("abc".to_string(), s1)]]);
}
#[test]
fn wrap_segments_break_on_space_with_style_change() {
let s1 = Style::new();
let s2 = Style::new().fg(Color::Red);
let segs = vec![("abc ".to_string(), s1), ("defgh".to_string(), s2)];
let lines = wrap_segments(&segs, 5);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], vec![("abc".to_string(), s1)]);
assert_eq!(lines[1], vec![("defgh".to_string(), s2)]);
}
#[test]
fn wrap_segments_cjk_with_mixed_styles() {
let s1 = Style::new();
let s2 = Style::new().fg(Color::Red);
let segs = vec![("日本".to_string(), s1), ("語".to_string(), s2)];
let lines = wrap_segments(&segs, 4);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], vec![("日本".to_string(), s1)]);
assert_eq!(lines[1], vec![("語".to_string(), s2)]);
}
#[test]
fn wrap_segments_hard_break_basic() {
let s = Style::new();
let segs = vec![("a\nb".to_string(), s)];
assert_eq!(
wrap_segments(&segs, 10),
vec![vec![("a".to_string(), s)], vec![("b".to_string(), s)]]
);
}
#[test]
fn wrap_segments_hard_break_blank_line() {
let s = Style::new();
let segs = vec![("a\n\nb".to_string(), s)];
assert_eq!(
wrap_segments(&segs, 10),
vec![
vec![("a".to_string(), s)],
Vec::<(String, Style)>::new(),
vec![("b".to_string(), s)],
]
);
}
#[test]
fn wrap_segments_hard_break_across_segment_boundary() {
let s1 = Style::new();
let s2 = Style::new().fg(Color::Red);
let segs = vec![("a\n".to_string(), s1), ("b".to_string(), s2)];
assert_eq!(
wrap_segments(&segs, 10),
vec![vec![("a".to_string(), s1)], vec![("b".to_string(), s2)]]
);
}
#[test]
fn wrap_segments_hard_break_then_soft_wrap() {
let s = Style::new();
let segs = vec![("hello world\nfoo".to_string(), s)];
let joined: Vec<String> = wrap_segments(&segs, 9)
.iter()
.map(|l| l.iter().map(|(t, _)| t.as_str()).collect())
.collect();
assert_eq!(joined, vec!["hello", "world", "foo"]);
}
#[test]
fn wrap_segments_crlf_normalized() {
let s = Style::new();
let segs = vec![("a\r\nb".to_string(), s)];
assert_eq!(
wrap_segments(&segs, 10),
vec![vec![("a".to_string(), s)], vec![("b".to_string(), s)]]
);
}
#[test]
fn wrap_segments_no_literal_control_char_in_runs() {
let s = Style::new();
let segs = vec![("alpha\nbeta\r\ngamma".to_string(), s)];
for line in wrap_segments(&segs, 80) {
for (t, _) in line {
assert!(!t.contains('\n') && !t.contains('\r'));
}
}
}
#[test]
fn diagnostic_demo_layout() {
use crate::rect::Rect;
use crate::style::{Align, Border, Constraints, Justify, Margin, Padding, Style};
let mut root = LayoutNode::container(
Direction::Column,
ContainerConfig {
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
},
);
let mut outer_container = LayoutNode::container(
Direction::Column,
ContainerConfig {
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: Some(Border::Rounded),
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::all(1),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 1,
},
);
outer_container.children.push(LayoutNode::text(
"header".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
outer_container.children.push(LayoutNode::text(
"separator".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
let mut inner_container = LayoutNode::container(
Direction::Column,
ContainerConfig {
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 1,
},
);
inner_container.children.push(LayoutNode::text(
"content1".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
inner_container.children.push(LayoutNode::text(
"content2".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
inner_container.children.push(LayoutNode::text(
"content3".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
outer_container.children.push(inner_container);
outer_container.children.push(LayoutNode::text(
"separator2".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
outer_container.children.push(LayoutNode::text(
"footer".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
root.children.push(outer_container);
compute(&mut root, Rect::new(0, 0, 80, 50));
eprintln!("\n=== DIAGNOSTIC LAYOUT TEST ===");
eprintln!("Root node:");
eprintln!(" pos: {:?}, size: {:?}", root.pos, root.size);
let outer = &root.children[0];
eprintln!("\nOuter bordered container (grow:1):");
eprintln!(" pos: {:?}, size: {:?}", outer.pos, outer.size);
let inner = &outer.children[2];
eprintln!("\nInner container (grow:1, simulates scrollable):");
eprintln!(" pos: {:?}, size: {:?}", inner.pos, inner.size);
eprintln!("\nAll children of outer container:");
for (i, child) in outer.children.iter().enumerate() {
eprintln!(" [{}] pos: {:?}, size: {:?}", i, child.pos, child.size);
}
assert_eq!(root.size, (80, 50));
assert_eq!(outer.size, (80, 50));
let expected_inner_height = 50 - 2 - 2 - 4;
assert_eq!(inner.size.1, expected_inner_height as u32);
let expected_inner_y = 1 + 1 + 1 + 1;
assert_eq!(inner.pos.1, expected_inner_y as u32);
}
#[test]
fn collect_focus_rects_from_markers() {
use Style;
let mut commands = vec![
Command::FocusMarker(0),
Command::Text {
content: "input1".into(),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
},
Command::FocusMarker(1),
Command::Text {
content: "input2".into(),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
},
];
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 40, 10);
compute(&mut tree, area);
let mut fd = FrameData::default();
collect_all(&tree, &mut fd);
assert_eq!(fd.focus_rects.len(), 2);
assert_eq!(fd.focus_rects[0].0, 0);
assert_eq!(fd.focus_rects[1].0, 1);
assert!(fd.focus_rects[0].1.width > 0);
assert!(fd.focus_rects[1].1.width > 0);
assert_ne!(fd.focus_rects[0].1.y, fd.focus_rects[1].1.y);
}
#[test]
fn focus_marker_tags_container() {
use crate::style::{Border, Style};
let mut commands = vec![
Command::FocusMarker(0),
Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Column,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: Some(Border::Single),
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Default::default(),
constraints: Default::default(),
title: None,
grow: 0,
group_name: None,
})),
Command::Text {
content: "inside".into(),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
},
Command::EndContainer,
];
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 40, 10);
compute(&mut tree, area);
let mut fd = FrameData::default();
collect_all(&tree, &mut fd);
assert_eq!(fd.focus_rects.len(), 1);
assert_eq!(fd.focus_rects[0].0, 0);
assert!(fd.focus_rects[0].1.width >= 8);
assert!(fd.focus_rects[0].1.height >= 3);
}
#[test]
fn wrapped_text_cache_reused_for_same_width() {
let mut node = LayoutNode::text(
"alpha beta gamma".to_string(),
Style::new(),
0,
Align::Start,
(None, true, false),
Margin::default(),
Constraints::default(),
);
let height_a = node.min_height_for_width(6);
let first_ptr = node
.text_data()
.and_then(|t| t.cached_wrapped.as_ref())
.map(Vec::as_ptr)
.unwrap();
let height_b = node.min_height_for_width(6);
let second_ptr = node
.text_data()
.and_then(|t| t.cached_wrapped.as_ref())
.map(Vec::as_ptr)
.unwrap();
assert_eq!(height_a, height_b);
assert_eq!(first_ptr, second_ptr);
assert_eq!(node.text_data().and_then(|t| t.cached_wrap_width), Some(6));
}
#[test]
fn collect_all_clips_raw_draw_to_scroll_viewport() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut scroll = LayoutNode::container(Direction::Column, default_container_config());
scroll.is_scrollable = true;
scroll.pos = (0, 0);
scroll.size = (20, 4);
scroll.scroll_offset = 2;
let mut raw = LayoutNode::raw_draw(7, Constraints::default(), 0, Margin::default(), None, None);
raw.pos = (1, 3);
raw.size = (6, 3);
scroll.children.push(raw);
root.children.push(scroll);
let mut fd = FrameData::default();
collect_all(&root, &mut fd);
let rects: Vec<_> = fd
.raw_draw_rects
.into_iter()
.map(|r| (r.draw_id, r.rect, r.top_clip_rows, r.original_height))
.collect();
assert_eq!(
rects,
vec![(7, crate::rect::Rect::new(1, 1, 6, 3), 0, 3)],
"collect_all must clip RawDraw rect into the scroll viewport, \
leave top_clip_rows = 0 when the image top is already inside \
the viewport, and report the unclipped original height"
);
}
#[test]
fn group_names_share_arc_across_focus_descendants() {
use std::sync::Arc;
const N_GROUPS: usize = 50;
const FOCUSES_PER_GROUP: usize = 3;
let mut commands: Vec<Command> = Vec::new();
let mut focus_id = 0usize;
for i in 0..N_GROUPS {
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Column,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: Some(Arc::from(format!("group-{i}").as_str())),
})));
for _ in 0..FOCUSES_PER_GROUP {
commands.push(Command::FocusMarker(focus_id));
commands.push(Command::Text {
content: format!("row {focus_id}"),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Margin::default(),
constraints: Constraints::default(),
});
focus_id += 1;
}
commands.push(Command::EndContainer);
}
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 80, (N_GROUPS * FOCUSES_PER_GROUP) as u32 + 4);
compute(&mut tree, area);
let mut fd = FrameData::default();
collect_all(&tree, &mut fd);
assert_eq!(
fd.group_rects.len(),
N_GROUPS,
"one group rect per container"
);
for (i, (name, _rect)) in fd.group_rects.iter().enumerate() {
assert_eq!(name.as_ref(), format!("group-{i}"));
}
assert_eq!(
fd.focus_groups.len(),
N_GROUPS * FOCUSES_PER_GROUP,
"one focus slot per focusable"
);
for g in 0..N_GROUPS {
let base = g * FOCUSES_PER_GROUP;
let first = fd.focus_groups[base]
.as_ref()
.unwrap_or_else(|| panic!("focus slot {base} missing group"));
assert_eq!(first.as_ref(), format!("group-{g}"));
for k in 1..FOCUSES_PER_GROUP {
let slot = fd.focus_groups[base + k]
.as_ref()
.unwrap_or_else(|| panic!("focus slot {} missing group", base + k));
assert!(
Arc::ptr_eq(first, slot),
"focus slots within group-{g} must share the same Arc allocation"
);
}
}
}
#[test]
fn flexbox_row_many_children_overflow_scratch() {
const N: usize = 32;
let mut commands: Vec<Command> = Vec::new();
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Row,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
})));
for i in 0..N {
commands.push(Command::Text {
content: format!("c{i}"),
cursor_offset: None,
style: Style::new(),
grow: 1,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
});
}
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
compute(&mut tree, crate::rect::Rect::new(0, 0, 200, 4));
let row = &tree.children[0];
assert_eq!(row.children.len(), N);
let mut prev_end = 0u32;
for child in &row.children {
assert!(child.pos.0 >= prev_end, "children must not overlap");
prev_end = child.pos.0 + child.size.0;
}
assert!(prev_end <= 200);
}
#[test]
fn flexbox_column_many_children_overflow_scratch() {
const N: usize = 20;
let mut commands: Vec<Command> = Vec::new();
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Column,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
})));
for i in 0..N {
commands.push(Command::Text {
content: format!("row{i}"),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
});
}
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
compute(&mut tree, crate::rect::Rect::new(0, 0, 20, 50));
let col = &tree.children[0];
assert_eq!(col.children.len(), N);
let mut prev_end = 0u32;
for child in &col.children {
assert!(
child.pos.1 >= prev_end,
"children must not overlap vertically"
);
prev_end = child.pos.1 + child.size.1;
}
assert!(prev_end <= 50);
}
#[test]
fn flexbox_grow_with_max_width_no_gap() {
use crate::style::{Align, Constraints, Justify, Margin, Padding};
let mut commands = vec![
Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Row,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: crate::style::Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
})),
Command::Text {
content: "A".into(),
cursor_offset: None,
style: crate::style::Style::new(),
grow: 1,
align: Align::Start,
wrap: false,
truncate: false,
margin: Margin::default(),
constraints: Constraints::default().max_w(10),
},
Command::Text {
content: "B".into(),
cursor_offset: None,
style: crate::style::Style::new(),
grow: 1,
align: Align::Start,
wrap: false,
truncate: false,
margin: Margin::default(),
constraints: Constraints::default(),
},
Command::EndContainer,
];
let mut tree = build_tree(&mut commands);
compute(&mut tree, crate::rect::Rect::new(0, 0, 40, 4));
let row = &tree.children[0];
assert_eq!(row.children.len(), 2);
let c0 = &row.children[0];
let c1 = &row.children[1];
assert_eq!(
c0.size.0, 10,
"child 0 width should be clamped to max_width=10"
);
assert_eq!(
c1.pos.0,
c0.pos.0 + c0.size.0,
"child 1 x must equal child 0 x + child 0 width (no gap)"
);
}
#[test]
fn flexbox_column_grow_with_max_height_no_gap() {
use crate::style::{Align, Constraints, Justify, Margin, Padding};
let mut commands = vec![
Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Column,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: crate::style::Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 1,
group_name: None,
})),
Command::Text {
content: "A".into(),
cursor_offset: None,
style: crate::style::Style::new(),
grow: 1,
align: Align::Start,
wrap: false,
truncate: false,
margin: Margin::default(),
constraints: Constraints::default().max_h(5),
},
Command::Text {
content: "B".into(),
cursor_offset: None,
style: crate::style::Style::new(),
grow: 1,
align: Align::Start,
wrap: false,
truncate: false,
margin: Margin::default(),
constraints: Constraints::default(),
},
Command::EndContainer,
];
let mut tree = build_tree(&mut commands);
compute(&mut tree, crate::rect::Rect::new(0, 0, 20, 20));
let col = &tree.children[0];
assert_eq!(col.children.len(), 2);
let c0 = &col.children[0];
let c1 = &col.children[1];
assert_eq!(
c0.size.1, 5,
"child 0 height should be clamped to max_height=5"
);
assert_eq!(
c1.pos.1,
c0.pos.1 + c0.size.1,
"child 1 y must equal child 0 y + child 0 height (no gap)"
);
}
#[test]
fn collect_all_keeps_scroll_invariant_with_nested_scrollables() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut outer = LayoutNode::container(Direction::Column, default_container_config());
outer.is_scrollable = true;
outer.pos = (0, 0);
outer.size = (40, 20);
let mut inner = LayoutNode::container(Direction::Column, default_container_config());
inner.is_scrollable = true;
inner.pos = (1, 1);
inner.size = (38, 18);
outer.children.push(inner);
let mut sibling = LayoutNode::container(Direction::Column, default_container_config());
sibling.is_scrollable = true;
sibling.pos = (0, 25);
sibling.size = (40, 10);
root.children.push(outer);
root.children.push(sibling);
let mut fd = FrameData::default();
collect_all(&root, &mut fd);
assert_eq!(
fd.scroll_infos.len(),
fd.scroll_rects.len(),
"scroll_infos and scroll_rects must always have equal length \
(collect_all_inner branch merge invariant)"
);
assert_eq!(
fd.scroll_infos.len(),
3,
"expected 3 scrollable nodes (outer, inner, sibling)"
);
}
#[test]
fn raw_draw_constructor_matches_inline_literal_shape() {
let constraints = Constraints::default().min_w(7).min_h(2);
let margin = Margin {
top: 1,
right: 2,
bottom: 3,
left: 4,
};
let node = LayoutNode::raw_draw(42, constraints, 5, margin, Some(11), Some(13));
assert!(matches!(node.kind, NodeKind::RawDraw(42)));
assert_eq!(node.grow, 5);
assert_eq!(node.margin.top, 1);
assert_eq!(node.margin.right, 2);
assert_eq!(node.margin.bottom, 3);
assert_eq!(node.margin.left, 4);
assert_eq!(node.constraints.min_width(), Some(7));
assert_eq!(node.constraints.min_height(), Some(2));
assert_eq!(node.size, (7, 2), "size must seed from constraints minima");
assert_eq!(node.pos, (0, 0));
assert_eq!(node.focus_id, Some(11));
assert_eq!(node.interaction_id, Some(13));
assert!(node.text_data().is_none());
assert_eq!(node.align, Align::Start);
assert!(node.align_self.is_none());
assert_eq!(node.justify, Justify::Start);
assert!(!node.wrap);
assert!(!node.truncate);
assert_eq!(node.gap, 0);
assert!(node.border.is_none());
assert!(node.bg_color.is_none());
assert!(node.title.is_none());
assert!(node.children.is_empty());
assert!(!node.is_scrollable);
assert_eq!(node.scroll_offset, 0);
assert_eq!(node.content_height, 0);
assert!(node.link_url.is_none());
assert!(node.group_name.is_none());
assert!(node.overlays.is_empty());
}
fn build_deep_node(depth: usize) -> LayoutNode {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut cursor = &mut root;
for _ in 0..depth {
cursor.children.push(LayoutNode::container(
Direction::Column,
default_container_config(),
));
cursor = cursor.children.last_mut().unwrap();
}
root
}
#[test]
#[should_panic(expected = "layout tree depth exceeds 512")]
fn compute_panics_at_depth_guard() {
let mut node = build_deep_node(514);
compute(&mut node, crate::rect::Rect::new(0, 0, 80, 24));
}
#[test]
#[should_panic(expected = "layout tree depth exceeds 512")]
fn collect_all_panics_at_depth_guard() {
let mut node = build_deep_node(514);
fn populate_sizes(n: &mut LayoutNode) {
n.size = (10, 10);
for c in &mut n.children {
populate_sizes(c);
}
}
populate_sizes(&mut node);
let mut fd = FrameData::default();
collect_all(&node, &mut fd);
}
#[test]
#[should_panic(expected = "layout tree depth exceeds 512")]
fn render_panics_at_depth_guard() {
let mut node = build_deep_node(514);
fn populate_sizes(n: &mut LayoutNode) {
n.size = (10, 10);
n.pos = (0, 0);
for c in &mut n.children {
populate_sizes(c);
}
}
populate_sizes(&mut node);
let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 80, 24));
super::render::render(&node, &mut buf);
}
#[test]
fn collect_all_reuses_buffer_without_leaking_prior_frame_data() {
use crate::style::{Constraints, Margin};
let mut tree_a = LayoutNode::container(Direction::Column, default_container_config());
let mut focus_a0 = LayoutNode::container(Direction::Column, default_container_config());
focus_a0.focus_id = Some(0);
focus_a0.pos = (0, 0);
focus_a0.size = (10, 1);
tree_a.children.push(focus_a0);
let mut focus_a1 = LayoutNode::container(Direction::Column, default_container_config());
focus_a1.focus_id = Some(1);
focus_a1.pos = (0, 1);
focus_a1.size = (10, 1);
tree_a.children.push(focus_a1);
let mut fd = FrameData::default();
collect_all(&tree_a, &mut fd);
assert_eq!(fd.focus_rects.len(), 2, "frame A: two focus rects");
let mut tree_b = LayoutNode::container(Direction::Column, default_container_config());
let mut raw =
LayoutNode::raw_draw(42, Constraints::default(), 0, Margin::default(), None, None);
raw.pos = (0, 0);
raw.size = (4, 2);
tree_b.children.push(raw);
collect_all(&tree_b, &mut fd);
assert_eq!(
fd.focus_rects.len(),
0,
"frame B: focus_rects must be cleared before refill"
);
assert_eq!(
fd.raw_draw_rects.len(),
1,
"frame B: one raw_draw rect must be present"
);
assert_eq!(fd.raw_draw_rects[0].draw_id, 42);
}
#[test]
fn f12_debug_overlay_outlines_overlay_layer() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut base = LayoutNode::container(Direction::Column, default_container_config());
base.pos = (0, 0);
base.size = (40, 5);
root.children.push(base);
let mut overlay_node = LayoutNode::container(Direction::Column, default_container_config());
overlay_node.pos = (10, 10);
overlay_node.size = (20, 4);
root.overlays.push(super::tree::OverlayLayer {
node: overlay_node,
modal: false,
});
let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 40, 20));
super::render::render_debug_overlay(&root, &mut buf, 0, 60.0, crate::DebugLayer::All);
let cell_top_right = buf.get(10 + 20 - 1, 10);
assert_eq!(
cell_top_right.symbol, "┐",
"F12 overlay outline must hit overlay's top-right corner; got {:?}",
cell_top_right.symbol
);
let cell_bottom_right = buf.get(10 + 20 - 1, 10 + 4 - 1);
assert_eq!(
cell_bottom_right.symbol, "┘",
"F12 overlay outline must hit overlay's bottom-right corner; got {:?}",
cell_bottom_right.symbol
);
}
#[test]
fn count_leaf_widgets_matches_outline_count_with_overlays() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
root.children.push(LayoutNode::text(
"a".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
root.children.push(LayoutNode::text(
"b".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
let mut overlay_root = LayoutNode::container(Direction::Column, default_container_config());
overlay_root.children.push(LayoutNode::text(
"overlay".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
root.overlays.push(super::tree::OverlayLayer {
node: overlay_root,
modal: false,
});
let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 80, 5));
super::render::render_debug_overlay(&root, &mut buf, 0, 60.0, crate::DebugLayer::All);
let mut bottom = String::new();
for x in 0..80 {
bottom.push_str(&buf.get(x, 4).symbol);
}
assert!(
bottom.contains("3 widgets"),
"status bar widget count must include overlay nodes; got {bottom:?}"
);
assert!(
bottom.contains("2 base") && bottom.contains("1 overlay"),
"status bar must include per-layer breakdown when multiple layers \
are populated; got {bottom:?}"
);
}
#[test]
fn f12_debug_overlay_distinguishes_layers_by_color() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut base = LayoutNode::container(Direction::Column, default_container_config());
base.pos = (2, 2);
base.size = (10, 4);
root.children.push(base);
let mut overlay_node = LayoutNode::container(Direction::Column, default_container_config());
overlay_node.pos = (15, 2);
overlay_node.size = (10, 4);
root.overlays.push(super::tree::OverlayLayer {
node: overlay_node,
modal: false,
});
let mut modal_node = LayoutNode::container(Direction::Column, default_container_config());
modal_node.pos = (28, 2);
modal_node.size = (10, 4);
root.overlays.push(super::tree::OverlayLayer {
node: modal_node,
modal: true,
});
let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 60, 8));
super::render::render_debug_overlay(&root, &mut buf, 0, 60.0, crate::DebugLayer::All);
let base_fg = buf.get(11, 5).style.fg;
let overlay_fg = buf.get(24, 5).style.fg;
let modal_fg = buf.get(37, 5).style.fg;
assert!(
base_fg.is_some() && overlay_fg.is_some() && modal_fg.is_some(),
"all three layer outlines must carry a foreground color; \
got base={base_fg:?} overlay={overlay_fg:?} modal={modal_fg:?}"
);
assert_ne!(
base_fg, overlay_fg,
"base and overlay outlines must be different colors"
);
assert_ne!(
base_fg, modal_fg,
"base and modal outlines must be different colors"
);
assert_ne!(
overlay_fg, modal_fg,
"overlay and modal outlines must be different colors"
);
if let Some(crate::style::Color::Rgb(r, g, b)) = base_fg {
assert!(
g > r && g > b,
"Base outline should be green-dominant; got rgb({r},{g},{b})"
);
} else {
panic!("Base outline must be Color::Rgb; got {base_fg:?}");
}
if let Some(crate::style::Color::Rgb(r, g, b)) = overlay_fg {
assert!(
r > g && r > b,
"Overlay outline should be red-dominant; got rgb({r},{g},{b})"
);
} else {
panic!("Overlay outline must be Color::Rgb; got {overlay_fg:?}");
}
if let Some(crate::style::Color::Rgb(r, g, b)) = modal_fg {
assert!(
b > r && b > g,
"Modal outline should be blue-dominant; got rgb({r},{g},{b})"
);
} else {
panic!("Modal outline must be Color::Rgb; got {modal_fg:?}");
}
}
#[test]
fn build_tree_drains_in_place_preserving_capacity() {
let initial_cap = 64;
let mut commands: Vec<Command> = Vec::with_capacity(initial_cap);
commands.push(Command::Text {
content: "hello".into(),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
});
commands.push(Command::Spacer { grow: 1 });
let cap_before = commands.capacity();
let _tree = build_tree(&mut commands);
assert_eq!(
commands.len(),
0,
"build_tree must drain the Vec to len = 0"
);
assert!(
commands.capacity() >= cap_before,
"build_tree must preserve at least the prior capacity (was {cap_before}, now {})",
commands.capacity()
);
let cap_after_first = commands.capacity();
for _ in 0..cap_after_first {
commands.push(Command::Spacer { grow: 0 });
}
let cap_after_pushes = commands.capacity();
assert_eq!(
cap_after_first, cap_after_pushes,
"pushing within the prior capacity must not realloc \
(was {cap_after_first}, now {cap_after_pushes})"
);
let _tree2 = build_tree(&mut commands);
assert_eq!(commands.len(), 0, "second drain must also leave len = 0");
}
fn push_textcol_20(commands: &mut Vec<Command>, shrink: bool) {
if shrink {
commands.push(Command::ShrinkMarker);
}
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Column,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
})));
commands.push(Command::Text {
content: "x".repeat(20),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
});
commands.push(Command::EndContainer);
}
fn open_row(commands: &mut Vec<Command>) {
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Row,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
})));
}
#[test]
fn flex_shrink_default_off_preserves_overflow() {
let mut commands: Vec<Command> = Vec::new();
open_row(&mut commands);
push_textcol_20(&mut commands, false);
push_textcol_20(&mut commands, false);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 4);
compute(&mut tree, area);
let row = &tree.children[0];
assert_eq!(row.children.len(), 2);
assert!(!row.children[0].shrink);
assert!(!row.children[1].shrink);
assert_eq!(row.children[0].size.0, 20);
assert_eq!(row.children[1].size.0, 20);
}
#[test]
fn flex_shrink_all_children_proportional_distribution() {
let mut commands: Vec<Command> = Vec::new();
open_row(&mut commands);
push_textcol_20(&mut commands, true);
push_textcol_20(&mut commands, true);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 4);
compute(&mut tree, area);
let row = &tree.children[0];
assert_eq!(row.children.len(), 2);
assert!(row.children[0].shrink);
assert!(row.children[1].shrink);
assert_eq!(row.children[0].size.0, 15);
assert_eq!(row.children[1].size.0, 15);
}
#[test]
fn flex_shrink_mixed_only_flagged_scale() {
let mut commands: Vec<Command> = Vec::new();
open_row(&mut commands);
push_textcol_20(&mut commands, true);
push_textcol_20(&mut commands, false);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 4);
compute(&mut tree, area);
let row = &tree.children[0];
assert_eq!(row.children.len(), 2);
assert!(row.children[0].shrink);
assert!(!row.children[1].shrink);
assert_eq!(row.children[0].size.0, 10);
assert_eq!(row.children[1].size.0, 20);
}
fn push_textcol(commands: &mut Vec<Command>, width: usize, shrink: bool, basis: Option<u32>) {
if shrink {
commands.push(Command::ShrinkMarker);
}
if let Some(b) = basis {
commands.push(Command::BasisMarker(b));
}
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Column,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
})));
commands.push(Command::Text {
content: "x".repeat(width),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
});
commands.push(Command::EndContainer);
}
fn open_row_cfg(commands: &mut Vec<Command>, gap: i32, wrap: Option<i32>) {
if let Some(cross_gap) = wrap {
commands.push(Command::WrapMarker(cross_gap));
}
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Row,
gap,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
})));
}
#[test]
fn flex_wrap_two_lines_on_overflow() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, Some(0));
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 8);
compute(&mut tree, area);
let row = &tree.children[0];
assert!(row.wrap_children);
assert_eq!(row.children.len(), 3);
assert_eq!(row.children[0].pos.1, area.y);
assert_eq!(row.children[1].pos.1, area.y);
assert_eq!(row.children[0].pos.0, area.x);
assert_eq!(row.children[1].pos.0, area.x + 14);
assert_eq!(row.children[2].pos.1, area.y + 1);
assert_eq!(row.children[2].pos.0, area.x);
}
#[test]
fn flex_wrap_off_is_single_line() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, None);
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 8);
compute(&mut tree, area);
let row = &tree.children[0];
assert!(!row.wrap_children);
for child in &row.children {
assert_eq!(child.pos.1, area.y);
}
assert_eq!(row.children[0].pos.0, area.x);
assert_eq!(row.children[1].pos.0, area.x + 14);
assert_eq!(row.children[2].pos.0, area.x + 28);
}
#[test]
fn flex_wrap_row_gap_between_lines() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, Some(2));
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 12);
compute(&mut tree, area);
let row = &tree.children[0];
assert_eq!(row.cross_gap, 2);
assert_eq!(row.children[0].pos.1, area.y);
assert_eq!(row.children[1].pos.1, area.y);
assert_eq!(row.children[2].pos.1, area.y + 3);
}
#[test]
fn flex_wrap_oversize_child_own_line() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, Some(0));
push_textcol(&mut commands, 50, false, None); push_textcol(&mut commands, 10, false, None);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 8);
compute(&mut tree, area);
let row = &tree.children[0];
assert_eq!(row.children.len(), 2);
assert_eq!(row.children[0].pos.1, area.y);
assert_eq!(row.children[1].pos.1, area.y + 1);
assert_eq!(row.children[1].pos.0, area.x);
}
#[test]
fn flex_wrap_min_height_reserves_lines() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, Some(0));
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
push_textcol(&mut commands, 14, false, None);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 12);
compute(&mut tree, area);
let row = &mut tree.children[0];
assert_eq!(row.min_height_for_width(30), 2);
}
#[test]
fn flex_basis_feeds_grow() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, None);
for _ in 0..2 {
commands.push(Command::BasisMarker(10));
commands.push(Command::BeginContainer(Box::new(BeginContainerArgs {
direction: Direction::Column,
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 1,
group_name: None,
})));
commands.push(Command::Text {
content: "x".to_string(),
cursor_offset: None,
style: Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Default::default(),
constraints: Default::default(),
});
commands.push(Command::EndContainer);
}
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 40, 4);
compute(&mut tree, area);
let row = &tree.children[0];
assert_eq!(row.children[0].flex_basis(), Some(10));
assert_eq!(row.children[1].flex_basis(), Some(10));
assert_eq!(row.children[0].size.0, 20);
assert_eq!(row.children[1].size.0, 20);
}
#[test]
fn flex_basis_feeds_shrink() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, None);
push_textcol(&mut commands, 5, true, Some(20));
push_textcol(&mut commands, 5, true, Some(20));
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 30, 4);
compute(&mut tree, area);
let row = &tree.children[0];
assert!(row.children[0].shrink);
assert_eq!(row.children[0].flex_basis(), Some(20));
assert_eq!(row.children[0].size.0, 15);
assert_eq!(row.children[1].size.0, 15);
}
#[test]
fn flex_basis_none_falls_back_to_min_width() {
let mut commands: Vec<Command> = Vec::new();
open_row_cfg(&mut commands, 0, None);
push_textcol(&mut commands, 20, false, None);
push_textcol(&mut commands, 20, false, None);
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
let area = crate::rect::Rect::new(0, 0, 60, 4);
compute(&mut tree, area);
let row = &tree.children[0];
assert_eq!(row.children[0].flex_basis(), None);
assert_eq!(row.children[0].size.0, 20);
assert_eq!(row.children[1].size.0, 20);
}
fn contains_lone_regional_indicator(s: &str) -> bool {
use unicode_segmentation::UnicodeSegmentation;
s.graphemes(true).any(|g| {
let mut chars = g.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => ('\u{1F1E6}'..='\u{1F1FF}').contains(&c),
_ => false,
}
})
}
#[test]
fn wrap_lines_zwj_flag_not_split() {
let lines = wrap_lines("🇰🇷🇯🇵", 2);
for line in &lines {
assert!(
!contains_lone_regional_indicator(line),
"flag split mid-cluster: {line:?}"
);
}
let joined: String = lines.join("");
assert!(joined.contains("🇰🇷"));
assert!(joined.contains("🇯🇵"));
}
#[test]
fn wrap_lines_family_emoji_at_boundary() {
let input = "日👨\u{200D}👩\u{200D}👧\u{200D}👦";
let lines = wrap_lines(input, 2);
let whole = "👨\u{200D}👩\u{200D}👧\u{200D}👦";
assert!(
lines.iter().any(|l| l.contains(whole)),
"family emoji was broken across lines: {lines:?}"
);
for line in &lines {
assert!(
!line.starts_with('\u{200D}') && !line.ends_with('\u{200D}'),
"dangling ZWJ in {line:?}"
);
}
}
#[test]
fn wrap_lines_devanagari_cluster() {
use unicode_segmentation::UnicodeSegmentation;
let input = "क्षि";
let lines = wrap_lines(input, 1);
let input_clusters: Vec<&str> = input.graphemes(true).collect();
for line in &lines {
for g in line.graphemes(true) {
assert!(
input_clusters.contains(&g),
"devanagari cluster fragmented: {g:?} not an input cluster"
);
}
}
}
#[test]
fn wrap_lines_thai_cluster() {
use unicode_segmentation::UnicodeSegmentation;
let input = "กำ";
let lines = wrap_lines(input, 1);
let input_clusters: Vec<&str> = input.graphemes(true).collect();
for line in &lines {
for g in line.graphemes(true) {
assert!(
input_clusters.contains(&g),
"thai cluster fragmented: {g:?}"
);
}
}
}
#[test]
fn split_long_word_keeps_clusters() {
let lines = wrap_lines("🇰🇷🇯🇵🇺🇸", 2);
for line in &lines {
assert!(
!contains_lone_regional_indicator(line),
"long-word chunk split a flag: {line:?}"
);
}
let joined: String = lines.join("");
assert!(joined.contains("🇰🇷") && joined.contains("🇯🇵") && joined.contains("🇺🇸"));
}
#[test]
fn wrap_segments_zwj_across_break() {
let whole = "👨\u{200D}👩\u{200D}👧\u{200D}👦";
let segs = vec![
("日本".to_string(), Style::new()),
(whole.to_string(), Style::new()),
];
let lines = wrap_segments(&segs, 2);
let any_whole = lines.iter().any(|line| {
let joined: String = line.iter().map(|(t, _)| t.as_str()).collect();
joined.contains(whole)
});
assert!(any_whole, "ZWJ cluster split across lines: {lines:?}");
for line in &lines {
for (t, _) in line {
assert!(
!t.starts_with('\u{200D}') && !t.ends_with('\u{200D}'),
"dangling ZWJ in segment {t:?}"
);
}
}
}
#[test]
fn truncate_with_ellipsis_drops_whole_cluster() {
let out = super::render::truncate_with_ellipsis("🇰🇷abc", 2);
assert_eq!(out, "\u{2026}");
assert!(!contains_lone_regional_indicator(&out));
}
#[test]
fn truncate_with_ellipsis_ascii_unchanged() {
assert_eq!(
super::render::truncate_with_ellipsis("hello", 4),
"hel\u{2026}"
);
assert_eq!(
super::render::truncate_with_ellipsis("hi", 10),
"hi\u{2026}"
);
}
fn gap_overlap_tree(
direction: Direction,
gap: i32,
child_w: u32,
child_h: u32,
n: usize,
area: crate::rect::Rect,
) -> LayoutNode {
use crate::style::{Align, Constraints, Justify, Margin, Padding};
let mut commands = vec![Command::BeginContainer(Box::new(BeginContainerArgs {
direction,
gap,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: None,
border_sides: BorderSides::all(),
border_style: crate::style::Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: None,
grow: 0,
group_name: None,
}))];
for _ in 0..n {
commands.push(Command::Text {
content: "x".into(),
cursor_offset: None,
style: crate::style::Style::new(),
grow: 0,
align: Align::Start,
wrap: false,
truncate: false,
margin: Margin::default(),
constraints: Constraints::default().w(child_w).h(child_h),
});
}
commands.push(Command::EndContainer);
let mut tree = build_tree(&mut commands);
compute(&mut tree, area);
tree
}
#[test]
fn gap_overlap_1_reduces_total_width() {
let tree = gap_overlap_tree(
Direction::Row,
-1,
10,
1,
2,
crate::rect::Rect::new(0, 0, 25, 4),
);
let row = &tree.children[0];
let c0 = &row.children[0];
let c1 = &row.children[1];
assert_eq!(c0.size.0, 10);
assert_eq!(c1.size.0, 10);
assert_eq!(
c1.pos.0,
c0.pos.0 + c0.size.0 - 1,
"child 1 must overlap child 0 by one column"
);
assert_eq!(c1.pos.0 + c1.size.0 - c0.pos.0, 19);
}
#[test]
fn gap_overlap_zero_is_gap_zero() {
let area = crate::rect::Rect::new(0, 0, 40, 4);
let a = gap_overlap_tree(Direction::Row, 0, 8, 1, 3, area);
let b = gap_overlap_tree(Direction::Row, 0, 8, 1, 3, area);
for i in 0..3 {
assert_eq!(a.children[0].children[i].pos, b.children[0].children[i].pos);
assert_eq!(
a.children[0].children[i].size,
b.children[0].children[i].size
);
}
let row = &a.children[0];
assert_eq!(row.children[1].pos.0, row.children[0].pos.0 + 8);
}
#[test]
fn gap_overlap_large_value_does_not_panic() {
let tree = gap_overlap_tree(
Direction::Row,
-1000,
5,
1,
3,
crate::rect::Rect::new(0, 0, 20, 4),
);
let row = &tree.children[0];
assert_eq!(row.children.len(), 3);
for child in &row.children {
assert_eq!(child.pos.0, row.children[0].pos.0);
}
}
#[test]
fn gap_positive_unchanged() {
let tree = gap_overlap_tree(
Direction::Row,
2,
6,
1,
2,
crate::rect::Rect::new(0, 0, 40, 4),
);
let row = &tree.children[0];
let c0 = &row.children[0];
let c1 = &row.children[1];
assert_eq!(c1.pos.0, c0.pos.0 + c0.size.0 + 2);
}
#[test]
fn gap_overlap_col_direction() {
let tree = gap_overlap_tree(
Direction::Column,
-1,
4,
3,
2,
crate::rect::Rect::new(0, 0, 10, 25),
);
let col = &tree.children[0];
let c0 = &col.children[0];
let c1 = &col.children[1];
assert_eq!(c0.size.1, 3);
assert_eq!(c1.size.1, 3);
assert_eq!(
c1.pos.1,
c0.pos.1 + c0.size.1 - 1,
"child 1 must overlap child 0 by one row"
);
}
mod wrap_lines_grapheme_property {
use super::wrap_lines;
use proptest::prelude::*;
use unicode_segmentation::UnicodeSegmentation;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn no_partial_cluster(
s in "[a-c \\x{1F1E6}-\\x{1F1FF}\\x{0301}\\x{0E33}\\x{0E01}]{0,32}",
w in 1u32..6,
) {
let input_clusters: std::collections::HashSet<String> =
s.graphemes(true).map(str::to_string).collect();
for line in wrap_lines(&s, w) {
for g in line.graphemes(true) {
prop_assert!(
g == " " || input_clusters.contains(g),
"output cluster {:?} was not a whole input cluster (input {:?}, width {})",
g, s, w
);
}
}
}
}
}
#[cfg(test)]
fn scrollable_row(children_widths: &[u32], gap: i32, viewport_w: u32) -> LayoutNode {
let mut row = LayoutNode::container(Direction::Row, default_container_config());
row.is_scrollable = true;
row.gap = gap;
for (i, &w) in children_widths.iter().enumerate() {
let mut t = LayoutNode::text(
format!("C{i}"),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default().min_w(w).max_w(w),
);
t.size = (w, 1);
row.children.push(t);
}
let mut root = LayoutNode::container(Direction::Column, default_container_config());
root.children.push(row);
compute(&mut root, crate::rect::Rect::new(0, 0, viewport_w, 4));
root
}
#[test]
fn scrollable_row_overflow_sets_content_width_and_lays_out_horizontally() {
let root = scrollable_row(&[10, 10, 10, 10], 1, 20);
let row = &root.children[0];
assert!(row.is_scrollable);
assert_eq!(
row.content_width, 43,
"content_width must equal the natural width (4*10 + 3*1)"
);
assert_eq!(
row.content_height, 0,
"a scrollable row has no content_height"
);
let c = &row.children;
assert_eq!(c[0].pos, (0, 0));
assert_eq!(c[1].pos.0, 11, "second child after first(10)+gap(1)");
assert_eq!(c[2].pos.0, 22);
assert_eq!(c[3].pos.0, 33);
for child in c {
assert_eq!(child.pos.1, 0, "all children share the same row (y)");
}
assert!(
c[3].pos.0 + c[3].size.0 > 20,
"the far-right child must overflow the 20-wide viewport"
);
}
#[test]
fn scrollable_row_fits_reports_content_within_viewport() {
let root = scrollable_row(&[5, 5, 5], 1, 20);
let row = &root.children[0];
assert_eq!(
row.content_width, 17,
"a fitting scrollable row records its real content width (3*5 + 2*1)"
);
let mut s = crate::widgets::ScrollState::new();
s.set_bounds_x(row.content_width, 20);
assert!(
!s.can_scroll_right(),
"content (17) ≤ viewport (20): no horizontal slack"
);
}
#[test]
fn scrollable_column_reports_zero_content_width() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut col = LayoutNode::container(Direction::Column, default_container_config());
col.is_scrollable = true;
for i in 0..10 {
let mut t = LayoutNode::text(
format!("row {i}"),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
);
t.size = (6, 1);
col.children.push(t);
}
root.children.push(col);
compute(&mut root, crate::rect::Rect::new(0, 0, 20, 4));
let col = &root.children[0];
assert_eq!(
col.content_width, 0,
"vertical scrollable: content_width == 0"
);
assert!(
col.content_height > 0,
"vertical scrollable: content_height > 0"
);
}
#[test]
fn collect_all_reports_horizontal_axis_for_scrollable_row() {
let root = scrollable_row(&[10, 10, 10, 10], 1, 20);
let mut fd = FrameData::default();
collect_all(&root, &mut fd);
assert_eq!(fd.scroll_infos.len(), 1);
let (content, viewport, is_horizontal) = fd.scroll_infos[0];
assert!(is_horizontal, "scrollable row must be tagged horizontal");
assert_eq!(content, 43, "reports content width");
assert_eq!(viewport, 20, "reports viewport width");
}
#[test]
fn collect_all_keeps_vertical_axis_flag_false_for_scrollable_column() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut col = LayoutNode::container(Direction::Column, default_container_config());
col.is_scrollable = true;
col.pos = (0, 0);
col.size = (20, 4);
col.content_height = 30;
root.children.push(col);
let mut fd = FrameData::default();
collect_all(&root, &mut fd);
let (content, viewport, is_horizontal) = fd.scroll_infos[0];
assert!(!is_horizontal, "scrollable column stays vertical (false)");
assert_eq!(content, 30);
assert_eq!(viewport, 4);
}
#[test]
fn collect_all_shifts_child_x_by_scroll_offset_x() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut row = LayoutNode::container(Direction::Row, default_container_config());
row.is_scrollable = true;
row.pos = (0, 0);
row.size = (20, 1);
row.scroll_offset_x = 7;
let mut child = LayoutNode::text(
"focusable".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
);
child.pos = (10, 0);
child.size = (9, 1);
child.focus_id = Some(3);
row.children.push(child);
root.children.push(row);
let mut fd = FrameData::default();
collect_all(&root, &mut fd);
let (_, rect) = fd
.focus_rects
.iter()
.find(|(id, _)| *id == 3)
.expect("focus rect collected");
assert_eq!(
rect.x, 3,
"child at layout x=10 with x-offset 7 must collect at screen x=3"
);
}
#[test]
fn scroll_row_renders_left_to_right_not_stacked() {
let mut tb = crate::test_utils::TestBackend::new(20, 6);
let mut scroll = crate::widgets::ScrollState::new();
tb.render(|ui| {
let _ = ui.scroll_row(&mut scroll, |ui| {
for i in 0..6 {
ui.text(format!("COL{i}"));
}
});
});
let line0 = tb.line(0);
assert!(
line0.contains("COL0") && line0.contains("COL1"),
"scroll_row must lay COL0 and COL1 on the SAME line (got {line0:?})"
);
assert_eq!(
tb.line(1),
"",
"a horizontal scroll_row must not stack children onto row 1"
);
}
#[test]
fn scroll_row_clips_far_right_then_reveals_after_scroll() {
let mut tb = crate::test_utils::TestBackend::new(16, 4);
let mut scroll = crate::widgets::ScrollState::new();
tb.render(|ui| {
let _ = ui.scroll_row(&mut scroll, |ui| {
for i in 0..8 {
ui.text(format!("[item{i:02}]"));
}
});
});
tb.render(|ui| {
let _ = ui.scroll_row(&mut scroll, |ui| {
for i in 0..8 {
ui.text(format!("[item{i:02}]"));
}
});
});
tb.assert_contains("[item00]");
tb.assert_not_contains("[item07]");
scroll.scroll_right(60);
tb.render(|ui| {
let _ = ui.scroll_row(&mut scroll, |ui| {
for i in 0..8 {
ui.text(format!("[item{i:02}]"));
}
});
});
tb.assert_contains("[item07]");
tb.assert_not_contains("[item00]");
}
#[test]
fn scroll_state_horizontal_bounds_clamp_and_predicates() {
let mut s = crate::widgets::ScrollState::new();
s.set_bounds_x(100, 20);
assert!(!s.can_scroll_left(), "at start, cannot scroll left");
assert!(s.can_scroll_right(), "content overflows, can scroll right");
s.scroll_right(1000); assert_eq!(s.offset_x, 80, "scroll_right clamps to content - viewport");
assert!(!s.can_scroll_right(), "at end, cannot scroll right");
assert!(s.can_scroll_left(), "at end, can scroll left");
s.scroll_left(1000); assert_eq!(s.offset_x, 0, "scroll_left clamps to 0");
assert!((s.progress_x() - 0.0).abs() < f64::EPSILON);
s.scroll_right(40);
assert!((s.progress_x() - 0.5).abs() < 1e-9, "40/80 == 0.5 progress");
}
#[test]
fn scroll_state_vertical_api_unchanged_by_horizontal_addition() {
let mut s = crate::widgets::ScrollState::new();
s.set_bounds(50, 10);
s.scroll_down(5);
assert_eq!(s.offset, 5);
assert_eq!(s.offset_x, 0, "vertical scroll must not touch offset_x");
assert_eq!(s.content_width(), 0);
assert_eq!(s.viewport_width(), 0);
}
mod hscroll_proptest {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn content_width_and_offset_clamp(
widths in prop::collection::vec(1u32..12, 1..8),
viewport_w in 4u32..30,
requested_offset in 0usize..200,
) {
let root = scrollable_row(&widths, 1, viewport_w);
let row = &root.children[0];
let gap: i64 = 1;
let gaps = (widths.len() as i64 - 1).max(0) * gap;
let natural: i64 = widths.iter().map(|&w| w as i64).sum::<i64>() + gaps;
prop_assert_eq!(row.content_width as i64, natural,
"scrollable row records the natural content width");
let mut fd = FrameData::default();
collect_all(&root, &mut fd);
let (content, viewport, is_horizontal) = fd.scroll_infos[0];
prop_assert!(is_horizontal);
let mut s = crate::widgets::ScrollState::new();
s.set_bounds_x(content, viewport);
s.scroll_right(requested_offset);
let max = content.saturating_sub(viewport) as usize;
prop_assert!(s.offset_x <= max,
"offset_x {} must clamp to max {}", s.offset_x, max);
prop_assert!(s.offset_x as u32 <= content,
"offset_x must never exceed total content width");
}
}
}
fn row_with(children: Vec<LayoutNode>) -> LayoutNode {
let mut n = LayoutNode::container(Direction::Row, default_container_config());
n.children = children;
n
}
fn col_with(children: Vec<LayoutNode>) -> LayoutNode {
let mut n = LayoutNode::container(Direction::Column, default_container_config());
n.children = children;
n
}
#[test]
fn pct_width_child_resolves_after_being_measured_unresolved() {
let leaf = LayoutNode::text(
"x".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
);
let mut pct_col = col_with(vec![leaf]);
pct_col.constraints = Constraints::default().w_pct(50);
let fixed_text = LayoutNode::text(
"y".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
);
let mut root = col_with(vec![row_with(vec![pct_col, fixed_text])]);
let area = crate::rect::Rect::new(0, 0, 40, 10);
compute(&mut root, area);
let row = &root.children[0];
assert_eq!(
row.children[0].constraints.width,
crate::style::WidthSpec::Fixed(20),
"pct constraint must resolve to Fixed(20)"
);
assert_eq!(
row.children[0].size.0, 20,
"pct child must be sized to its resolved 50% width, not a stale memo"
);
}
#[test]
fn min_width_memo_matches_uncached_after_resolution() {
let mut node = LayoutNode::container(Direction::Column, default_container_config());
node.constraints = Constraints::default().w_pct(50);
node.children.push(LayoutNode::text(
"abcd".to_string(),
Style::new(),
0,
Align::Start,
(None, false, false),
Margin::default(),
Constraints::default(),
));
let unresolved = node.min_width();
assert_eq!(unresolved, 4);
assert_eq!(node.min_width(), 4);
super::flexbox::resolve_axis_specs(&mut node.constraints, crate::rect::Rect::new(0, 0, 40, 4));
node.invalidate_size_cache();
assert_eq!(
node.min_width(),
20,
"after Pct resolves to Fixed(20), min_width must update, not return the stale cached 4"
);
}