Files
rust_browser/crates/display_list/src/builder.rs
Zachary D. Rowitsch 9d86a5c2cf Implement iframe static rendering with code review fixes (§4.8.5)
Add iframe support: srcdoc/src content fetching, independent pipeline
rendering, style isolation, dimension attributes, and UA stylesheet
defaults. Includes code review fixes for recursion guard, dimension
capping, alpha compositing, spec-compliant u32 parsing, base URL
correctness, font loading, and golden test infrastructure that exercises
the full iframe rendering pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 01:13:02 -04:00

1456 lines
60 KiB
Rust

use layout::{BoxType, InlineFragment, LayoutBox, LayoutTree};
use shared::{BorderStyle, CornerRadii, EdgeColors, EdgeSizes, EdgeStyles, Rect};
use style::{BorderCollapse, EmptyCells, Float, ListStylePosition, Overflow, Position, Visibility};
use crate::{DisplayItem, DisplayList};
// =============================================================================
// Display List Builder
// =============================================================================
/// A positioned or stacking-context descendant collected from the subtree
/// for rendering at the correct CSS paint-order step.
struct StackingParticipant<'a> {
layout_box: &'a LayoutBox,
/// Accumulated offset of this element's parent in the tree.
parent_offset: CumulativeOffset,
/// Clip rects from intermediate overflow:hidden ancestors between
/// the owning stacking context and this element.
ancestor_clips: Vec<Rect>,
}
/// Collected stacking context descendants bucketed by CSS 2.1 Appendix E (§E.2) steps.
struct StackingBuckets<'a> {
/// Step 2: child stacking contexts with negative z-index
negative: Vec<StackingParticipant<'a>>,
/// Step 6: child stacking contexts with z-index:0
zero: Vec<StackingParticipant<'a>>,
/// Step 7: child stacking contexts with positive z-index (>0)
positive: Vec<StackingParticipant<'a>>,
}
/// Cumulative offset for position: relative elements.
/// This offset is accumulated as we traverse the tree, so children
/// of a relatively positioned element also get the offset applied.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct CumulativeOffset {
pub(crate) x: f32,
pub(crate) y: f32,
}
impl CumulativeOffset {
pub(crate) fn add(&self, x: f32, y: f32) -> Self {
Self {
x: self.x + x,
y: self.y + y,
}
}
pub(crate) fn apply(&self, rect: &Rect) -> Rect {
if self.x == 0.0 && self.y == 0.0 {
*rect
} else {
Rect::new(
rect.x() + self.x,
rect.y() + self.y,
rect.width(),
rect.height(),
)
}
}
}
/// Controls which parts of a box are rendered during CSS 2.1 Appendix E paint ordering.
#[derive(Clone, Copy, PartialEq)]
enum RenderPhase {
/// Render backgrounds, borders, box-shadows, images, markers only (step 3)
BackgroundsOnly,
/// Render inline content (IFC fragments, text) only (step 5)
InlineContentOnly,
/// Render everything — used for floats (step 4) and other full-render contexts
Full,
}
pub struct DisplayListBuilder {
list: DisplayList,
pub(crate) scroll_offset_y: f32,
viewport_width: f32,
pub(crate) viewport_height: f32,
}
impl Default for DisplayListBuilder {
fn default() -> Self {
Self::new()
}
}
impl DisplayListBuilder {
pub fn new() -> Self {
Self {
list: DisplayList::new(),
scroll_offset_y: 0.0,
viewport_width: 0.0,
viewport_height: 0.0,
}
}
pub fn with_scroll_offset(scroll_y: f32) -> Self {
Self {
list: DisplayList::new(),
scroll_offset_y: scroll_y,
viewport_width: 0.0,
viewport_height: 0.0,
}
}
pub fn build(mut self, tree: &LayoutTree) -> DisplayList {
self.viewport_width = tree.viewport_width;
self.viewport_height = tree.viewport_height;
// Paint canvas background as a full-viewport rect before any layout boxes.
let viewport_rect = Rect::new(0.0, 0.0, tree.viewport_width, tree.viewport_height);
if tree.canvas_background_color.a != 0 {
self.list.push(DisplayItem::SolidRect {
rect: viewport_rect,
color: tree.canvas_background_color,
radii: CornerRadii::default(),
});
}
if let Some(ref gradient) = tree.canvas_background_gradient {
self.list.push(DisplayItem::LinearGradient {
rect: viewport_rect,
gradient: gradient.clone(),
radii: CornerRadii::default(),
});
}
if let Some(image_id) = tree.canvas_background_image_id {
self.list.push(DisplayItem::BackgroundImage {
clip_rect: viewport_rect,
image_id,
position_x: tree.canvas_background_position_x,
position_y: tree.canvas_background_position_y,
repeat: tree.canvas_background_repeat,
attachment: tree.canvas_background_attachment,
radii: CornerRadii::default(),
});
}
if let Some(ref root) = tree.root {
let initial_offset = CumulativeOffset {
x: 0.0,
y: -self.scroll_offset_y,
};
self.render_layout_box(root, initial_offset, true);
}
self.list
}
fn render_layout_box(
&mut self,
layout_box: &LayoutBox,
offset: CumulativeOffset,
is_root: bool,
) {
// Render using stacking context rules
self.render_stacking_context(layout_box, offset, is_root);
}
/// Render a layout box following CSS 2.1 Appendix E stacking context rules.
/// Paint order within a stacking context:
/// 1. Background and borders of the stacking context element
/// 2. Child stacking contexts with negative z-index (sorted by z-index)
/// 3. In-flow, non-inline-level, non-positioned descendants (block bg/borders)
/// 4. Non-positioned floats (painted as mini stacking contexts)
/// 5. In-flow, inline-level, non-positioned descendants (inline content)
/// 6. Positioned descendants with z-index: auto or 0
/// 7. Child stacking contexts with positive z-index (sorted by z-index)
fn render_stacking_context(
&mut self,
layout_box: &LayoutBox,
parent_offset: CumulativeOffset,
is_root: bool,
) {
// Fixed elements ignore scroll offset - reset to zero
let base_offset = if layout_box.position == Position::Fixed {
CumulativeOffset::default()
} else {
parent_offset
};
// Add this element's render offset + sticky offset to the cumulative offset
let (sticky_x, sticky_y) = self.compute_sticky_offset(layout_box, base_offset);
let offset = base_offset.add(
layout_box.render_offset_x + sticky_x,
layout_box.render_offset_y + sticky_y,
);
let is_visible = layout_box.visibility == Visibility::Visible;
// CSS 2.1 §11.1.2: Apply clip rect for absolutely positioned elements
let has_clip = layout_box.clip.is_some();
if let Some(clip) = &layout_box.clip {
// clip: rect(top, right, bottom, left) — offsets relative to the element's border box
let border_box = offset.apply(&layout_box.dimensions.border_box());
let clip_top = clip.top.unwrap_or(0.0);
let clip_left = clip.left.unwrap_or(0.0);
let clip_bottom = clip.bottom.unwrap_or(border_box.height());
let clip_right = clip.right.unwrap_or(border_box.width());
let clip_rect = Rect::new(
border_box.x() + clip_left,
border_box.y() + clip_top,
(clip_right - clip_left).max(0.0),
(clip_bottom - clip_top).max(0.0),
);
self.list.push(DisplayItem::PushClip { rect: clip_rect });
}
// Step 1: Render container's own background/border (skip if hidden).
if is_visible {
self.render_box_shadows(layout_box, offset, false);
self.render_background(layout_box, offset);
self.render_borders(layout_box, offset);
self.render_box_shadows(layout_box, offset, true);
self.render_image(layout_box, offset);
self.render_list_marker(layout_box, offset);
}
// Check if we need to clip overflow
let needs_clip = layout_box.overflow_x != Overflow::Visible
|| layout_box.overflow_y != Overflow::Visible;
if needs_clip {
let clip_rect = if is_root {
// CSS 2.1 §11.1.1: overflow on root applies to the viewport, pinned at origin
Rect::new(0.0, 0.0, self.viewport_width, self.viewport_height)
} else {
offset.apply(&layout_box.dimensions.padding_box())
};
self.list.push(DisplayItem::PushClip { rect: clip_rect });
}
// Categorize direct children for stacking order.
// Positioned-auto direct children are handled here (step 6).
let mut normal_flow_children: Vec<&LayoutBox> = Vec::new();
let mut positioned_auto_children: Vec<&LayoutBox> = Vec::new();
let has_ifc = layout_box.inline_context.is_some();
// Only collect stacking participants if this box actually creates a
// stacking context (or is root). Positioned-auto elements do NOT create
// stacking contexts — their stacking context descendants are hoisted to
// the nearest ancestor stacking context via collect_stacking_participants.
let owns_stacking_context = is_root || layout_box.creates_stacking_context();
for child in &layout_box.children {
if child.creates_stacking_context() {
// Always skip stacking context children from normal categorization.
// When owns_stacking_context is true, they're collected below via
// collect_stacking_participants. When false (positioned-auto parent),
// they've already been collected by the nearest ancestor SC.
continue;
} else if child.is_positioned()
&& !(has_ifc && child.box_type == layout::BoxType::Inline)
{
// Positioned-auto block-level children are rendered at step 6.
// Inline positioned children in an IFC are handled by the IFC
// fragment system, not extracted to step 6.
positioned_auto_children.push(child);
} else if has_ifc
&& (child.box_type == layout::BoxType::Anonymous
|| child.box_type == layout::BoxType::Inline)
{
// Anonymous and inline children are already represented in the IFC
// fragments; rendering them as normal flow would double-paint
// the text (once from the IFC at step 5, once from the child box).
continue;
} else {
// Normal flow (including floats — floats are separated during rendering)
normal_flow_children.push(child);
}
}
// Collect stacking context descendants from the entire subtree (tree order).
// Only done when this box owns a stacking context — positioned-auto elements
// don't collect because their SC descendants are hoisted to the parent SC.
let mut buckets = StackingBuckets {
negative: Vec::new(),
zero: Vec::new(),
positive: Vec::new(),
};
if owns_stacking_context {
self.collect_stacking_participants(layout_box, offset, &[], &[], &mut buckets, is_root);
}
// Stable sort preserves tree order for equal z-index
buckets
.negative
.sort_by_key(|p| p.layout_box.z_index.unwrap_or(0));
buckets
.positive
.sort_by_key(|p| p.layout_box.z_index.unwrap_or(0));
// Step 2: Render negative z-index stacking contexts
for p in &buckets.negative {
let parent_offset = p.parent_offset;
self.render_with_ancestor_clips(p, |builder| {
builder.render_stacking_context(p.layout_box, parent_offset, false);
});
}
// Steps 3-5: Render normal flow with proper float ordering.
// CSS 2.1 Appendix E: block backgrounds (step 3) → floats (step 4) → inline content (step 5).
// Pass 1 (step 3): render only backgrounds/borders of normal flow children
for child in &normal_flow_children {
self.render_layout_box_normal(child, offset, true, RenderPhase::BackgroundsOnly);
}
// Pass 2 (step 4): collect and render all floats (full render)
// Both direct float children of this stacking context and nested float descendants.
let mut floats: Vec<(&LayoutBox, CumulativeOffset)> = Vec::new();
for child in &normal_flow_children {
if child.float != Float::None {
// Direct float child — render in float layer
floats.push((child, offset));
} else {
// Non-float child — collect nested float descendants
self.collect_descendant_floats(child, offset, &mut floats);
}
}
for (float_box, float_offset) in floats {
self.render_layout_box_normal(float_box, float_offset, false, RenderPhase::Full);
}
// For IFC elements, render non-inline descendants (e.g., InlineBlock within
// inline wrappers) that were skipped by the Inline child exclusion above.
if has_ifc {
self.render_non_inline_descendants(
layout_box,
offset,
true,
RenderPhase::BackgroundsOnly,
);
}
// Pass 3 (step 5): render only inline content of normal flow children
for child in &normal_flow_children {
self.render_layout_box_normal(child, offset, true, RenderPhase::InlineContentOnly);
}
// For IFC elements, also render inline content of non-inline descendants.
if has_ifc {
self.render_non_inline_descendants(
layout_box,
offset,
true,
RenderPhase::InlineContentOnly,
);
}
// Pass 4 (step 5 cont.): inline content of the stacking context element itself
if is_visible {
if let Some(ref ifc) = layout_box.inline_context {
self.render_inline_fragments(ifc, offset);
} else {
self.render_text(layout_box, offset);
}
}
// Step 6: Render child stacking contexts with z-index:0 AND positioned
// descendants with z-index:auto, in tree order (CSS 2.1 Appendix E step 6).
// Collect all step-6 participants and merge in tree order using node_id.
{
// Each entry: (node_id for tree ordering, layout_box, offset, ancestor_clips)
let mut step6: Vec<(usize, &LayoutBox, CumulativeOffset, Vec<Rect>)> = Vec::new();
// Direct positioned-auto children
for child in positioned_auto_children {
step6.push((child.node_id.0, child, offset, Vec::new()));
}
// Deep positioned-auto descendants from normal flow subtree
let mut deep_positioned: Vec<(&LayoutBox, CumulativeOffset)> = Vec::new();
for child in &normal_flow_children {
if child.float == Float::None {
self.collect_positioned_auto_descendants(child, offset, &mut deep_positioned);
}
}
if has_ifc {
self.collect_positioned_auto_descendants(layout_box, offset, &mut deep_positioned);
}
for (pos_box, pos_offset) in deep_positioned {
step6.push((pos_box.node_id.0, pos_box, pos_offset, Vec::new()));
}
// z-index:0 stacking contexts from full subtree
for p in buckets.zero {
step6.push((
p.layout_box.node_id.0,
p.layout_box,
p.parent_offset,
p.ancestor_clips,
));
}
// Stable sort by node_id to get tree order.
// Invariant: NodeId values increase in document (DFS) order,
// so sorting by node_id.0 yields correct tree order.
step6.sort_by_key(|(nid, _, _, _)| *nid);
for (_, lb, item_offset, clips) in &step6 {
for clip in clips {
self.list.push(DisplayItem::PushClip { rect: *clip });
}
self.render_stacking_context(lb, *item_offset, false);
for _ in clips {
self.list.push(DisplayItem::PopClip);
}
}
}
// Step 7: Render positive z-index (>0) stacking contexts
for p in &buckets.positive {
let parent_offset = p.parent_offset;
self.render_with_ancestor_clips(p, |builder| {
builder.render_stacking_context(p.layout_box, parent_offset, false);
});
}
// Render collapsed borders (on top of all cell backgrounds)
if is_visible {
self.render_collapsed_borders(layout_box, offset);
}
// Step 10 (CSS 2.1 Appendix E): Render outline on top of all content
if is_visible {
self.render_outline(layout_box, offset);
}
if needs_clip {
self.list.push(DisplayItem::PopClip);
}
// Emit scroll indicators for overflow: scroll (always) or auto (when content overflows)
if !is_root && needs_clip {
self.emit_scroll_indicators(layout_box, offset);
}
// Pop clip rect for CSS clip property (CSS 2.1 §11.1.2)
if has_clip {
self.list.push(DisplayItem::PopClip);
}
}
/// Render a layout box in normal flow.
/// When `skip_floats` is true, float children at any depth are skipped
/// (they will be collected and rendered separately by the stacking context).
/// The `phase` parameter controls which parts are rendered for Appendix E ordering.
fn render_layout_box_normal(
&mut self,
layout_box: &LayoutBox,
parent_offset: CumulativeOffset,
skip_floats: bool,
phase: RenderPhase,
) {
// Skip float children when in skip_floats mode
if skip_floats && layout_box.float != Float::None {
return;
}
// Fixed elements ignore scroll offset - reset to zero
let base_offset = if layout_box.position == Position::Fixed {
CumulativeOffset::default()
} else {
parent_offset
};
// Add this element's render offset + sticky offset to the cumulative offset
let (sticky_x, sticky_y) = self.compute_sticky_offset(layout_box, base_offset);
let offset = base_offset.add(
layout_box.render_offset_x + sticky_x,
layout_box.render_offset_y + sticky_y,
);
let is_visible = layout_box.visibility == Visibility::Visible;
// CSS 2.1 §17.6.1.1: empty-cells: hide suppresses backgrounds and borders
// for empty table cells in the separate border model.
let hide_empty_cell = layout_box.box_type == layout::BoxType::TableCell
&& layout_box.empty_cells == EmptyCells::Hide
&& layout_box.border_collapse == BorderCollapse::Separate
&& Self::is_cell_empty(layout_box);
// Render container's own background/border (skip if hidden or in InlineContentOnly phase).
if is_visible && phase != RenderPhase::InlineContentOnly && !hide_empty_cell {
self.render_box_shadows(layout_box, offset, false);
self.render_background(layout_box, offset);
self.render_borders(layout_box, offset);
self.render_box_shadows(layout_box, offset, true);
self.render_image(layout_box, offset);
self.render_list_marker(layout_box, offset);
}
// Check if we need to clip overflow
let needs_clip = layout_box.overflow_x != Overflow::Visible
|| layout_box.overflow_y != Overflow::Visible;
if needs_clip {
let clip_rect = offset.apply(&layout_box.dimensions.padding_box());
self.list.push(DisplayItem::PushClip { rect: clip_rect });
}
// Handle inline formatting context
if let Some(ref ifc) = layout_box.inline_context {
// Only render inline content if visible and not in BackgroundsOnly phase
if is_visible && phase != RenderPhase::BackgroundsOnly {
self.render_inline_fragments(ifc, offset);
}
// Always render block-level and inline-block children
// (they may override visibility).
self.render_non_inline_descendants(layout_box, offset, skip_floats, phase);
} else {
// No IFC: existing behavior
if is_visible && phase != RenderPhase::BackgroundsOnly {
self.render_text(layout_box, offset);
}
for child in &layout_box.children {
// Skip non-inline stacking-context children — they have been hoisted
// to the owning stacking context's paint-order steps 2/6/7.
if skip_floats
&& child.box_type != layout::BoxType::Inline
&& child.creates_stacking_context()
{
continue;
}
// Non-inline positioned-auto elements: skip during phased passes
// (collected by collect_positioned_auto_descendants for step 6).
// In Full phase, render via render_stacking_context for Appendix E ordering.
// Inline positioned elements (e.g., position:relative spans) stay in flow.
if child.is_positioned()
&& !child.creates_stacking_context()
&& child.box_type != layout::BoxType::Inline
{
if phase == RenderPhase::Full {
self.render_stacking_context(child, offset, false);
}
// In BackgroundsOnly/InlineContentOnly, skip entirely —
// they'll be collected by collect_positioned_auto_descendants.
continue;
}
let child_skip = skip_floats && !child.is_positioned();
self.render_layout_box_normal(child, offset, child_skip, phase);
}
}
// Render collapsed borders (on top of all cell backgrounds) — part of backgrounds phase
if is_visible && phase != RenderPhase::InlineContentOnly {
self.render_collapsed_borders(layout_box, offset);
}
// Outline: rendered after all content (CSS 2.1 Appendix E step 10)
if is_visible && phase != RenderPhase::BackgroundsOnly {
self.render_outline(layout_box, offset);
}
if needs_clip {
self.list.push(DisplayItem::PopClip);
}
// Emit scroll indicators for overflow: scroll (always) or auto (when content overflows)
// Only emit during InlineContentOnly or Full phase to avoid double-emitting
if needs_clip && phase != RenderPhase::BackgroundsOnly {
self.emit_scroll_indicators(layout_box, offset);
}
}
/// Recursively collect all float descendants from the normal flow tree.
/// Stops at stacking context boundaries and positioned elements.
fn collect_descendant_floats<'a>(
&self,
layout_box: &'a LayoutBox,
parent_offset: CumulativeOffset,
floats: &mut Vec<(&'a LayoutBox, CumulativeOffset)>,
) {
let base_offset = if layout_box.position == Position::Fixed {
CumulativeOffset::default()
} else {
parent_offset
};
// Don't compute sticky offset here — this is a geometry collection pass.
// Sticky visual offsets are applied during actual rendering.
let offset = base_offset.add(layout_box.render_offset_x, layout_box.render_offset_y);
for child in &layout_box.children {
if child.creates_stacking_context() || child.is_positioned() {
continue;
}
if child.float != Float::None {
floats.push((child, offset));
} else {
// Recurse into normal flow children looking for nested floats
self.collect_descendant_floats(child, offset, floats);
}
}
}
/// Recursively collect positioned-auto descendants from the normal flow tree.
/// These need to be rendered at step 6 of the owning stacking context.
/// Stops at stacking context boundaries (they're handled by collect_stacking_participants).
fn collect_positioned_auto_descendants<'a>(
&self,
layout_box: &'a LayoutBox,
parent_offset: CumulativeOffset,
positioned: &mut Vec<(&'a LayoutBox, CumulativeOffset)>,
) {
let base_offset = if layout_box.position == Position::Fixed {
CumulativeOffset::default()
} else {
parent_offset
};
let offset = base_offset.add(layout_box.render_offset_x, layout_box.render_offset_y);
for child in &layout_box.children {
if child.creates_stacking_context() {
// Stacking context children are handled by collect_stacking_participants
continue;
}
if child.is_positioned() && child.box_type != layout::BoxType::Inline {
// Collect non-inline positioned-auto descendants for step 6.
// Inline positioned elements (e.g., position:relative spans)
// are part of the IFC and should not be hoisted.
// Don't recurse — its own positioned descendants are handled
// within its own stacking context rendering.
positioned.push((child, offset));
} else if child.float != Float::None {
// Floats are handled separately; don't recurse into them
// for positioned descendants (floats establish their own context).
continue;
} else {
self.collect_positioned_auto_descendants(child, offset, positioned);
}
}
}
/// Recursively collect stacking-context descendants from the normal-flow subtree
/// for rendering at the correct CSS 2.1 Appendix E paint-order steps:
/// negative z-index (step 2), z-index:0 (step 6), positive z-index (step 7).
/// Recurses through positioned-auto ancestors (they don't create stacking contexts).
/// Stops at stacking context boundaries and non-SC floats.
fn collect_stacking_participants<'a>(
&self,
layout_box: &'a LayoutBox,
parent_offset: CumulativeOffset,
ancestor_clips: &[Rect],
// Clips that apply to absolutely positioned descendants (only from positioned ancestors)
positioned_ancestor_clips: &[Rect],
buckets: &mut StackingBuckets<'a>,
is_root: bool,
) {
let base_offset = if layout_box.position == Position::Fixed {
CumulativeOffset::default()
} else {
parent_offset
};
// Don't compute sticky offset here — geometry collection pass only.
let offset = base_offset.add(layout_box.render_offset_x, layout_box.render_offset_y);
let is_positioned = layout_box.is_positioned() || is_root;
// Track overflow:hidden and CSS clip from intermediate ancestors.
// CSS 2.1 §11.1.2: clip applies to the element and its descendants.
let mut clips = ancestor_clips.to_vec();
let mut pos_clips = positioned_ancestor_clips.to_vec();
if layout_box.overflow_x != Overflow::Visible || layout_box.overflow_y != Overflow::Visible
{
let clip_rect = if is_root {
// CSS 2.1 §11.1.1: overflow on root applies to the viewport, pinned at origin
Rect::new(0.0, 0.0, self.viewport_width, self.viewport_height)
} else {
offset.apply(&layout_box.dimensions.padding_box())
};
clips.push(clip_rect);
// CSS 2.1 §11.1.1: overflow clipping on a non-positioned element does NOT
// clip absolutely positioned descendants whose containing block is above it.
// Only positioned elements (containing blocks) propagate overflow clips
// to absolutely positioned descendants.
if is_positioned {
pos_clips.push(clip_rect);
}
}
// Propagate CSS clip: rect() from this ancestor to descendant stacking contexts
if let Some(clip) = &layout_box.clip {
let border_box = offset.apply(&layout_box.dimensions.border_box());
let clip_top = clip.top.unwrap_or(0.0);
let clip_left = clip.left.unwrap_or(0.0);
let clip_bottom = clip.bottom.unwrap_or(border_box.height());
let clip_right = clip.right.unwrap_or(border_box.width());
let clip_rect = Rect::new(
border_box.x() + clip_left,
border_box.y() + clip_top,
(clip_right - clip_left).max(0.0),
(clip_bottom - clip_top).max(0.0),
);
clips.push(clip_rect);
if is_positioned {
pos_clips.push(clip_rect);
}
}
for child in &layout_box.children {
// Skip simple floats — their stacking-context descendants stay within
// the float's mini stacking context, not hoisted.
// But floats that *themselves* create a stacking context must be
// collected here (CSS 2.1 Appendix E steps 2/6/7), since they are
// excluded from both `normal_flow_children` and
// `collect_descendant_floats`.
if child.float != Float::None && !child.creates_stacking_context() {
continue;
}
// Inline-level stacking-context elements are handled by the IFC
// fragment system, not hoisted. Recurse into them like normal flow.
let is_inline = child.box_type == layout::BoxType::Inline;
if !is_inline && child.creates_stacking_context() {
let z = child.z_index.unwrap_or(0);
// CSS 2.1 §11.1.1: absolutely positioned children use only clips
// from positioned ancestors (containing blocks).
let child_clips = if child.position == Position::Absolute {
pos_clips.clone()
} else {
clips.clone()
};
let participant = StackingParticipant {
layout_box: child,
parent_offset: offset,
ancestor_clips: child_clips,
};
if z < 0 {
buckets.negative.push(participant);
} else if z == 0 {
// CSS 2.1 Appendix E step 6: z-index:0 stacking contexts
buckets.zero.push(participant);
} else {
// CSS 2.1 Appendix E step 7: positive z-index only
buckets.positive.push(participant);
}
} else if child.is_positioned() {
// Positioned-auto elements (z-index:auto) do NOT create stacking
// contexts, so their descendant stacking contexts still participate
// in the current stacking context. Recurse into them to collect
// any such descendants (CSS 2.1 Appendix E).
self.collect_stacking_participants(
child, offset, &clips, &pos_clips, buckets, false,
);
} else {
// Normal flow, non-positioned, non-float, or inline:
// recurse deeper
self.collect_stacking_participants(
child, offset, &clips, &pos_clips, buckets, false,
);
}
}
}
/// Render a stacking participant with its ancestor clip rects.
fn render_with_ancestor_clips<F>(&mut self, p: &StackingParticipant, render_fn: F)
where
F: FnOnce(&mut Self),
{
for clip in &p.ancestor_clips {
self.list.push(DisplayItem::PushClip { rect: *clip });
}
render_fn(self);
for _ in &p.ancestor_clips {
self.list.push(DisplayItem::PopClip);
}
}
/// Render inline fragments from an inline formatting context
fn render_inline_fragments(
&mut self,
ifc: &layout::InlineFormattingContext,
offset: CumulativeOffset,
) {
for line_box in &ifc.line_boxes {
for fragment in &line_box.fragments {
// Skip atomic inline fragments - they are positioning-only placeholders
if fragment.is_atomic {
continue;
}
// 1. Render inline element background (if present)
if let Some(bg_color) = fragment.background_color {
if bg_color.a > 0 {
let bg_rect = self.compute_fragment_background_rect(fragment);
let bg_rect = offset.apply(&bg_rect);
self.list.push(DisplayItem::SolidRect {
rect: bg_rect,
color: bg_color,
radii: CornerRadii::default(),
});
}
}
// 2. Render inline element borders (if fragment is element start/end)
self.render_fragment_borders(fragment, offset);
// 3. Render text (if present)
if let Some(ref text) = fragment.text {
let text_rect = offset.apply(&fragment.rect);
self.list.push(DisplayItem::Text {
rect: text_rect,
text: text.clone(),
color: fragment.color,
font_size: fragment.font_size,
font_weight: fragment.font_weight,
font_style: fragment.font_style,
font_variant: fragment.font_variant,
font_family: fragment.font_family.clone(),
});
}
// 4. Render text decorations (underline, line-through, overline)
if !fragment.text_decoration_line.is_none() && fragment.text.is_some() {
let text_rect = offset.apply(&fragment.rect);
let thickness = (fragment.font_size / 14.0).max(1.0);
if fragment.text_decoration_line.has_underline() {
let y = text_rect.y() + fragment.baseline_offset + thickness * 2.0;
self.list.push(DisplayItem::SolidRect {
rect: Rect::new(text_rect.x(), y, text_rect.width(), thickness),
color: fragment.color,
radii: CornerRadii::default(),
});
}
if fragment.text_decoration_line.has_line_through() {
let y = text_rect.y() + fragment.baseline_offset - fragment.font_size * 0.3;
self.list.push(DisplayItem::SolidRect {
rect: Rect::new(text_rect.x(), y, text_rect.width(), thickness),
color: fragment.color,
radii: CornerRadii::default(),
});
}
if fragment.text_decoration_line.has_overline() {
let y = text_rect.y() + fragment.baseline_offset - fragment.font_size * 0.8;
self.list.push(DisplayItem::SolidRect {
rect: Rect::new(text_rect.x(), y, text_rect.width(), thickness),
color: fragment.color,
radii: CornerRadii::default(),
});
}
}
}
}
}
/// Compute the background rect for a fragment, including padding
fn compute_fragment_background_rect(&self, fragment: &InlineFragment) -> Rect {
let padding = fragment.padding.unwrap_or(EdgeSizes::ZERO);
let border = fragment.border.unwrap_or(EdgeSizes::ZERO);
let mut x = fragment.rect.x();
let mut width = fragment.rect.width();
// Include left padding/border if this is the start of the element
if fragment.is_element_start {
x -= padding.left + border.left;
width += padding.left + border.left;
}
// Include right padding/border if this is the end of the element
if fragment.is_element_end {
width += padding.right + border.right;
}
Rect::new(
x,
fragment.rect.y() - padding.top - border.top,
width,
fragment.rect.height() + padding.vertical() + border.vertical(),
)
}
/// Render borders for an inline fragment with offset support
fn render_fragment_borders(&mut self, fragment: &InlineFragment, offset: CumulativeOffset) {
let (Some(border), Some(colors), Some(styles)) = (
&fragment.border,
&fragment.border_colors,
&fragment.border_styles,
) else {
return;
};
if !styles.any_visible() {
return;
}
let mut widths = *border;
if !fragment.is_element_start {
widths.left = 0.0;
}
if !fragment.is_element_end {
widths.right = 0.0;
}
if !fragment.is_first_line {
widths.top = 0.0;
}
if !fragment.is_last_line {
widths.bottom = 0.0;
}
if widths.top == 0.0 && widths.right == 0.0 && widths.bottom == 0.0 && widths.left == 0.0 {
return;
}
let border_rect = self.compute_fragment_background_rect(fragment);
let border_rect = offset.apply(&border_rect);
self.list.push(DisplayItem::Border {
rect: border_rect,
widths,
colors: *colors,
styles: *styles,
radii: CornerRadii::default(),
});
}
fn render_box_shadows(
&mut self,
layout_box: &LayoutBox,
offset: CumulativeOffset,
inset: bool,
) {
if layout_box.box_shadows.is_empty() {
return;
}
let border_box = layout_box.dimensions.border_box();
let border_box = offset.apply(&border_box);
// CSS: first shadow in the list paints on top (closest to viewer).
// Display list paints in order (later items on top), so we emit
// last-declared shadow first so it ends up underneath.
for shadow in layout_box.box_shadows.iter().rev() {
if shadow.inset != inset {
continue;
}
self.list.push(DisplayItem::BoxShadow {
border_box,
shadow: *shadow,
});
}
}
/// CSS 2.1 §17.6.1.1: A cell is "empty" if it has no in-flow content —
/// no child boxes (or only anonymous children with whitespace-only text)
/// and no non-whitespace inline content.
fn is_cell_empty(layout_box: &LayoutBox) -> bool {
// Check children: non-anonymous children mean the cell has real content.
// Anonymous children with only whitespace text are treated as empty.
for child in &layout_box.children {
if child.box_type != BoxType::Anonymous {
return false;
}
// Anonymous child: check if it contains only whitespace
if let Some(ref text) = child.text_content {
if !text.trim().is_empty() {
return false;
}
}
// Recursively check anonymous child's inline content
if let Some(ref ifc) = child.inline_context {
for line_box in &ifc.line_boxes {
for fragment in &line_box.fragments {
if let Some(ref text) = fragment.text {
if !text.trim().is_empty() {
return false;
}
} else {
return false;
}
}
}
}
// Anonymous child with non-anonymous grandchildren is non-empty
if !child.children.is_empty() {
return false;
}
}
if let Some(ref ifc) = layout_box.inline_context {
for line_box in &ifc.line_boxes {
for fragment in &line_box.fragments {
if let Some(ref text) = fragment.text {
if !text.trim().is_empty() {
return false;
}
} else {
// Non-text fragments (e.g., replaced elements) mean the cell is not empty
return false;
}
}
}
}
if let Some(ref text) = layout_box.text_content {
if !text.trim().is_empty() {
return false;
}
}
true
}
fn render_background(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
let border_box = layout_box.dimensions.border_box();
let rect = offset.apply(&border_box);
let radii = layout_box.resolved_border_radii();
// Render background color (skip transparent)
if layout_box.background_color.a != 0 {
self.list.push(DisplayItem::SolidRect {
rect,
color: layout_box.background_color,
radii,
});
}
// Render background gradient on top of background color
if let Some(gradient) = &layout_box.background_gradient {
self.list.push(DisplayItem::LinearGradient {
rect,
gradient: gradient.clone(),
radii,
});
}
// Render background image on top of background color (and gradient)
if let Some(bg_image_id) = layout_box.background_image_id {
let padding_box = layout_box.dimensions.padding_box();
let clip_rect = offset.apply(&padding_box);
self.list.push(DisplayItem::BackgroundImage {
clip_rect,
image_id: bg_image_id,
position_x: layout_box.background_position_x,
position_y: layout_box.background_position_y,
repeat: layout_box.background_repeat,
attachment: layout_box.background_attachment,
radii,
});
}
}
fn render_borders(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
let d = &layout_box.dimensions;
// Skip if no borders or no visible styles
if d.border.top == 0.0
&& d.border.right == 0.0
&& d.border.bottom == 0.0
&& d.border.left == 0.0
{
return;
}
if !layout_box.border_styles.any_visible() {
return;
}
let border_box = d.border_box();
let rect = offset.apply(&border_box);
self.list.push(DisplayItem::Border {
rect,
widths: d.border,
colors: layout_box.border_colors,
styles: layout_box.border_styles,
radii: layout_box.resolved_border_radii(),
});
}
/// Render outline outside the border box (CSS 2.1 §18.4).
/// Outline does not affect layout and is painted after borders.
fn render_outline(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
if !layout_box.outline_style.is_visible() || layout_box.outline_width <= 0.0 {
return;
}
let d = &layout_box.dimensions;
let border_box = d.border_box();
// Expand border box outward by outline_offset + outline_width
let expand = layout_box.outline_offset + layout_box.outline_width;
let w = border_box.width() + expand * 2.0;
let h = border_box.height() + expand * 2.0;
if w <= 0.0 || h <= 0.0 {
return;
}
let outline_rect = Rect::new(border_box.x() - expand, border_box.y() - expand, w, h);
let rect = offset.apply(&outline_rect);
self.list.push(DisplayItem::Outline {
rect,
width: layout_box.outline_width,
style: layout_box.outline_style,
color: layout_box.outline_color,
});
}
/// Emit scroll indicator display items for overflow: scroll or auto.
/// CSS 2.1 §11.1: scroll always shows scrollbar, auto only when content overflows.
/// Note: scroll indicators overlap the content/padding area (last 8px). Interactive
/// scrolling (Story 5-5) should account for this by reducing available content width/height.
fn emit_scroll_indicators(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
let padding_box = offset.apply(&layout_box.dimensions.padding_box());
let content_rect = &layout_box.dimensions.content;
// Compute content extent from children.
// Skip absolutely positioned children whose containing block is not this element
// (they escape the overflow container per CSS 2.1 §11.1.1).
let is_containing_block = layout_box.is_positioned();
let mut max_child_right: f32 = 0.0;
let mut max_child_bottom: f32 = 0.0;
for child in &layout_box.children {
if child.position == Position::Absolute && !is_containing_block {
continue;
}
let child_bb = child.dimensions.border_box();
max_child_right =
max_child_right.max(child_bb.x() + child_bb.width() - content_rect.x());
max_child_bottom =
max_child_bottom.max(child_bb.y() + child_bb.height() - content_rect.y());
}
// Also consider inline formatting context dimensions
if let Some(ref ifc) = layout_box.inline_context {
max_child_right = max_child_right.max(ifc.max_line_width);
max_child_bottom = max_child_bottom.max(ifc.total_height);
}
let content_width = content_rect.width();
let content_height = content_rect.height();
let overflows_x = max_child_right > content_width;
let overflows_y = max_child_bottom > content_height;
const SCROLLBAR_SIZE: f32 = 8.0;
const MIN_THUMB: f32 = 16.0;
// Determine which scrollbars are shown
let show_vertical = match layout_box.overflow_y {
Overflow::Scroll => true,
Overflow::Auto => overflows_y,
_ => false,
};
let show_horizontal = match layout_box.overflow_x {
Overflow::Scroll => true,
Overflow::Auto => overflows_x,
_ => false,
};
// Vertical scrollbar (shorten track if horizontal scrollbar also present to avoid overlap)
if show_vertical {
let track_x = padding_box.x() + padding_box.width() - SCROLLBAR_SIZE;
let track_y = padding_box.y();
let track_h = padding_box.height() - if show_horizontal { SCROLLBAR_SIZE } else { 0.0 };
let track_rect = Rect::new(track_x, track_y, SCROLLBAR_SIZE, track_h);
let thumb_h = if overflows_y && max_child_bottom > 0.0 {
(content_height / max_child_bottom * track_h)
.max(MIN_THUMB)
.min(track_h)
} else {
track_h // No overflow → thumb fills track
};
let thumb_rect = Rect::new(track_x, track_y, SCROLLBAR_SIZE, thumb_h);
self.list.push(DisplayItem::ScrollIndicator {
track_rect,
thumb_rect,
vertical: true,
});
}
// Horizontal scrollbar (shorten track if vertical scrollbar also present to avoid overlap)
if show_horizontal {
let track_x = padding_box.x();
let track_y = padding_box.y() + padding_box.height() - SCROLLBAR_SIZE;
let track_w = padding_box.width() - if show_vertical { SCROLLBAR_SIZE } else { 0.0 };
let track_rect = Rect::new(track_x, track_y, track_w, SCROLLBAR_SIZE);
let thumb_w = if overflows_x && max_child_right > 0.0 {
(content_width / max_child_right * track_w)
.max(MIN_THUMB)
.min(track_w)
} else {
track_w
};
let thumb_rect = Rect::new(track_x, track_y, thumb_w, SCROLLBAR_SIZE);
self.list.push(DisplayItem::ScrollIndicator {
track_rect,
thumb_rect,
vertical: false,
});
}
}
fn render_collapsed_borders(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
let Some(ref segments) = layout_box.collapsed_borders else {
return;
};
for seg in segments {
if !seg.style.is_visible() || seg.width == 0.0 {
continue;
}
let rect = offset.apply(&seg.rect);
// Emit a Border display item. For horizontal segments we set only
// the top width; for vertical segments only the left width.
// The rect already has the correct dimensions.
if seg.is_horizontal {
self.list.push(DisplayItem::Border {
rect,
widths: EdgeSizes::new(seg.width, 0.0, 0.0, 0.0),
colors: EdgeColors::new(seg.color, seg.color, seg.color, seg.color),
styles: EdgeStyles::new(
seg.style,
BorderStyle::None,
BorderStyle::None,
BorderStyle::None,
),
radii: CornerRadii::default(),
});
} else {
self.list.push(DisplayItem::Border {
rect,
widths: EdgeSizes::new(0.0, 0.0, 0.0, seg.width),
colors: EdgeColors::new(seg.color, seg.color, seg.color, seg.color),
styles: EdgeStyles::new(
BorderStyle::None,
BorderStyle::None,
BorderStyle::None,
seg.style,
),
radii: CornerRadii::default(),
});
}
}
}
fn render_list_marker(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
if let Some(ref marker) = layout_box.list_marker {
let content = &layout_box.dimensions.content;
let marker_width = layout_box.list_marker_width;
let (x, y) = match layout_box.list_style_position {
ListStylePosition::Outside => {
let gap = 4.0;
(content.x() - marker_width - gap, content.y())
}
ListStylePosition::Inside => {
// Inside: marker is at the start of the content area
(content.x(), content.y())
}
};
let rect = Rect::new(x, y, marker_width, layout_box.font_size);
let rect = offset.apply(&rect);
self.list.push(DisplayItem::Text {
rect,
text: marker.clone(),
color: layout_box.color,
font_size: layout_box.font_size,
font_weight: layout_box.font_weight,
font_style: layout_box.font_style,
font_variant: layout_box.font_variant,
font_family: layout_box.font_family.clone(),
});
}
}
fn render_image(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
if let Some(image_id) = layout_box.image_id {
let content_rect = layout_box.dimensions.content;
let rect = offset.apply(&content_rect);
self.list.push(DisplayItem::Image { rect, image_id });
}
if let Some(ref iframe) = layout_box.iframe_content {
let content_rect = layout_box.dimensions.content;
let rect = offset.apply(&content_rect);
self.list.push(DisplayItem::IframeContent {
rect,
width: iframe.width,
height: iframe.height,
pixels: iframe.pixels.clone(),
});
}
}
/// Render block-level and inline-block descendants within an IFC,
/// traversing through inline wrappers (e.g., `<a>` containing `<img>`)
/// without re-rendering their backgrounds/borders (already handled by
/// the IFC fragment system).
fn render_non_inline_descendants(
&mut self,
layout_box: &LayoutBox,
offset: CumulativeOffset,
skip_floats: bool,
phase: RenderPhase,
) {
for child in &layout_box.children {
// Skip non-inline stacking-context children — they have been hoisted
// to the owning stacking context's paint-order steps 2/7.
if skip_floats
&& child.box_type != layout::BoxType::Inline
&& child.creates_stacking_context()
{
continue;
}
// Non-inline positioned-auto elements: skip during phased passes
// (they'll be collected by collect_positioned_auto_descendants for step 6).
// In Full phase, render via render_stacking_context for Appendix E ordering.
// Inline positioned elements (e.g., position:relative spans) stay in IFC flow.
if child.is_positioned()
&& !child.creates_stacking_context()
&& child.box_type != layout::BoxType::Inline
{
if phase == RenderPhase::Full {
self.render_stacking_context(child, offset, false);
}
continue;
}
match child.box_type {
layout::BoxType::Block
| layout::BoxType::Flex
| layout::BoxType::Grid
| layout::BoxType::Table
| layout::BoxType::TableRowGroup
| layout::BoxType::TableRow
| layout::BoxType::TableCell
| layout::BoxType::TableCaption
| layout::BoxType::InlineBlock => {
// Don't propagate skip_floats into positioned elements
let child_skip = skip_floats && !child.is_positioned();
self.render_layout_box_normal(child, offset, child_skip, phase);
}
layout::BoxType::Inline => {
// Absolutely/fixed positioned inline children
// (e.g. <span style="position:absolute">) get their own
// formatting context from deferred abs-pos layout.
// Render them fully so their IFC content appears.
// Skip those that create stacking contexts (e.g. z-index set)
// — they are already handled by collect_stacking_participants.
if (child.position == Position::Absolute || child.position == Position::Fixed)
&& !child.creates_stacking_context()
{
self.render_layout_box_normal(child, offset, false, phase);
} else {
self.render_non_inline_descendants(child, offset, skip_floats, phase);
}
}
_ => {}
}
}
}
fn render_text(&mut self, layout_box: &LayoutBox, offset: CumulativeOffset) {
if let Some(ref text) = layout_box.text_content {
let content_rect = layout_box.dimensions.content;
let rect = offset.apply(&content_rect);
self.list.push(DisplayItem::Text {
rect,
text: text.clone(),
color: layout_box.color,
font_size: layout_box.font_size,
font_weight: layout_box.font_weight,
font_style: layout_box.font_style,
font_variant: layout_box.font_variant,
font_family: layout_box.font_family.clone(),
});
}
}
/// Compute the sticky positioning offset for a layout box.
/// `base_offset` is the parent's accumulated offset (includes scroll and ancestor offsets).
///
/// Per CSS Position 3 §A.2: when both `top` and `bottom` are specified, `top` wins
/// for the offset but the result is still clamped by the bottom constraint.
pub(crate) fn compute_sticky_offset(
&self,
layout_box: &LayoutBox,
base_offset: CumulativeOffset,
) -> (f32, f32) {
let Some(ref sc) = layout_box.sticky_constraints else {
return (0.0, 0.0);
};
let margin_box_height = sc.normal_flow_bottom - sc.normal_flow_y;
let mut offset_y = 0.0;
// Top sticky: push element down so its top stays at the threshold
if let Some(top) = sc.top_threshold {
let viewport_y = sc.normal_flow_y + base_offset.y;
if viewport_y < top {
offset_y = top - viewport_y;
}
}
// Bottom sticky: push element up so its bottom stays at the threshold.
// When both top and bottom are set, top wins for the offset value,
// but bottom still clamps (applied below).
if let Some(bottom) = sc.bottom_threshold {
let elem_bottom_viewport = sc.normal_flow_y + margin_box_height + base_offset.y;
let stick_point = self.viewport_height - bottom;
if sc.top_threshold.is_none() && elem_bottom_viewport > stick_point {
offset_y = stick_point - elem_bottom_viewport;
}
}
// Clamp: for top-sticky, never above normal position (offset >= 0)
if sc.top_threshold.is_some() {
offset_y = offset_y.max(0.0);
}
// Clamp: can't push element below containing block bottom
if offset_y > 0.0 {
let max_offset = sc.containing_block_bottom - sc.normal_flow_bottom;
offset_y = offset_y.min(max_offset.max(0.0));
}
// When both top and bottom are set, clamp top-sticky offset so the
// element's bottom doesn't violate the bottom threshold.
if let (Some(_top), Some(bottom)) = (sc.top_threshold, sc.bottom_threshold) {
let elem_bottom_viewport =
sc.normal_flow_y + margin_box_height + base_offset.y + offset_y;
let stick_point = self.viewport_height - bottom;
if elem_bottom_viewport > stick_point {
let excess = elem_bottom_viewport - stick_point;
offset_y = (offset_y - excess).max(0.0);
}
}
(0.0, offset_y)
}
/// Apply render offset (for position: relative) to a rect - used by tests
pub fn apply_render_offset(&self, rect: &Rect, layout_box: &LayoutBox) -> Rect {
if layout_box.render_offset_x == 0.0 && layout_box.render_offset_y == 0.0 {
*rect
} else {
Rect::new(
rect.x() + layout_box.render_offset_x,
rect.y() + layout_box.render_offset_y,
rect.width(),
rect.height(),
)
}
}
}
// =============================================================================
// Convenience functions
// =============================================================================
pub fn build_display_list(tree: &LayoutTree) -> DisplayList {
DisplayListBuilder::new().build(tree)
}
/// Build a display list with a vertical scroll offset applied.
///
/// This is more efficient than rebuilding the layout tree when only the scroll
/// position changes - we skip the HTML/CSS parsing and layout phases.
pub fn build_display_list_with_scroll(tree: &LayoutTree, scroll_y: f32) -> DisplayList {
DisplayListBuilder::with_scroll_offset(scroll_y).build(tree)
}