From d55e48a10fbe0f12d72d94bc1fc9fca306e54f2b Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:52:21 +0000 Subject: [PATCH] Preserve percentage bases in simple float bands --- crates/obscura-render/src/dom.rs | 139 +++++++++++++++++++++ crates/obscura-render/tests/layout_test.rs | 100 +++++++++++++++ 2 files changed, 239 insertions(+) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index d6b56d3..1077b02 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -7914,6 +7914,145 @@ fn build_children_with_float_zone( ) -> Vec { let is_float = |cid: NodeId| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false); + // A definite-height block whose only substantive contents are either one + // full-width float or one percentage float followed by one percentage + // inline atom has a bounded, single float band. The general legacy path + // below cannot represent that shape: it puts the float in an auto-width, + // zero-height escape wrapper, so both percentage axes resolve against an + // indefinite synthetic box. A Bootstrap `width:100%;height:100%` column + // then collapses or moves away from block-start, and the equally common + // `[float:left;width:50%][inline-block;width:50%]` control row gives the + // float zero size. + // + // Keep this deliberately narrower than a float manager. In this exact + // one-band case a full-size flex row is only a placement representation: + // its percentage basis is the real containing block's content box, its + // block-start is the normal flow position, and it cannot incorrectly + // contain an escaping float because the parent already owns a definite + // block size. Text, multiple flow siblings, auto sizes, and floats that + // can extend beyond an auto-height parent retain the general path. + let parent_has_definite_height = styles.get(&parent_id).is_some_and(|parent| { + matches!( + parent.height, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ) && parent.size_expressions[1].is_none() + && !has_in_flow_generated_pseudo(parent.before_pseudo.as_deref()) + && !has_in_flow_generated_pseudo(parent.after_pseudo.as_deref()) + }); + if parent_has_definite_height { + let fills_axis = |value: f32| (value - 1.0).abs() < 0.001; + let substantive: Vec = dom_children + .iter() + .copied() + .filter(|cid| { + tree.get_node(*cid).is_some_and(|node| { + node.is_element() || !tree.text_content(*cid).trim().is_empty() + }) + }) + .collect(); + let floated: Vec = substantive + .iter() + .copied() + .filter(|cid| is_float(*cid)) + .collect(); + if floated.len() == 1 { + let float_dom = floated[0]; + let float_style = styles.get(&float_dom); + let float_percent_width = float_style.and_then(|style| match style.width { + crate::Dimension::Percent(value) => Some(value), + _ => None, + }); + let float_fills_height = float_style.is_some_and(|style| { + matches!(style.height, crate::Dimension::Percent(value) if fills_axis(value)) + && style.size_expressions[1].is_none() + }); + let sole_full_width_float = substantive.len() == 1 + && float_percent_width.is_some_and(fills_axis) + && float_fills_height; + let split_band_flow = if substantive.len() == 2 + && substantive[0] == float_dom + && float_style.and_then(|style| style.float) == Some(crate::Float::Left) + && float_fills_height + { + let flow_dom = substantive[1]; + styles.get(&flow_dom).and_then(|flow_style| { + let flow_percent_width = match flow_style.width { + crate::Dimension::Percent(value) => value, + _ => return None, + }; + let flow_fills_height = matches!( + flow_style.height, + crate::Dimension::Percent(value) if fills_axis(value) + ) && flow_style.size_expressions[1].is_none(); + let widths_fill_one_band = float_percent_width.is_some_and(|float_width| { + float_width > 0.0 + && flow_percent_width > 0.0 + && (float_width + flow_percent_width - 1.0).abs() < 0.001 + }); + (flow_style.display != crate::Display::None + && !flow_style.display_contents + && flow_style.is_inline_block + && flow_fills_height + && widths_fill_one_band) + .then_some(flow_dom) + }) + } else { + None + }; + + if sole_full_width_float || split_band_flow.is_some() { + let float_node = build( + tree, + float_dom, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + ); + // The split-band guard admits only a boxed inline-block + // element, so it must be built as one atomic box. Calling + // `build_any` here would be needlessly fragile: its text and + // transparent-inline branches may fan out into several nodes, + // and discovering that only after construction would leave + // detached nodes behind when falling back to the general path. + let flow_node = split_band_flow.and_then(|flow_dom| { + build( + tree, + flow_dom, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + ) + }); + if let Some(float_node) = float_node { + if sole_full_width_float || flow_node.is_some() { + let children: Vec = + [Some(float_node), flow_node].into_iter().flatten().collect(); + let band_style = taffy::Style { + display: taffy::style::Display::Flex, + flex_direction: taffy::FlexDirection::Row, + flex_wrap: taffy::FlexWrap::NoWrap, + align_items: Some(taffy::AlignItems::FLEX_START), + size: taffy::Size { + width: taffy::Dimension::percent(1.0), + height: taffy::Dimension::percent(1.0), + }, + ..Default::default() + }; + if let Ok(band) = taffy_tree.new_with_children(band_style, &children) { + return vec![band]; + } + } + } + } + } + } + // A block made entirely from inline-ish flow content and two or more // right floats is the classic utility/navigation bar. Right floats are // placed from the inline end inward, so their visual order is the reverse diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 6e24943..dffbb65 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -885,6 +885,106 @@ fn float_exclusion_continues_through_non_bfc_block_wrappers() { ); } +#[test] +fn full_width_percentage_float_uses_definite_containing_block() { + let tree = parse_html( + r#" + +
+
+
+
+
+
+
+
+
+ "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let host = rect("host"); + let float = rect("float"); + let badges = rect("badges"); + assert!( + (host.x - 0.0).abs() < 0.01 + && (host.y - 0.0).abs() < 0.01 + && (host.width - 800.0).abs() < 0.01 + && (host.height - 600.0).abs() < 0.01, + "host: {host:?}" + ); + assert!( + (float.x - 0.0).abs() < 0.01 + && (float.y - 0.0).abs() < 0.01 + && (float.width - 800.0).abs() < 0.01 + && (float.height - 600.0).abs() < 0.01, + "float: {float:?}" + ); + assert!( + (badges.x - 230.0).abs() < 0.01 + && (badges.y - 0.0).abs() < 0.01 + && (badges.width - 340.0).abs() < 0.01 + && (badges.height - 48.0).abs() < 0.01, + "nested centered badges: {badges:?}" + ); +} + +#[test] +fn percentage_float_and_inline_sibling_share_definite_height_band() { + let tree = parse_html( + r#" + +
+ "#, + ); + let layout = layout_dom(&tree, (800.0, 200.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let float = rect("float"); + let flow = rect("flow"); + assert!( + (float.x - 1.0).abs() < 0.01 + && (float.y - 1.0).abs() < 0.01 + && (float.width - 199.0).abs() < 0.01 + && (float.height - 44.0).abs() < 0.01, + "float: {float:?}" + ); + assert!( + (flow.x - 200.0).abs() < 0.01 + && (flow.y - 1.0).abs() < 0.01 + && (flow.width - 199.0).abs() < 0.01 + && (flow.height - 44.0).abs() < 0.01, + "flow: {flow:?}" + ); +} + #[test] fn replaced_image_contributes_intrinsic_size_in_ordered_grid() { let tree = parse_html(include_str!("../../../render-repros/replaced-grid-order.html"));