fix: preserve intrinsic row sizing across containing blocks

This commit is contained in:
Kinneyzhang 2026-08-25 20:55:43 +08:00
parent b25328198c
commit 175ae2ed4e
11 changed files with 239 additions and 19 deletions

View File

@ -87,6 +87,12 @@ container children are normally supplied to `ebox-row`, `ebox-column`,
use the variadic row/column helpers for more than two children. Nil children
are ignored. An empty row or column becomes a spacer.
In a `row`, omitted or `auto` widths use each childs intrinsic width when the
children are concatenated; the parent viewport is not reused as every childs
width. Explicit `viewport`, `stretch`, and definite widths still resolve against
their containing block. A `column` fills narrower layout rows to its containing
block, while a visual box paints its border and padding at its own used width.
`:key` is the stable application identity for a sibling. `:id` is a logical
selector/update identifier. `:host-ref` is an opaque application-owned anchor
used by host-reference lookup and logical candidate replacement. Host refs

View File

@ -78,6 +78,12 @@ window再切换到渲染后的 buffer。如果窗口选择由调用方负责
`ebox-stack` 接受两个节点;超过两个子节点时使用 row/column。nil 子节点会
被忽略;空 row/column 会变成 spacer。
`row` 中省略或 `auto` 的宽度按子节点自身的 intrinsic width 参与横向拼接;
父级 viewport 不会被重复当成每个子节点的宽度。显式 `viewport`、`stretch`
或 definite width 仍按指定 containing block 解析。`column` 会把较窄的布局
行补齐到自己的 containing block但带视觉 box 的子节点会在自己的 used width
上绘制边框和 padding。
`:key` 是兄弟节点范围内的稳定业务 identity`:id` 是 logical selector/update
标识;`:host-ref` 是应用拥有的不透明锚点,用于 host-reference 查询和逻辑
candidate replacement。一个 source tree 内的非 nil host-ref 必须唯一。

View File

@ -424,12 +424,14 @@ this avoids signature and retained-cost work for one-off responsive sizes."
(context
(list (ebox--ensure-node-id source)
axis main cross align viewport
ebox--intrinsic-layout-measurement)))
ebox--intrinsic-layout-measurement
ebox--inline-auto-width-intrinsic-p)))
(if (gethash context observations)
(let ((ebox-viewport-width viewport))
(list (ebox--current-display-signature)
viewport ebox-viewport-height
ebox--intrinsic-layout-measurement
ebox--inline-auto-width-intrinsic-p
ebox--render-region-id
ebox--scroll-window-initial-lookahead-lines-override
ebox--scroll-window-render-disabled
@ -964,6 +966,8 @@ Return a sized render entry containing the rendered string and dimensions."
source axis main cross align item-viewport
(list :intrinsic-layout-measurement
ebox--intrinsic-layout-measurement
:inline-auto-width-intrinsic
ebox--inline-auto-width-intrinsic-p
:scroll-window-render-disabled
ebox--scroll-window-render-disabled
:viewport-height-dependent

View File

@ -333,6 +333,8 @@ the short runway that makes immediate post-resize smooth scrolling cheap."
(defvar ebox--viewport-dependent-subtree-cache nil
"Dynamic render-pass cache for viewport dependency existence checks.")
(defvar ebox--inline-auto-width-intrinsic-p)
(defvar ebox--viewport-height-dependent-subtree-cache nil
"Dynamic render-pass cache for viewport-height dependency checks.")
@ -420,6 +422,7 @@ Kept as a narrow predicate for callers that need box-only sizing semantics."
ebox-viewport-width)
(when (ebox--viewport-height-dependent-subtree-p node)
ebox-viewport-height)
ebox--inline-auto-width-intrinsic-p
(ebox--render-cache-node-body-signature node)))
(defun ebox--render-cache-box-signature (box)

View File

@ -69,6 +69,12 @@
Descendants still see the current containing block, but stack/column containers
must not stretch their own outer width to that containing block.")
(defvar ebox--inline-auto-width-intrinsic-p nil
"Non-nil while a horizontal layout sizes auto-width children intrinsically.
The ambient viewport remains available to explicit `viewport' and `stretch'
widths, but an omitted or `auto' width must not consume the whole horizontal
containing block merely because the row itself is being rendered in it.")
(defvar ebox--box-content-render-cache nil
"Dynamic render-pass cache for rendered lazy child content.")
@ -882,6 +888,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(list ebox-viewport-width
ebox-viewport-height
ebox--intrinsic-layout-measurement
ebox--inline-auto-width-intrinsic-p
ebox--render-region-id))
(defun ebox--render-box-content-node (box node)
@ -1021,7 +1028,8 @@ property parsers."
props (ebox-get props :max-width)))
(preferred-width
(or (ebox--definite-content-size-pixel props width-value)
(and (or (null width-value)
(and (not ebox--inline-auto-width-intrinsic-p)
(or (null width-value)
(eq width-value 'auto)
(eq width-value 'fit-content))
(ebox--stretch-content-pixel props))
@ -1040,11 +1048,16 @@ an inherited viewport."
(let ((content-viewport (ebox--wrapper-content-viewport-pixel props)))
(cond
((eq (ebox-get props :width) 'max-content)
(let ((ebox-viewport-width nil)
(ebox--intrinsic-layout-measurement t))
(let ((ebox-viewport-width nil)
(ebox--intrinsic-layout-measurement t)
(ebox--inline-auto-width-intrinsic-p nil))
(ebox-render node)))
(content-viewport
(let ((ebox-viewport-width content-viewport))
(let ((ebox-viewport-width content-viewport)
;; A definite wrapper establishes a new containing block. Its
;; child layout must resume normal cross-axis stretch semantics,
;; even when the wrapper itself is an item of an intrinsic row.
(ebox--inline-auto-width-intrinsic-p nil))
(if (and (hash-table-p ebox--render-cache-table)
(not ebox--intrinsic-layout-measurement)
(null ebox--render-region-id)
@ -1058,6 +1071,8 @@ an inherited viewport."
:content-viewport content-viewport
:viewport-height ebox-viewport-height
:intrinsic ebox--intrinsic-layout-measurement
:auto-width-intrinsic
ebox--inline-auto-width-intrinsic-p
:render-region-id ebox--render-region-id
:scroll-lookahead
ebox--scroll-window-initial-lookahead-lines-override
@ -1103,7 +1118,8 @@ FALLBACK is used for nil and auto values."
(preferred-width
(cond
((or (null width-value) (eq width-value 'auto))
(or (ebox--stretch-content-pixel box)
(or (and (not ebox--inline-auto-width-intrinsic-p)
(ebox--stretch-content-pixel box))
(ebox--content-max-pixel box)))
((or (eq width-value 'viewport)
(memq width-value '(stretch contain)))
@ -1688,10 +1704,19 @@ A sole child Range keeps a material row parent so the Range stays addressable."
(defun ebox--render-concat (node)
"Render a concat NODE to a string (internal, called by `ebox-render').
Children are rendered; shorter children are padded with blank lines so all
reach the same height before horizontal concatenation."
(let* ((rendered (mapcar #'ebox--render-with-cache
(ebox--layout-children node)))
Auto-width children are rendered at intrinsic inline size while explicit
viewport/definite widths keep their containing-block semantics. Shorter
children are padded with blank lines so all reach the same height before
horizontal concatenation."
(let* ((rendered
(mapcar (lambda (child)
;; A row owns the horizontal concatenation. Its auto
;; width children must therefore measure intrinsically;
;; explicit viewport/stretch widths still resolve against
;; the ambient containing block.
(let ((ebox--inline-auto-width-intrinsic-p t))
(ebox--render-with-cache child)))
(ebox--layout-children node)))
(max-h (if rendered
(apply #'max (mapcar #'ebox-string-height rendered))
0))
@ -1925,7 +1950,9 @@ Return a plist with :lines and :complete. This mirrors the normal stack
renderer for the top window of a scroll container, but stops before hidden
leaves are rendered. When CACHE is supplied, rendering continues from the
last cached leaf instead of walking the stack from the beginning."
(let* ((target-w (or (ebox--viewport-pixel-width nil) 0))
(let* ((target-w (if ebox--inline-auto-width-intrinsic-p
0
(or (ebox--viewport-pixel-width nil) 0)))
(cached (and (ebox--stack-window-cache-compatible-p
cache node target-w)
cache))
@ -3054,7 +3081,9 @@ Stack leaves are rendered independently. When a containing-block viewport is
bound, narrower rows are padded to that available width, while wider definite
children keep their own width and overflow only on their own rows. During
intrinsic measurement, descendants still receive the containing block but the
stack itself uses its natural max child width."
stack itself uses its natural max child width. A stack used as an intrinsic
inline child follows the same natural-width rule and does not consume the
ambient horizontal viewport."
(let* ((rendered-items
(mapcar (lambda (leaf)
(let ((rendered (ebox--render-with-cache leaf)))
@ -3064,7 +3093,8 @@ stack itself uses its natural max child width."
(max-w (if rendered-items
(apply #'max (mapcar #'cdr rendered-items))
0))
(target-w (if ebox--intrinsic-layout-measurement
(target-w (if (or ebox--intrinsic-layout-measurement
ebox--inline-auto-width-intrinsic-p)
max-w
(or (ebox--viewport-pixel-width nil) max-w))))
(let ((rendered

View File

@ -330,6 +330,7 @@ pub struct LayoutContext {
pub viewport_width: i64,
pub viewport_width_known: bool,
pub viewport_height: i64,
pub inline_auto_width_intrinsic: bool,
}
#[derive(Debug, Clone, Copy)]
@ -3416,7 +3417,9 @@ fn resolve_child_viewport_width(
let minimum = definite(min_width).unwrap_or(0);
let maximum = definite(max_width);
let auto_stretch = if matches!(width, Size::Auto) {
stretch
(!context.inline_auto_width_intrinsic)
.then_some(stretch)
.flatten()
} else {
None
};
@ -3512,7 +3515,11 @@ fn resolve_definite_box_content_width(
let maximum = definite(max_width);
let preferred = override_width
.or_else(|| definite(width))
.or_else(|| matches!(width, Size::Auto).then_some(stretch).flatten())
.or_else(|| {
(matches!(width, Size::Auto) && !context.inline_auto_width_intrinsic)
.then_some(stretch)
.flatten()
})
.or(maximum);
preferred.map(|preferred| minimum.max(preferred.max(0).min(maximum.unwrap_or(i64::MAX))))
}
@ -4177,6 +4184,7 @@ fn measure_flex_item<'a>(
},
viewport_width_known: uses_inline_viewport && inline_viewport.is_some(),
viewport_height: context.viewport_height,
inline_auto_width_intrinsic: context.inline_auto_width_intrinsic,
};
let rendered = render_node(&item.node, measurement_context, uses_inline_viewport)?;
let min_main = flex_min_main(&item.node, &rendered, axis, context)?;
@ -4546,6 +4554,7 @@ fn render_flex_sized_entry(
item_viewport.is_some() || context.viewport_width_known
},
viewport_height: context.viewport_height,
inline_auto_width_intrinsic: context.inline_auto_width_intrinsic,
};
let override_size = box_override_for_flex(item.source, axis, main, cross, stretch);
let mut rendered =
@ -4706,7 +4715,11 @@ fn render_node_window(
return Some(render_node(node, context, intrinsic));
}
match node {
LayoutNode::Column { children } if !intrinsic && context.viewport_width_known => {
LayoutNode::Column { children }
if !intrinsic
&& !context.inline_auto_width_intrinsic
&& context.viewport_width_known =>
{
Some(render_column_window(children, context, start, height))
}
_ => Some(
@ -5286,6 +5299,8 @@ fn render_node_with_override(
viewport_width_known: !intrinsic_child
&& (preliminary_child_width.is_some() || context.viewport_width_known),
viewport_height: preliminary_child_height.unwrap_or(context.viewport_height),
inline_auto_width_intrinsic: context.inline_auto_width_intrinsic
&& matches!(effective_width, Size::Auto),
};
if simple_scroll_window {
if let Some(total_height) = exact_rendered_height(child, child_context) {
@ -5331,7 +5346,11 @@ fn render_node_with_override(
let rendered = child_rendered.as_ref().expect("validated child");
(rendered.min_content_width(*wrap_mode), rendered.max_width())
};
let auto_width = stretch.unwrap_or(max_content);
let auto_width = if context.inline_auto_width_intrinsic {
max_content
} else {
stretch.unwrap_or(max_content)
};
let minimum = resolve_width(
min_width,
Some(0),
@ -5630,9 +5649,13 @@ fn render_node_with_override(
Ok(rendered)
}
LayoutNode::Row { children } => {
let child_context = LayoutContext {
inline_auto_width_intrinsic: true,
..context
};
let rendered = children
.iter()
.map(|child| render_node(child, context, intrinsic))
.map(|child| render_node(child, child_context, intrinsic))
.collect::<Result<Vec<_>, _>>()?;
let height = rendered
.iter()
@ -5665,7 +5688,10 @@ fn render_node_with_override(
.map(Rendered::first_width)
.max()
.unwrap_or(0);
let target = if intrinsic || !context.viewport_width_known {
let target = if intrinsic
|| context.inline_auto_width_intrinsic
|| !context.viewport_width_known
{
maximum
} else {
context.viewport_width.max(0)
@ -5810,6 +5836,19 @@ mod tests {
}
}
fn auto_text_box(
region_id: i64,
content: MeasuredText,
surface_template_id: Option<u32>,
) -> LayoutNode {
let mut node = text_box(region_id, content, surface_template_id);
let LayoutNode::Box { width, .. } = &mut node else {
unreachable!();
};
*width = Size::Auto;
node
}
fn child_box(
region_id: i64,
child: LayoutNode,
@ -5899,6 +5938,7 @@ mod tests {
viewport_width: 80,
viewport_width_known: true,
viewport_height: 24,
inline_auto_width_intrinsic: false,
}
}
@ -5953,6 +5993,7 @@ mod tests {
viewport_width: 80,
viewport_width_known: true,
viewport_height: MAX_LAYOUT_WORK_UNITS as i64 + 1,
inline_auto_width_intrinsic: false,
},
&mut context_work,
)
@ -6279,6 +6320,7 @@ mod tests {
viewport_width: 320,
viewport_width_known: true,
viewport_height: 10,
inline_auto_width_intrinsic: false,
};
let fit_content = Size::FitContent {
limit: Some(Box::new(Size::Pixels { value: 180 })),
@ -6319,6 +6361,7 @@ mod tests {
viewport_width: 0,
viewport_width_known: false,
viewport_height: 0,
inline_auto_width_intrinsic: false,
};
assert_eq!(
resolve_width(
@ -6347,6 +6390,7 @@ mod tests {
viewport_width: 298,
viewport_width_known: true,
viewport_height: 20,
inline_auto_width_intrinsic: false,
},
BoxSizing::BorderBox,
0,
@ -6365,11 +6409,13 @@ mod tests {
viewport_width: 0,
viewport_width_known: true,
viewport_height: 10,
inline_auto_width_intrinsic: false,
};
let unavailable = LayoutContext {
viewport_width: 0,
viewport_width_known: false,
viewport_height: 10,
inline_auto_width_intrinsic: false,
};
assert_eq!(flex_horizontal_size(&Size::Auto, known_zero), Some(0));
@ -6874,6 +6920,50 @@ mod tests {
);
}
#[test]
fn row_auto_children_do_not_duplicate_column_viewport() {
let row = LayoutNode::Row {
children: vec![
auto_text_box(2, measured_text(vec![vec![cluster("Left", 4, None)]]), None),
auto_text_box(
3,
measured_text(vec![vec![cluster("Right", 5, None)]]),
None,
),
],
};
let mut root = child_box(
1,
LayoutNode::Column {
children: vec![
row,
auto_text_box(4, measured_text(vec![vec![cluster("Body", 4, None)]]), None),
],
},
None,
);
let LayoutNode::Box { width, .. } = &mut root else {
unreachable!();
};
*width = Size::Pixels { value: 120 };
let document = LayoutDocument {
version: LAYOUT_VERSION,
space_width: 1,
style_count: 0,
property_template_count: 0,
styles: Vec::new(),
root,
};
document.validate().unwrap();
let tape = document.layout_tape(test_context(), None).unwrap();
assert!(
tape.lines.iter().all(|line| line.width <= 120),
"row children duplicated the containing column width: {:?}",
tape.lines.iter().map(|line| line.width).collect::<Vec<_>>()
);
}
#[test]
fn fixed_height_scroll_layout_uses_nonzero_offset_window() {
let document = fixed_scroll_column_document(7);

View File

@ -995,6 +995,7 @@ fn checked_layout_context(
viewport_width,
viewport_width_known,
viewport_height,
inline_auto_width_intrinsic: false,
};
document.validate_context(context)?;
Ok(context)

View File

@ -4246,6 +4246,16 @@
(equal (ebox--render-cache-value-signature value)
value))))
(ert-deftest ebox-render-cache-signature-includes-inline-auto-width-context ()
"Row intrinsic sizing must not reuse a cross-axis stretch render."
(ebox-test--reset-runtime-state)
(let ((node (ebox-create :content "context-sensitive")))
(let ((normal (let ((ebox--inline-auto-width-intrinsic-p nil))
(ebox--render-cache-node-signature node)))
(intrinsic (let ((ebox--inline-auto-width-intrinsic-p t))
(ebox--render-cache-node-signature node))))
(should-not (equal normal intrinsic)))))
(ert-deftest ebox-render-cache-signature-includes-rendered-region-identity ()
"Cached render strings should never cross distinct region identities."
(ebox-test--reset-runtime-state)

View File

@ -237,6 +237,53 @@
(dolist (width widths)
(should (<= width 180)))))
(ert-deftest ebox-build-column-contains-auto-row-children ()
"A column wrapper must not let an auto-width row child duplicate its viewport."
(let* ((node (ebox-build
'(column :width (120)
(row
(box :content "Left" :border t)
(box :content "Right" :border t))
(box :content "Body" :border t))))
(widths (ebox-dsl-test--line-widths node)))
(should (cl-every (lambda (width) (<= width 120)) widths))))
(ert-deftest ebox-row-keeps-explicit-viewport-widths-intrinsic-context ()
"Row intrinsic sizing must not change explicit viewport-sized children."
(let* ((ebox-viewport-width 120)
(node (ebox-row
(ebox-create :content "Left" :width 'viewport)
(ebox-create :content "Right" :width 'stretch)))
(widths (ebox-dsl-test--line-widths node)))
(should (equal widths '(240)))))
(ert-deftest ebox-row-keeps-composite-column-intrinsic-width ()
"A composite column child must not consume the row viewport as auto width."
(let* ((ebox-viewport-width 120)
(node (ebox-row
(ebox-column
(ebox-create :content "Top")
(ebox-create :content "Bottom"))
(ebox-create :content "Tail")))
(widths (ebox-dsl-test--line-widths node)))
(should (cl-every (lambda (width) (<= width 120)) widths))))
(ert-deftest ebox-build-lazy-column-contains-auto-row-children ()
"Lazy column windows must apply the same intrinsic row sizing rule."
(let* ((ebox-viewport-width 120)
(ebox-viewport-height 4)
(node (ebox-build
'(box :width (viewport)
:height (viewport-height)
:overflow scroll
(column
(row
(box :content "Left" :border t)
(box :content "Right" :border t))
(box :content "Body" :border t)))))
(widths (ebox-dsl-test--line-widths node)))
(should (cl-every (lambda (width) (<= width 120)) widths))))
(ert-deftest ebox-build-wrapper-viewport-width-bounds-child-layout-viewport ()
"A wrapper's `(viewport)' width should pass its content box to child layouts."
(let ((cases

View File

@ -738,6 +738,17 @@
(should (= (nth 2 widths) (nth 0 widths)))
(should (= (nth 3 widths) (nth 0 widths)))))
(ert-deftest ebox-flex-composite-auto-row-respects-assigned-width ()
"A composite auto-width row must not duplicate its Flex item viewport."
(let* ((layout
(ebox-flex :width '(120)
(ebox-row
(ebox-create :content "Left" :border t)
(ebox-create :content "Right" :border t))))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render layout)))))
(should (cl-every (lambda (width) (<= width 120)) widths))))
(ert-deftest ebox-flex-sized-persistent-hit-populates-pass-local-cache ()
"Persistent sized renders should become reusable in the current pass."
(let* ((source (ebox-create :content "A" :width '(80)))

View File

@ -80,6 +80,18 @@
(widths (mapcar #'ebox--string-pixel-width lines)))
(should (equal widths (make-list (length widths) 120)))))
(ert-deftest ebox-grid-composite-auto-row-respects-track-width ()
"A composite auto-width row must not duplicate its Grid track viewport."
(let* ((node (ebox-grid
:width '(120)
:grid-template-columns '((120))
(ebox-row
(ebox-create :content "Left" :border t)
(ebox-create :content "Right" :border t))))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render node)))))
(should (equal widths '(120)))))
(ert-deftest ebox-grid-sizes-auto-row-after-assigned-width-wrap ()
"Auto rows should use the height of width-constrained child content."
(let* ((node (ebox-grid