use rich_rs::{Console, ConsoleOptions, Segments};
use textual::layout::{Region, inspect_node_rects, resolve_layout};
use textual::node_id::NodeId;
use textual::style::{
BoxSizing, Color, Dock, Layout, Offset, OffsetValue, Position, Scalar, Spacing, Split, Style,
};
use textual::widget_tree::WidgetTree;
use textual::widgets::Widget;
struct TestWidget {
label: &'static str,
inline_style: Option<Style>,
}
impl TestWidget {
fn new(label: &'static str) -> Self {
Self {
label,
inline_style: None,
}
}
fn with_style(mut self, style: Style) -> Self {
self.inline_style = Some(style);
self
}
fn boxed(label: &'static str) -> Box<dyn Widget> {
Box::new(Self::new(label))
}
fn boxed_with_style(label: &'static str, style: Style) -> Box<dyn Widget> {
Box::new(Self::new(label).with_style(style))
}
}
impl Widget for TestWidget {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn style_type(&self) -> &'static str {
self.label
}
fn style(&self) -> Option<Style> {
self.inline_style.clone()
}
}
struct IntrinsicWidget {
label: &'static str,
inline_style: Option<Style>,
intrinsic_w: usize,
intrinsic_h: usize,
}
impl IntrinsicWidget {
fn boxed_with_style(
label: &'static str,
intrinsic_w: usize,
intrinsic_h: usize,
style: Style,
) -> Box<dyn Widget> {
Box::new(Self {
label,
inline_style: Some(style),
intrinsic_w,
intrinsic_h,
})
}
}
impl Widget for IntrinsicWidget {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn style_type(&self) -> &'static str {
self.label
}
fn style(&self) -> Option<Style> {
self.inline_style.clone()
}
fn content_width(&self) -> Option<usize> {
Some(self.intrinsic_w)
}
fn layout_height(&self) -> Option<usize> {
Some(self.intrinsic_h)
}
}
fn assert_layout(tree: &WidgetTree, node: NodeId, x0: u16, y0: u16, x1: u16, y1: u16) {
let (layout, _content) = inspect_node_rects(tree, node).expect("node not found");
assert_eq!(layout, (x0, y0, x1, y1), "layout_rect mismatch");
}
#[test]
fn p2g24_absolute_removed_from_flow() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let abs_child = tree.mount(
root,
TestWidget::boxed_with_style("Abs", {
let mut s = Style::new()
.height(Scalar::Cells(10))
.width(Scalar::Cells(20));
s.position = Some(Position::Absolute);
s
}),
);
let flow_child = tree.mount(
root,
TestWidget::boxed_with_style("Flow", Style::new().height(Scalar::Cells(5))),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, flow_child, 0, 0, 80, 5);
assert_layout(&tree, abs_child, 0, 0, 20, 10);
}
#[test]
fn p2g24_absolute_with_offset() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let abs_child = tree.mount(
root,
TestWidget::boxed_with_style("Abs", {
let mut s = Style::new()
.height(Scalar::Cells(10))
.width(Scalar::Cells(20));
s.position = Some(Position::Absolute);
s.offset = Some(Offset {
x: OffsetValue::Cells(5),
y: OffsetValue::Cells(3),
});
s
}),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, abs_child, 5, 3, 25, 13);
}
#[test]
fn p2g24_absolute_does_not_reduce_dock_space() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let abs_child = tree.mount(
root,
TestWidget::boxed_with_style("Abs", {
let mut s = Style::new().height(Scalar::Cells(30));
s.position = Some(Position::Absolute);
s
}),
);
let docked = tree.mount(
root,
TestWidget::boxed_with_style("Header", {
let mut s = Style::new().height(Scalar::Cells(3));
s.dock = Some(Dock::Top);
s
}),
);
let flow_child = tree.mount(root, TestWidget::boxed("Body"));
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, docked, 0, 0, 80, 3);
assert_layout(&tree, flow_child, 0, 3, 80, 50);
assert_layout(&tree, abs_child, 0, 0, 80, 30);
}
#[test]
fn p2g24_absolute_applies_min_constraints() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let abs_child = tree.mount(
root,
TestWidget::boxed_with_style("Abs", {
let mut s = Style::new()
.height(Scalar::Cells(5))
.width(Scalar::Cells(10));
s.position = Some(Position::Absolute);
s.min_width = Some(Scalar::Cells(30));
s.min_height = Some(Scalar::Cells(20));
s
}),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, abs_child, 0, 0, 30, 20);
}
#[test]
fn p2g24_absolute_applies_max_constraints() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let abs_child = tree.mount(
root,
TestWidget::boxed_with_style("Abs", {
let mut s = Style::new();
s.position = Some(Position::Absolute);
s.max_width = Some(Scalar::Cells(25));
s.max_height = Some(Scalar::Cells(12));
s
}),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, abs_child, 0, 0, 25, 12);
}
#[test]
fn p2g25_content_box_adds_chrome() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let child = tree.mount(
root,
TestWidget::boxed_with_style("Child", {
let mut s = Style::new()
.height(Scalar::Cells(10))
.width(Scalar::Cells(20))
.padding(Spacing::new(1, 2, 1, 2))
.border_top(Color::rgb(255, 255, 255))
.border_bottom(Color::rgb(255, 255, 255))
.border_left(Color::rgb(255, 255, 255))
.border_right(Color::rgb(255, 255, 255));
s.box_sizing = Some(BoxSizing::ContentBox);
s
}),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
let (layout, content) = inspect_node_rects(&tree, child).unwrap();
let layout_w = layout.2 - layout.0;
let layout_h = layout.3 - layout.1;
assert_eq!(
layout_w, 26,
"content-box: layout width should include chrome"
);
assert_eq!(
layout_h, 14,
"content-box: layout height should include chrome"
);
let content_w = content.2 - content.0;
let content_h = content.3 - content.1;
assert_eq!(content_w, 20, "content-box: content width");
assert_eq!(content_h, 10, "content-box: content height");
}
#[test]
fn p2g25_border_box_includes_chrome() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let child = tree.mount(
root,
TestWidget::boxed_with_style("Child", {
let mut s = Style::new()
.height(Scalar::Cells(14))
.width(Scalar::Cells(26))
.padding(Spacing::new(1, 2, 1, 2))
.border_top(Color::rgb(255, 255, 255))
.border_bottom(Color::rgb(255, 255, 255))
.border_left(Color::rgb(255, 255, 255))
.border_right(Color::rgb(255, 255, 255));
s.box_sizing = Some(BoxSizing::BorderBox);
s
}),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
let (layout, content) = inspect_node_rects(&tree, child).unwrap();
let layout_w = layout.2 - layout.0;
let layout_h = layout.3 - layout.1;
assert_eq!(layout_w, 26, "border-box: layout width == specified width");
assert_eq!(
layout_h, 14,
"border-box: layout height == specified height"
);
let content_w = content.2 - content.0;
let content_h = content.3 - content.1;
assert_eq!(content_w, 20, "border-box: content width = 26 - 6");
assert_eq!(content_h, 10, "border-box: content height = 14 - 4");
}
#[test]
fn p2g25_border_box_horizontal_layout() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed_with_style("Container", {
let mut s = Style::new();
s.layout = Some(Layout::Horizontal);
s
}));
let a = tree.mount(
root,
TestWidget::boxed_with_style("A", {
let mut s = Style::new()
.width(Scalar::Cells(30))
.padding(Spacing::new(0, 5, 0, 5));
s.box_sizing = Some(BoxSizing::BorderBox);
s
}),
);
let b = tree.mount(root, TestWidget::boxed("B"));
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
let (a_layout, a_content) = inspect_node_rects(&tree, a).unwrap();
let a_w = a_layout.2 - a_layout.0;
assert_eq!(a_w, 30, "border-box in horizontal: layout width");
let a_cw = a_content.2 - a_content.0;
assert_eq!(a_cw, 20, "border-box in horizontal: content width");
let (b_layout, _) = inspect_node_rects(&tree, b).unwrap();
assert_eq!(b_layout.0, 30, "B starts after A");
}
#[test]
fn p2g26_split_top_carves_space() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let split_child = tree.mount(
root,
TestWidget::boxed_with_style("SplitTop", {
let mut s = Style::new().height(Scalar::Cells(5));
s.split = Some(Split::Top);
s
}),
);
let flow_child = tree.mount(
root,
TestWidget::boxed_with_style("Body", Style::new().height(Scalar::Cells(10))),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, split_child, 0, 0, 80, 5);
assert_layout(&tree, flow_child, 0, 5, 80, 15);
}
#[test]
fn p2g26_split_left_carves_space() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed_with_style("Container", {
let mut s = Style::new();
s.layout = Some(Layout::Horizontal);
s
}));
let split_child = tree.mount(
root,
TestWidget::boxed_with_style("SplitLeft", {
let mut s = Style::new().width(Scalar::Cells(20));
s.split = Some(Split::Left);
s
}),
);
let flow_child = tree.mount(root, TestWidget::boxed("Body"));
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, split_child, 0, 0, 20, 50);
let (flow_l, _) = inspect_node_rects(&tree, flow_child).unwrap();
assert_eq!(flow_l.0, 20, "flow starts after split");
}
#[test]
fn p2g26_split_processed_before_dock() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let split_child = tree.mount(
root,
TestWidget::boxed_with_style("SplitTop", {
let mut s = Style::new().height(Scalar::Cells(5));
s.split = Some(Split::Top);
s
}),
);
let dock_child = tree.mount(
root,
TestWidget::boxed_with_style("DockTop", {
let mut s = Style::new().height(Scalar::Cells(3));
s.dock = Some(Dock::Top);
s
}),
);
let flow_child = tree.mount(root, TestWidget::boxed("Body"));
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
assert_layout(&tree, split_child, 0, 0, 80, 5);
assert_layout(&tree, dock_child, 0, 5, 80, 8);
let (flow_l, _) = inspect_node_rects(&tree, flow_child).unwrap();
assert_eq!(flow_l.1, 8, "flow starts after split + dock");
}
#[test]
fn p2g27_effective_padding_per_side_overrides() {
let mut s = Style::new().padding(Spacing::new(1, 2, 3, 4));
s.padding_top = Some(10);
s.padding_right = Some(20);
let eff = s.effective_padding();
assert_eq!(eff.top, 10, "per-side top overrides shorthand");
assert_eq!(eff.right, 20, "per-side right overrides shorthand");
assert_eq!(eff.bottom, 3, "shorthand bottom preserved");
assert_eq!(eff.left, 4, "shorthand left preserved");
}
#[test]
fn p2g27_effective_margin_per_side_overrides() {
let mut s = Style::new().margin(Spacing::new(5, 6, 7, 8));
s.margin_bottom = Some(99);
let eff = s.effective_margin();
assert_eq!(eff.top, 5);
assert_eq!(eff.right, 6);
assert_eq!(eff.bottom, 99, "per-side bottom overrides shorthand");
assert_eq!(eff.left, 8);
}
#[test]
fn p2g27_effective_padding_no_shorthand() {
let mut s = Style::new();
s.padding_left = Some(3);
let eff = s.effective_padding();
assert_eq!(eff.top, 0);
assert_eq!(eff.right, 0);
assert_eq!(eff.bottom, 0);
assert_eq!(eff.left, 3);
}
#[test]
fn p2g27_per_side_padding_in_layout() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let child = tree.mount(
root,
TestWidget::boxed_with_style("Child", {
let mut s = Style::new()
.height(Scalar::Cells(10))
.padding(Spacing::new(1, 1, 1, 1));
s.padding_left = Some(5); s
}),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
let (_layout, content) = inspect_node_rects(&tree, child).unwrap();
assert_eq!(content.0, 5, "content_x reflects per-side padding_left");
let content_w = content.2 - content.0;
assert_eq!(content_w, 74, "content_w reflects per-side override");
}
#[test]
fn p2g27_per_side_margin_in_layout() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let child = tree.mount(
root,
TestWidget::boxed_with_style("Child", {
let mut s = Style::new()
.height(Scalar::Cells(10))
.margin(Spacing::new(2, 2, 2, 2));
s.margin_left = Some(10); s
}),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 80, 50), (80, 50));
let (layout, _content) = inspect_node_rects(&tree, child).unwrap();
assert_eq!(layout.0, 10, "layout_x reflects per-side margin_left");
let layout_w = layout.2 - layout.0;
assert_eq!(layout_w, 68, "layout_w reflects per-side margin override");
}
#[test]
fn p2g33_column_span_2_in_2col_grid() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let wide = tree.mount(
root,
TestWidget::boxed_with_style("Wide", {
let mut s = Style::new();
s.column_span = Some(2);
s
}),
);
let normal = tree.mount(root, TestWidget::boxed("Normal"));
let parent_style = {
let mut s = Style::new();
s.layout = Some(Layout::Grid);
s.grid_size_columns = Some(2);
s
};
textual::layout::layout_grid(
&mut tree,
&[wide, normal],
Region::new(0, 0, 80, 60),
(80, 60),
&parent_style,
);
let (wide_l, _) = inspect_node_rects(&tree, wide).unwrap();
let wide_w = wide_l.2 - wide_l.0;
assert_eq!(wide_w, 80, "column-span: 2 should span full width");
let (normal_l, _) = inspect_node_rects(&tree, normal).unwrap();
let normal_w = normal_l.2 - normal_l.0;
assert_eq!(normal_w, 40, "normal child gets one column");
assert_eq!(
normal_l.1, wide_l.3,
"normal starts in next row after spanning child"
);
}
#[test]
fn p2g33_row_span_2_in_2row_grid() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let tall = tree.mount(
root,
TestWidget::boxed_with_style("Tall", {
let mut s = Style::new();
s.row_span = Some(2);
s
}),
);
let b = tree.mount(root, TestWidget::boxed("B"));
let c = tree.mount(root, TestWidget::boxed("C"));
let parent_style = {
let mut s = Style::new();
s.layout = Some(Layout::Grid);
s.grid_size_columns = Some(2);
s
};
textual::layout::layout_grid(
&mut tree,
&[tall, b, c],
Region::new(0, 0, 80, 60),
(80, 60),
&parent_style,
);
let (tall_l, _) = inspect_node_rects(&tree, tall).unwrap();
let tall_h = tall_l.3 - tall_l.1;
assert_eq!(tall_h, 60, "row-span: 2 should span full height");
assert_eq!(tall_l.0, 0, "tall starts at col 0");
let (b_l, _) = inspect_node_rects(&tree, b).unwrap();
assert_eq!(b_l.0, 40, "B at col 1");
assert_eq!(b_l.1, 0, "B at row 0");
let (c_l, _) = inspect_node_rects(&tree, c).unwrap();
assert_eq!(c_l.0, 40, "C at col 1");
assert_eq!(c_l.1, 30, "C at row 1");
}
#[test]
fn p2g33_span_with_gutter() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let wide = tree.mount(
root,
TestWidget::boxed_with_style("Wide", {
let mut s = Style::new();
s.column_span = Some(2);
s
}),
);
let a = tree.mount(root, TestWidget::boxed("A"));
let b = tree.mount(root, TestWidget::boxed("B"));
let parent_style = {
let mut s = Style::new();
s.layout = Some(Layout::Grid);
s.grid_size_columns = Some(3);
s.grid_gutter_vertical = Some(2);
s
};
textual::layout::layout_grid(
&mut tree,
&[wide, a, b],
Region::new(0, 0, 94, 30),
(94, 30),
&parent_style,
);
let (wide_l, _) = inspect_node_rects(&tree, wide).unwrap();
let wide_w = wide_l.2 - wide_l.0;
assert_eq!(wide_w, 62, "span width includes inter-column gutter");
let (a_l, _) = inspect_node_rects(&tree, a).unwrap();
assert_eq!(a_l.0, 64, "A starts at col 2 offset");
}
#[test]
fn p2g33_no_span_preserves_existing_behavior() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let a = tree.mount(root, TestWidget::boxed("A"));
let b = tree.mount(root, TestWidget::boxed("B"));
let c = tree.mount(root, TestWidget::boxed("C"));
let d = tree.mount(root, TestWidget::boxed("D"));
let parent_style = {
let mut s = Style::new();
s.layout = Some(Layout::Grid);
s.grid_size_columns = Some(2);
s
};
textual::layout::layout_grid(
&mut tree,
&[a, b, c, d],
Region::new(0, 0, 80, 50),
(80, 50),
&parent_style,
);
assert_layout(&tree, a, 0, 0, 40, 25);
assert_layout(&tree, b, 40, 0, 80, 25);
assert_layout(&tree, c, 0, 25, 40, 50);
assert_layout(&tree, d, 40, 25, 80, 50);
}
#[test]
fn p2g35_expand_vertical_grows_intrinsic_child() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed_with_style("Container", {
let mut s = Style::new();
s.layout = Some(Layout::Vertical);
s
}));
let expanded = tree.mount(
root,
IntrinsicWidget::boxed_with_style("Expanded", 4, 1, {
let mut s = Style::new();
s.expand = Some(true);
s
}),
);
let fixed = tree.mount(
root,
IntrinsicWidget::boxed_with_style("Fixed", 4, 1, Style::new()),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 40, 20), (40, 20));
let (expanded_l, _) = inspect_node_rects(&tree, expanded).unwrap();
let (fixed_l, _) = inspect_node_rects(&tree, fixed).unwrap();
let expanded_h = expanded_l.3 - expanded_l.1;
let fixed_h = fixed_l.3 - fixed_l.1;
assert!(
expanded_h > fixed_h,
"expand=true child should receive more vertical space ({expanded_h} vs {fixed_h})"
);
}
#[test]
fn p2g35_expand_horizontal_grows_intrinsic_child() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed_with_style("Container", {
let mut s = Style::new();
s.layout = Some(Layout::Horizontal);
s
}));
let expanded = tree.mount(
root,
IntrinsicWidget::boxed_with_style("Expanded", 1, 1, {
let mut s = Style::new();
s.expand = Some(true);
s
}),
);
let fixed = tree.mount(
root,
IntrinsicWidget::boxed_with_style("Fixed", 1, 1, Style::new()),
);
resolve_layout(&mut tree, root, Region::new(0, 0, 40, 10), (40, 10));
let (expanded_l, _) = inspect_node_rects(&tree, expanded).unwrap();
let (fixed_l, _) = inspect_node_rects(&tree, fixed).unwrap();
let expanded_w = expanded_l.2 - expanded_l.0;
let fixed_w = fixed_l.2 - fixed_l.0;
assert!(
expanded_w > fixed_w,
"expand=true child should receive more horizontal space ({expanded_w} vs {fixed_w})"
);
}
#[test]
fn p2_33_span_exceeding_grid_is_clamped() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let wide = tree.mount(
root,
TestWidget::boxed_with_style("Wide", {
let mut s = Style::new();
s.column_span = Some(4); s
}),
);
let normal = tree.mount(root, TestWidget::boxed("Normal"));
let parent_style = {
let mut s = Style::new();
s.layout = Some(Layout::Grid);
s.grid_size_columns = Some(2);
s
};
textual::layout::layout_grid(
&mut tree,
&[wide, normal],
Region::new(0, 0, 80, 60),
(80, 60),
&parent_style,
);
let (wide_l, _) = inspect_node_rects(&tree, wide).unwrap();
let wide_w = wide_l.2 - wide_l.0;
assert_eq!(
wide_w, 80,
"column-span: 4 should be clamped to grid width (2 cols = 80)"
);
}
#[test]
fn p2_33_overlapping_spans_use_occupancy() {
let mut tree = WidgetTree::new();
let root = tree.set_root(TestWidget::boxed("Container"));
let a = tree.mount(
root,
TestWidget::boxed_with_style("A", {
let mut s = Style::new();
s.column_span = Some(2);
s
}),
);
let b = tree.mount(
root,
TestWidget::boxed_with_style("B", {
let mut s = Style::new();
s.column_span = Some(2);
s
}),
);
let parent_style = {
let mut s = Style::new();
s.layout = Some(Layout::Grid);
s.grid_size_columns = Some(3);
s
};
textual::layout::layout_grid(
&mut tree,
&[a, b],
Region::new(0, 0, 90, 60),
(90, 60),
&parent_style,
);
let (a_l, _) = inspect_node_rects(&tree, a).unwrap();
let a_w = a_l.2 - a_l.0;
assert_eq!(a_w, 60, "A should span 2 columns");
assert_eq!(a_l.1, 0, "A should be in row 0");
let (b_l, _) = inspect_node_rects(&tree, b).unwrap();
assert!(
b_l.1 > a_l.1,
"B should be in a later row than A (occupancy prevents overlap): B.y0={} A.y0={}",
b_l.1,
a_l.1
);
let b_w = b_l.2 - b_l.0;
assert_eq!(b_w, 60, "B should also span 2 columns");
}