From 8385f496ca0a618ebc89c62d46f16d5ee71f5301 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:17:49 +0000 Subject: [PATCH] Use native floats for cleared bands --- crates/obscura-render/src/dom.rs | 282 +++++++++++++++++++-- crates/obscura-render/tests/layout_test.rs | 117 +++++++++ vendor/taffy/src/compute/float.rs | 17 +- 3 files changed, 388 insertions(+), 28 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 06a4d92..4118a10 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -5912,6 +5912,8 @@ fn build( // approximation corrupts flex sizing and percentage-margin placement. let has_float_child = style.display == crate::Display::Block && dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); + let native_float_band = + has_float_child && can_use_native_float_band(tree, style, &dom_children, styles); let has_in_flow_block_child = dom_children.iter().any(|cid| { styles.get(cid).map_or(false, |child| { child.display == crate::Display::Block @@ -5961,6 +5963,7 @@ fn build( if stacks_children_vertically && has_inline_ish_content + && !native_float_band && !(has_float_child && has_in_flow_block_child) { taffy_style.display = taffy::style::Display::Flex; @@ -5980,7 +5983,26 @@ fn build( taffy_style.align_items = Some(taffy::AlignItems::FLEX_START); } - let mut child_ids: Vec = if has_float_child { + if native_float_band { + // Native float placement is only selected for the guarded flat-band + // shape. It requires a real block formatting context on the parent. + taffy_style.display = taffy::style::Display::Block; + } + + let mut child_ids: Vec = if native_float_band { + build_children_with_native_float_band( + tree, + id, + style, + &dom_children, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + ) + } else if has_float_child { build_children_with_float_zone(tree, id, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles) } else if matches!(style.display, crate::Display::Flex | crate::Display::Grid) && !style.internal_flex_container @@ -5999,26 +6021,28 @@ fn build( } else { dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect() }; - if let Some((mut before, _)) = build_in_flow_pseudo( - id, - GeneratedBoxKind::Before, - style.before_pseudo.as_deref(), - taffy_tree, - words, - ifc_items, - ) { - before.append(&mut child_ids); - child_ids = before; - } - if let Some((mut after, _)) = build_in_flow_pseudo( - id, - GeneratedBoxKind::After, - style.after_pseudo.as_deref(), - taffy_tree, - words, - ifc_items, - ) { - child_ids.append(&mut after); + if !native_float_band { + if let Some((mut before, _)) = build_in_flow_pseudo( + id, + GeneratedBoxKind::Before, + style.before_pseudo.as_deref(), + taffy_tree, + words, + ifc_items, + ) { + before.append(&mut child_ids); + child_ids = before; + } + if let Some((mut after, _)) = build_in_flow_pseudo( + id, + GeneratedBoxKind::After, + style.after_pseudo.as_deref(), + taffy_tree, + words, + ifc_items, + ) { + child_ids.append(&mut after); + } } if style.internal_flex_container @@ -6812,6 +6836,222 @@ fn establishes_block_formatting_context(style: &crate::LayoutStyle) -> bool { || matches!(style.position, Some(taffy::Position::Absolute)) } +fn clear_matches_float_sides(clear: crate::Clear, has_left: bool, has_right: bool) -> bool { + (!has_left || matches!(clear, crate::Clear::Left | crate::Clear::Both)) + && (!has_right || matches!(clear, crate::Clear::Right | crate::Clear::Both)) +} + +fn has_deferred_or_auto_margin(style: &crate::LayoutStyle) -> bool { + style.margin_auto.iter().any(|value| *value) + || style.margin_percent.iter().any(Option::is_some) + || style.margin_relative.iter().any(Option::is_some) + || style.margin_expressions.iter().any(Option::is_some) +} + +fn is_structural_native_clear_box( + tree: &DomTree, + id: NodeId, + style: &crate::LayoutStyle, +) -> bool { + style.display == crate::Display::Block + && !style.display_contents + && !style.is_table_box + && !style.is_inline_block + && !style.flow_root + && !style.overflow_hidden + && effective_container_type(style) == crate::ContainerType::Normal + && style.float.is_none() + && style.clear.is_some() + && !matches!(style.position, Some(taffy::Position::Absolute)) + && tree.text_content(id).trim().is_empty() + && style.before_pseudo.is_none() + && style.after_pseudo.is_none() +} + +fn is_structural_native_float_pseudo(style: &crate::LayoutStyle) -> bool { + style.display != crate::Display::None + && style.display != crate::Display::Inline + && !style.display_contents + && !style.is_inline_block + && (!style.flow_root || style.is_table_box) + && !style.overflow_hidden + && effective_container_type(style) == crate::ContainerType::Normal + && style.float.is_none() + && !matches!(style.position, Some(taffy::Position::Absolute)) + && style + .before_content + .as_deref() + .map_or(true, |content| content.trim().is_empty()) +} + +/// Native taffy floats are currently sound for a deliberately small structural +/// subset: a flat block band made only of definite-width direct floats plus +/// empty generated/direct clearance boxes. Text flow, transparent wrappers, +/// shrink-to-fit floats, independent formatting contexts, and tables retain +/// the synthetic float-zone path. +fn can_use_native_float_band( + tree: &DomTree, + parent_style: &crate::LayoutStyle, + dom_children: &[NodeId], + styles: &HashMap, +) -> bool { + if parent_style.display != crate::Display::Block + || parent_style.internal_flex_container + || parent_style.is_inline_block + || parent_style.is_table_box + || parent_style.float.is_some() + || matches!(parent_style.position, Some(taffy::Position::Absolute)) + || !matches!( + parent_style.width, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ) + || parent_style.size_expressions[0].is_some() + { + return false; + } + + let mut has_left = false; + let mut has_right = false; + let mut saw_float = false; + let mut saw_clear_after_floats = false; + + if let Some(before) = parent_style.before_pseudo.as_deref() { + if !is_structural_native_float_pseudo(before) { + return false; + } + } + + for &id in dom_children { + let Some(node) = tree.get_node(id) else { continue }; + if !node.is_element() { + if !tree.text_content(id).trim().is_empty() { + return false; + } + continue; + } + let Some(style) = styles.get(&id) else { + return false; + }; + if style.display == crate::Display::None { + continue; + } + + if let Some(side) = style.float { + if saw_clear_after_floats + || style.display_contents + || style.is_table_box + || matches!(style.position, Some(taffy::Position::Absolute)) + || !matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_)) + || style.size_expressions[0].is_some() + || has_deferred_or_auto_margin(style) + { + return false; + } + saw_float = true; + has_left |= side == crate::Float::Left; + has_right |= side == crate::Float::Right; + } else if is_structural_native_clear_box(tree, id, style) { + if !saw_float { + return false; + } + saw_clear_after_floats |= clear_matches_float_sides( + style.clear.expect("structural clear has a side"), + has_left, + has_right, + ); + } else { + return false; + } + } + + if let Some(after) = parent_style.after_pseudo.as_deref() { + if !is_structural_native_float_pseudo(after) { + return false; + } + if let Some(clear) = after.clear { + saw_clear_after_floats |= clear_matches_float_sides(clear, has_left, has_right); + } + } + + // Taffy represents overflow clipping as a real BFC root. Other Obscura + // BFC markers do not yet have a distinct taffy-side representation, so + // they are intentionally not used as an escape-safety signal here. + let parent_is_native_bfc = parent_style.overflow_hidden; + saw_float && (saw_clear_after_floats || parent_is_native_bfc) +} + +fn set_native_float_clear( + taffy_tree: &mut TaffyTree, + node: taffy::NodeId, + style: &crate::LayoutStyle, + generated_pseudo: bool, +) { + let Ok(current) = taffy_tree.style(node) else { return }; + let mut native = current.clone(); + native.float = match style.float { + Some(crate::Float::Left) => taffy::style::Float::Left, + Some(crate::Float::Right) => taffy::style::Float::Right, + None => taffy::style::Float::None, + }; + native.clear = match style.clear { + Some(crate::Clear::Left) => taffy::style::Clear::Left, + Some(crate::Clear::Right) => taffy::style::Clear::Right, + Some(crate::Clear::Both) => taffy::style::Clear::Both, + None => taffy::style::Clear::None, + }; + if generated_pseudo && style.is_table_box { + // Bootstrap-style clearfix pseudos use display:table only to generate + // an empty block formatting box. Taffy's independent table-item clear + // path is incomplete, while its same-BFC block clear path is correct. + native.display = taffy::style::Display::Block; + native.item_is_table = false; + } + let _ = taffy_tree.set_style(node, native); +} + +#[allow(clippy::too_many_arguments)] +fn build_children_with_native_float_band( + tree: &DomTree, + parent_id: NodeId, + parent_style: &crate::LayoutStyle, + dom_children: &[NodeId], + taffy_tree: &mut TaffyTree, + id_map: &mut HashMap, + words: &mut HashMap, + engine: &mut crate::inline::TextEngine, + ifc_items: &mut IfcRegistry, + styles: &HashMap, +) -> Vec { + let mut result = Vec::new(); + for (kind, pseudo) in [ + (GeneratedBoxKind::Before, parent_style.before_pseudo.as_deref()), + (GeneratedBoxKind::After, parent_style.after_pseudo.as_deref()), + ] { + if kind == GeneratedBoxKind::After { + for &id in dom_children { + let Some(style) = styles.get(&id) else { continue }; + for node in build_any( + tree, id, taffy_tree, id_map, words, engine, ifc_items, styles, + ) { + set_native_float_clear(taffy_tree, node, style, false); + result.push(node); + } + } + } + if let Some((nodes, _)) = + build_in_flow_pseudo(parent_id, kind, pseudo, taffy_tree, words, ifc_items) + { + if let Some(style) = pseudo { + for node in nodes { + set_native_float_clear(taffy_tree, node, style, true); + result.push(node); + } + } + } + } + result +} + fn build_children_with_float_zone( tree: &DomTree, parent_id: NodeId, diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 9644e78..7d5c059 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -533,6 +533,123 @@ fn percentage_flex_overlay_uses_auto_width_parent_content_box() { assert!((sidebar.width - 320.0).abs() < 0.01, "sidebar: {sidebar:?}"); } +#[test] +fn native_float_band_matches_bootstrap_clearfix_geometry() { + let tree = parse_html(include_str!( + "../../../render-repros/bootstrap-float-clearfix.html" + )); + let layout = layout_dom(&tree, (900.0, 1000.0)); + let rect = |selector| { + let id = tree.query_selector(selector).unwrap().unwrap(); + layout.rects[&id] + }; + let row = rect(".row"); + let column = rect(".column"); + let centered = rect(".centered"); + let card = rect(".card"); + assert!( + (row.x - 0.0).abs() < 0.01 + && (row.y - 0.0).abs() < 0.01 + && (row.width - 900.0).abs() < 0.01 + && (row.height - 1000.0).abs() < 0.01, + "row: {row:?}" + ); + assert!( + (column.x - 0.0).abs() < 0.01 + && (column.y - 0.0).abs() < 0.01 + && (column.width - 900.0).abs() < 0.01 + && (column.height - 1000.0).abs() < 0.01, + "column: {column:?}" + ); + assert!( + (centered.x - 0.0).abs() < 0.01 + && (centered.y - 0.0).abs() < 0.01 + && (centered.width - 900.0).abs() < 0.01 + && (centered.height - 900.0).abs() < 0.01, + "centered: {centered:?}" + ); + assert!( + (card.x - 250.0).abs() < 0.01 + && (card.y - 200.0).abs() < 0.01 + && (card.width - 400.0).abs() < 0.01 + && (card.height - 500.0).abs() < 0.01, + "card: {card:?}" + ); +} + +#[test] +fn native_percentage_float_uses_padded_containing_block_content_width() { + let tree = parse_html( + r#" +
"#, + ); + let layout = layout_dom(&tree, (600.0, 300.0)); + let band = layout.rects[&tree.get_element_by_id("band").unwrap()]; + let float = layout.rects[&tree.get_element_by_id("float").unwrap()]; + let clear = layout.rects[&tree.get_element_by_id("clear").unwrap()]; + assert!( + (band.width - 300.0).abs() < 0.01 && (band.height - 80.0).abs() < 0.01, + "band: {band:?}" + ); + assert!( + (float.x - 20.0).abs() < 0.01 + && (float.y - 20.0).abs() < 0.01 + && (float.width - 130.0).abs() < 0.01 + && (float.height - 40.0).abs() < 0.01, + "float: {float:?}" + ); + assert!( + (clear.x - 20.0).abs() < 0.01 && (clear.y - 60.0).abs() < 0.01, + "clear: {clear:?}" + ); +} + +#[test] +fn native_float_clearance_is_side_sensitive() { + let tree = parse_html( + r#" +
+
"#, + ); + let layout = layout_dom(&tree, (600.0, 300.0)); + let matching = tree.get_element_by_id("matching").unwrap(); + let other = tree.get_element_by_id("other-side").unwrap(); + let matching_clear = tree + .query_selector("#matching .clear") + .unwrap() + .unwrap(); + let other_clear = tree + .query_selector("#other-side .clear") + .unwrap() + .unwrap(); + let matching_rect = layout.rects[&matching]; + let other_rect = layout.rects[&other]; + let matching_clear_rect = layout.rects[&matching_clear]; + let other_clear_rect = layout.rects[&other_clear]; + assert!( + (matching_clear_rect.y - 50.0).abs() < 0.01 + && (matching_rect.height - 60.0).abs() < 0.01, + "matching: {matching_rect:?} clear: {matching_clear_rect:?}" + ); + assert!( + (other_clear_rect.y - 60.0).abs() < 0.01 + && (other_rect.height - 50.0).abs() < 0.01, + "other: {other_rect:?} clear: {other_clear_rect:?}" + ); +} + #[test] fn float_flow_zone_preserves_blocks_inline_runs_and_clearance() { let tree = parse_html(include_str!("../../../render-repros/float-flow-bands.html")); diff --git a/vendor/taffy/src/compute/float.rs b/vendor/taffy/src/compute/float.rs index e565ac0..17c0b52 100644 --- a/vendor/taffy/src/compute/float.rs +++ b/vendor/taffy/src/compute/float.rs @@ -452,14 +452,17 @@ impl FloatContext { /// Get the end segment of the last float on side(s) specified by the clear parameter (if any) fn cleared_segment(&self, clear: Clear) -> Option { + let placed_end = |slot: usize| { + let placed = &self.last_placed_floats[slot]; + (placed.start < placed.end).then_some(placed.end) + }; match clear { - Clear::Left => Some(self.last_placed_floats[0].end), - Clear::Right => Some(self.last_placed_floats[1].end), - Clear::Both => { - let left_end = self.last_placed_floats[0].end; - let right_end = self.last_placed_floats[1].end; - Some(left_end.max(right_end)) - } + Clear::Left => placed_end(0), + Clear::Right => placed_end(1), + Clear::Both => match (placed_end(0), placed_end(1)) { + (Some(left), Some(right)) => Some(left.max(right)), + (left, right) => left.or(right), + }, Clear::None => None, } }