//! Display list construction and the Metal GPU compositor. //! //! Walks a layout tree, generates paint commands ([`PaintCommand`]), and feeds //! them to the Metal compositor ([`GpuRenderer`]) — the single rendering //! backend. On-screen it targets a drawable; headless callers (the e2e harness, //! iframe snapshots) use [`GpuRenderer::render_to_pixels`] to render offscreen //! and read the result back as a BGRA buffer, so tests exercise the exact //! production renderer. pub mod atlas; pub mod gpu; pub mod layer; pub use gpu::{GpuRenderer, RenderFonts}; use std::collections::HashMap; use we_css::values::{Color, LinearGradient, RadialGradient}; use we_dom::NodeId; use we_layout::{ BoxType, FormControlInfo, FormControlType, LayoutBox, LayoutTree, Rect, SelectOption, TextLine, SCROLLBAR_WIDTH, }; use we_style::computed::{ Appearance, BackgroundRepeat, BackgroundSize, BorderStyle, BoxShadow, Display, LengthOrAuto, ObjectFit, Overflow, Position, TextDecoration, Visibility, WillChange, }; /// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. pub type ScrollState = HashMap; /// Scroll bar track color (light gray). const SCROLLBAR_TRACK_COLOR: Color = Color { r: 230, g: 230, b: 230, a: 255, }; /// Scroll bar thumb color (darker gray). const SCROLLBAR_THUMB_COLOR: Color = Color { r: 160, g: 160, b: 160, a: 255, }; /// A paint command in the display list. #[derive(Debug)] pub enum PaintCommand { /// Fill a rectangle with a solid color. FillRect { x: f32, y: f32, width: f32, height: f32, color: Color, }, /// Fill or stroke a rounded rectangle with per-corner radii (top-left, /// top-right, bottom-right, bottom-left), evaluated analytically by the /// renderer as a signed-distance field for resolution-independent, /// antialiased corners. `stroke_width == 0.0` fills the interior; a /// positive value strokes a ring of that width inward from the outer edge. RoundedRect { x: f32, y: f32, width: f32, height: f32, radii: [f32; 4], stroke_width: f32, color: Color, }, /// Draw an outer blurred rounded-rectangle shadow. The geometry describes /// the shadow-casting box after spread has been applied; the renderer /// expands the draw quad by `blur_radius` and shades the soft alpha falloff. BoxShadow { x: f32, y: f32, width: f32, height: f32, radii: [f32; 4], blur_radius: f32, color: Color, }, /// Fill a rectangle with a CSS linear gradient. LinearGradient { x: f32, y: f32, width: f32, height: f32, gradient: LinearGradient, }, /// Fill a rectangle with a CSS radial gradient. RadialGradient { x: f32, y: f32, width: f32, height: f32, position_x: LengthOrAuto, position_y: LengthOrAuto, repeat: BackgroundRepeat, gradient: RadialGradient, }, /// Draw a text fragment at a position with styling. DrawGlyphs { line: TextLine, font_size: f32, color: Color, }, /// Draw an image at a position with given display dimensions. DrawImage { x: f32, y: f32, width: f32, height: f32, object_fit: ObjectFit, node_id: NodeId, }, /// Draw a CSS background image inside an element background painting area. DrawBackgroundImage { x: f32, y: f32, width: f32, height: f32, node_id: NodeId, size: BackgroundSize, position_x: LengthOrAuto, position_y: LengthOrAuto, repeat: BackgroundRepeat, }, /// Push a clip rectangle onto the clip stack. All subsequent paint /// commands are clipped to the intersection of all active clip rects. /// /// `radii` are the per-corner radii (top-left, top-right, bottom-right, /// bottom-left) of the clip's rounded corners, in the same logical units as /// the rect. All-zero radii are a plain rectangular clip; non-zero radii /// additionally round the corners off (the renderer keeps the rectangular /// scissor for the straight edges and applies an analytic SDF for the /// corners). PushClip { x: f32, y: f32, width: f32, height: f32, radii: [f32; 4], }, /// Pop the most recent clip rectangle off the clip stack. PopClip, /// Begin a compositing layer. Content within the layer is rendered to an /// intermediate surface and then composited with the given opacity. PushLayer { opacity: f32, x: f32, y: f32, width: f32, height: f32, }, /// End the current compositing layer and composite it onto the parent. PopLayer, } /// A flat list of paint commands in painter's order. pub type DisplayList = Vec; /// Pending dropdown overlay collected during the main paint pass. struct PendingDropdown { /// Screen position of the select control. x: f32, y: f32, width: f32, height: f32, font_size: f32, options: Vec, highlighted_index: Option, } /// Build a display list from a layout tree. /// /// Walks the tree in depth-first pre-order (painter's order): /// backgrounds first, then borders, then text on top. pub fn build_display_list(tree: &LayoutTree) -> DisplayList { build_display_list_with_scroll(tree, &HashMap::new()) } /// Build a display list from a layout tree with scroll state. /// /// `page_scroll` is the viewport-level scroll offset (x, y) applied to all content. /// `scroll_state` maps element NodeIds to their per-element scroll offsets. pub fn build_display_list_with_scroll( tree: &LayoutTree, scroll_state: &ScrollState, ) -> DisplayList { let mut list = DisplayList::new(); let mut dropdowns = Vec::new(); paint_box( &tree.root, &mut list, (0.0, 0.0), scroll_state, 0.0, &mut dropdowns, ); for dd in &dropdowns { paint_dropdown_overlay(dd, &mut list); } list } /// Build a display list with page-level scrolling. /// /// `page_scroll_y` shifts all content vertically (viewport-level scroll). pub fn build_display_list_with_page_scroll( tree: &LayoutTree, page_scroll_y: f32, scroll_state: &ScrollState, ) -> DisplayList { let mut list = DisplayList::new(); let mut dropdowns = Vec::new(); paint_box( &tree.root, &mut list, (0.0, -page_scroll_y), scroll_state, 0.0, &mut dropdowns, ); for dd in &dropdowns { paint_dropdown_overlay(dd, &mut list); } list } /// Build a display list into an existing buffer (clear + refill). /// /// Reuses the buffer's allocated capacity, avoiding repeated allocation /// when the display list size is stable across frames. pub fn build_display_list_into( tree: &LayoutTree, page_scroll_y: f32, scroll_state: &ScrollState, list: &mut DisplayList, ) { list.clear(); let mut dropdowns = Vec::new(); paint_box( &tree.root, list, (0.0, -page_scroll_y), scroll_state, 0.0, &mut dropdowns, ); for dd in &dropdowns { paint_dropdown_overlay(dd, list); } } /// The background color propagated to the viewport canvas. /// /// CSS background propagation (CSS2 §14.2): for an HTML document the root /// (``) element's background paints the whole viewport canvas; if the /// root background is transparent, the `` element's background is used /// instead. Real browsers therefore paint the entire viewport — including the /// area outside a centered or narrower body box, and any area below a short /// document — in that color, rather than leaving it the default white. /// /// Returns `None` when neither the root nor the body has a non-transparent /// background, in which case callers keep their default canvas color. pub fn canvas_background_color(tree: &LayoutTree) -> Option { let root = &tree.root; if root.background_color.a > 0 { return Some(root.background_color); } // Root background is transparent: fall back to the body element's. The // body is the first element-level child of the root (`` produces no // box, so the first child box that comes from an element is ``). let body = root .children .iter() .find(|c| matches!(c.box_type, BoxType::Block(_) | BoxType::Inline(_)))?; if body.background_color.a > 0 { Some(body.background_color) } else { None } } /// Compute the dropdown menu geometry for an open single-select. /// /// Given the select control's screen-space border box and its options, /// returns `(menu_x, menu_y, menu_width, menu_height, line_height)`. pub fn dropdown_menu_geometry( select_x: f32, select_y: f32, select_w: f32, select_h: f32, font_size: f32, option_count: usize, ) -> (f32, f32, f32, f32, f32) { let line_height = font_size * 1.2; let padding = 4.0f32; let border = 1.0f32; let menu_height = option_count as f32 * line_height + padding * 2.0 + border * 2.0; let menu_width = select_w.max(100.0); let menu_x = select_x; let menu_y = select_y + select_h; (menu_x, menu_y, menu_width, menu_height, line_height) } /// Returns `true` if a box is positioned (absolute or fixed). fn is_positioned(b: &LayoutBox) -> bool { b.position == Position::Absolute || b.position == Position::Fixed } /// Returns `true` if a layout box requires its own compositing layer. fn needs_compositing_layer(b: &LayoutBox) -> bool { // opacity < 1.0 requires group compositing to render correctly b.opacity < 1.0 // will-change hints signal the compositor to promote to a layer || b.will_change != WillChange::default() // fixed-position elements benefit from their own layer || b.position == Position::Fixed // scrollable containers with overflow render content to a layer texture || ((b.overflow == Overflow::Scroll || b.overflow == Overflow::Auto) && b.content_height > b.rect.height) } fn paint_box( layout_box: &LayoutBox, list: &mut DisplayList, translate: (f32, f32), scroll_state: &ScrollState, sticky_ref_screen_y: f32, dropdowns: &mut Vec, ) { if layout_box.transform.paints_as_zero_scale() { return; } let visible = layout_box.visibility == Visibility::Visible; let transform_ref = border_box(layout_box); let (transform_tx, transform_ty) = layout_box .transform .paint_offset(transform_ref.width, transform_ref.height); let tx = translate.0 + transform_tx; let ty = translate.1 + transform_ty; // If this box needs a compositing layer, wrap all its paint commands. let layer = needs_compositing_layer(layout_box); if layer { let bb = border_box(layout_box); list.push(PaintCommand::PushLayer { opacity: layout_box.opacity, x: bb.x + tx, y: bb.y + ty, width: bb.width, height: bb.height, }); } if visible { if let Some(ref fc) = layout_box.form_control { // Form controls paint their own background, borders, and content. paint_box_shadows(layout_box, list, tx, ty); paint_form_control(layout_box, fc, list, tx, ty); // Collect open dropdown overlays for painting after the main pass. if fc.control_type == FormControlType::Select && fc.dropdown_open { let bb = border_box(layout_box); dropdowns.push(PendingDropdown { x: bb.x + tx, y: bb.y + ty, width: bb.width, height: bb.height, font_size: layout_box.font_size, options: fc.options.clone(), highlighted_index: fc.highlighted_index, }); } } else { // Non-replaced inline boxes (``, ``) now carry a real // bounding rect so hit-testing can resolve inline links, but their // backgrounds and text-decoration are painted per text fragment in // `paint_text`. Painting a box-level background/border here would // double-paint (and fill the rectangular hull of a wrapped inline), // so skip it for them — matching the prior behaviour when these // boxes had a zero rect. let inline_non_replaced = matches!( layout_box.box_type, BoxType::Inline(_) | BoxType::TextRun { .. } ) && layout_box.replaced_size.is_none() && layout_box.display != Display::InlineBlock; if !inline_non_replaced { paint_box_shadows(layout_box, list, tx, ty); paint_background(layout_box, list, tx, ty); paint_borders(layout_box, list, tx, ty); } // Emit image paint command for replaced elements using their used // content-box dimensions. Intrinsic dimensions still matter for // layout and for object-fit cropping, but CSS width/height set the // painted destination box. if layout_box.replaced_size.is_some() || layout_box.replaced_ratio.is_some() { if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { list.push(PaintCommand::DrawImage { x: layout_box.rect.x + tx, y: layout_box.rect.y + ty, width: layout_box.rect.width, height: layout_box.rect.height, object_fit: layout_box.object_fit, node_id, }); } } } } // Determine if this box is scrollable. let scrollable = layout_box.overflow == Overflow::Scroll || layout_box.overflow == Overflow::Auto; // If this box has overflow clipping, push a clip rect for the padding box. // The clip applies to the box's own inline content (text, list markers) and // to its descendants — backgrounds, borders, and replaced content above // already painted outside the clip per CSS. let clips = layout_box.overflow != Overflow::Visible; if clips { let clip = padding_box(layout_box); let radii = padding_box_radii(layout_box, clip.width, clip.height); list.push(PaintCommand::PushClip { x: clip.x + tx, y: clip.y + ty, width: clip.width, height: clip.height, radii, }); } if visible && layout_box.form_control.is_none() { paint_text(layout_box, list, tx, ty); paint_list_marker(layout_box, list, tx, ty); } // Compute child translate: adds scroll offset for scrollable boxes. let mut child_translate = (tx, ty); // When entering a scroll container, update the sticky reference point // to the container's padding box top in screen coordinates (pre-scroll). let mut child_sticky_ref = sticky_ref_screen_y; if scrollable { // The scroll container's padding box top on screen (before scroll). child_sticky_ref = (layout_box.rect.y - layout_box.padding.top) + ty; if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { if let Some(&(sx, sy)) = scroll_state.get(&node_id) { child_translate.0 -= sx; child_translate.1 -= sy; } } } // Check if any children are positioned (absolute/fixed). let has_positioned = layout_box.children.iter().any(is_positioned); if has_positioned { // CSS stacking context ordering: // 1. Positioned children with negative z-index // 2. In-flow (non-positioned) children in tree order // 3. Positioned children with z-index >= 0 (or auto) in z-index order // Collect positioned children indices, partitioned by z-index sign. let mut negative_z: Vec = Vec::new(); let mut non_negative_z: Vec = Vec::new(); for (i, child) in layout_box.children.iter().enumerate() { if is_positioned(child) { let z = child.z_index.unwrap_or(0); if z < 0 { negative_z.push(i); } else { non_negative_z.push(i); } } } // Sort by z-index (stable sort preserves tree order for equal z-index). negative_z.sort_by_key(|&i| layout_box.children[i].z_index.unwrap_or(0)); non_negative_z.sort_by_key(|&i| layout_box.children[i].z_index.unwrap_or(0)); // Paint negative z-index positioned children. for &i in &negative_z { paint_child( &layout_box.children[i], list, child_translate, scroll_state, child_sticky_ref, dropdowns, ); } // Paint in-flow children in tree order. for child in &layout_box.children { if !is_positioned(child) { paint_child( child, list, child_translate, scroll_state, child_sticky_ref, dropdowns, ); } } // Paint non-negative z-index positioned children. for &i in &non_negative_z { paint_child( &layout_box.children[i], list, child_translate, scroll_state, child_sticky_ref, dropdowns, ); } } else { // No positioned children — paint all in tree order. for child in &layout_box.children { paint_child( child, list, child_translate, scroll_state, child_sticky_ref, dropdowns, ); } } if clips { list.push(PaintCommand::PopClip); } // Paint scroll bars after PopClip so they're not clipped by the container. if scrollable && visible { paint_scrollbars(layout_box, list, tx, ty, scroll_state); } if layer { list.push(PaintCommand::PopLayer); } } /// Paint a child box, applying sticky positioning offset when needed. fn paint_child( child: &LayoutBox, list: &mut DisplayList, child_translate: (f32, f32), scroll_state: &ScrollState, sticky_ref_screen_y: f32, dropdowns: &mut Vec, ) { if child.position == Position::Sticky { let adjusted = compute_sticky_translate(child, child_translate, sticky_ref_screen_y); paint_box( child, list, adjusted, scroll_state, sticky_ref_screen_y, dropdowns, ); } else { paint_box( child, list, child_translate, scroll_state, sticky_ref_screen_y, dropdowns, ); } } /// Resolve a `LengthOrAuto` to an optional pixel value for sticky offsets. fn resolve_sticky_px(value: LengthOrAuto, reference: f32) -> Option { match value { LengthOrAuto::Length(v) => Some(v), LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), LengthOrAuto::Calc(px, pct) => Some(px + pct / 100.0 * reference), LengthOrAuto::ClampCalc { min_px, px_offset, pct, max_px, } => Some( (px_offset + pct / 100.0 * reference) .max(min_px) .min(max_px), ), LengthOrAuto::FitContent | LengthOrAuto::Auto => None, } } /// Compute the adjusted translate for a `position: sticky` element. /// /// The element is clamped so that its margin box stays within its /// `sticky_constraint` rectangle while honouring the CSS offset thresholds /// (`top`, `bottom`, `left`, `right`). fn compute_sticky_translate( child: &LayoutBox, child_translate: (f32, f32), sticky_ref_screen_y: f32, ) -> (f32, f32) { let constraint = match child.sticky_constraint { Some(c) => c, None => return child_translate, }; let [css_top, _css_right, css_bottom, _css_left] = child.css_offsets; let mut delta_y = 0.0f32; let margin_top_doc = child.rect.y - child.padding.top - child.border.top - child.margin.top; let margin_bottom_doc = child.rect.y + child.rect.height + child.padding.bottom + child.border.bottom + child.margin.bottom; let margin_top_screen = margin_top_doc + child_translate.1; let margin_bottom_screen = margin_bottom_doc + child_translate.1; let constraint_top_screen = constraint.y + child_translate.1; let constraint_bottom_screen = (constraint.y + constraint.height) + child_translate.1; // Handle `top` stickiness: push the element down so its margin box top // is at least `sticky_ref_screen_y + top`. if let Some(top) = resolve_sticky_px(css_top, constraint.height) { let target = sticky_ref_screen_y + top; let raw_delta = (target - margin_top_screen).max(0.0); // Clamp so margin box bottom does not exceed constraint bottom. let max_delta = (constraint_bottom_screen - margin_bottom_screen).max(0.0); delta_y = raw_delta.min(max_delta); } // Handle `bottom` stickiness: pull the element up so its margin box // bottom does not go below `sticky_ref_screen_y + visible_height - bottom`. // Without a reliable visible-height at this point we approximate by clamping // against the constraint top. if let Some(bottom) = resolve_sticky_px(css_bottom, constraint.height) { // Bottom stickiness: the element should not scroll below the // visible area minus the bottom offset. The visible bottom is // approximated as constraint_bottom_screen. let target_bottom = constraint_bottom_screen - bottom; let raw = (margin_bottom_screen + delta_y - target_bottom).max(0.0); // Clamp so margin top does not go above constraint top. let max_up = (margin_top_screen + delta_y - constraint_top_screen).max(0.0); delta_y -= raw.min(max_up); } (child_translate.0, child_translate.1 + delta_y) } /// Compute the padding box rectangle for a layout box. /// The padding box is the content area expanded by padding. fn padding_box(layout_box: &LayoutBox) -> Rect { Rect { x: layout_box.rect.x - layout_box.padding.left, y: layout_box.rect.y - layout_box.padding.top, width: layout_box.rect.width + layout_box.padding.left + layout_box.padding.right, height: layout_box.rect.height + layout_box.padding.top + layout_box.padding.bottom, } } /// Compute the border box of a layout box (padding box + border widths). fn border_box(layout_box: &LayoutBox) -> Rect { let pb = padding_box(layout_box); Rect { x: pb.x - layout_box.border.left, y: pb.y - layout_box.border.top, width: pb.width + layout_box.border.left + layout_box.border.right, height: pb.height + layout_box.border.top + layout_box.border.bottom, } } /// Extract the NodeId from a BoxType, if it has one. pub(crate) fn node_id_from_box_type(box_type: &BoxType) -> Option { match box_type { BoxType::Block(id) | BoxType::Inline(id) => Some(*id), BoxType::TextRun { node, .. } => Some(*node), BoxType::Anonymous => None, } } /// Whether `layout_box` has any non-zero corner radius. fn has_border_radius(layout_box: &LayoutBox) -> bool { layout_box.border_radii.iter().any(|&r| r > 0.0) } /// If the box has a uniform border (all four sides the same non-zero width, /// the same color, and a solid style), return that `(width, color)`. Rounded /// borders are only painted as a clean ring in this uniform case; non-uniform /// rounded borders fall back to the square per-side painter. fn uniform_solid_border(layout_box: &LayoutBox) -> Option<(f32, Color)> { let b = &layout_box.border; let w = b.top; if w <= 0.0 || b.right != w || b.bottom != w || b.left != w { return None; } let styles = &layout_box.border_styles; if styles.iter().any(|s| *s != BorderStyle::Solid) { return None; } let colors = &layout_box.border_colors; if colors.iter().any(|c| *c != colors[0]) { return None; } Some((w, colors[0])) } /// Compute the per-corner radii of a box's padding box for overflow clipping. /// /// The border box carries `border_radii`; the padding box (the overflow clip /// rect) sits inside the border, so each corner radius shrinks by the adjacent /// border widths. Per CSS, the inner radius of a corner is the outer radius /// minus the border width on that side, floored at zero. Because the renderer /// uses a single (circular) radius per corner, we subtract the larger of the /// two borders meeting at the corner so content never bleeds past the curve. /// The result is clamped so opposing corners cannot overlap the padding box. fn padding_box_radii(layout_box: &LayoutBox, pad_w: f32, pad_h: f32) -> [f32; 4] { if !has_border_radius(layout_box) { return [0.0; 4]; } let r = layout_box.border_radii; let b = &layout_box.border; let inner = [ (r[0] - b.left.max(b.top)).max(0.0), // top-left (r[1] - b.right.max(b.top)).max(0.0), // top-right (r[2] - b.right.max(b.bottom)).max(0.0), // bottom-right (r[3] - b.left.max(b.bottom)).max(0.0), // bottom-left ]; clamp_radii(pad_w, pad_h, inner) } /// Clamp the four corner radii so opposing corners never overlap the box. fn clamp_radii(w: f32, h: f32, radii: [f32; 4]) -> [f32; 4] { let max_r = (w / 2.0).min(h / 2.0).max(0.0); [ radii[0].clamp(0.0, max_r), radii[1].clamp(0.0, max_r), radii[2].clamp(0.0, max_r), radii[3].clamp(0.0, max_r), ] } /// Fill a rounded rectangle with per-corner radii (top-left, top-right, /// bottom-right, bottom-left). Emitted as a [`PaintCommand::RoundedRect`] that /// the renderer evaluates as an analytic signed-distance field, giving /// mathematically perfect, resolution-independent antialiased corners. All-zero /// radii collapse to a plain `FillRect`. fn fill_rounded_rect( list: &mut DisplayList, x: f32, y: f32, w: f32, h: f32, radii: [f32; 4], color: Color, ) { if w <= 0.0 || h <= 0.0 || color.a == 0 { return; } let radii = clamp_radii(w, h, radii); if radii.iter().all(|&r| r <= 0.0) { list.push(PaintCommand::FillRect { x, y, width: w, height: h, color, }); return; } list.push(PaintCommand::RoundedRect { x, y, width: w, height: h, radii, stroke_width: 0.0, color, }); } /// Stroke a rounded-rectangle outline of the given `thickness` (drawn inward /// from the outer edge). Emitted as a [`PaintCommand::RoundedRect`] with a /// non-zero stroke width; the renderer evaluates the analytic SDF annulus. Used /// for rounded borders and focus rings without needing the backdrop color. #[allow(clippy::too_many_arguments)] fn stroke_rounded_rect( list: &mut DisplayList, x: f32, y: f32, w: f32, h: f32, radii: [f32; 4], thickness: f32, color: Color, ) { if w <= 0.0 || h <= 0.0 || thickness <= 0.0 || color.a == 0 { return; } let radii = clamp_radii(w, h, radii); let t = thickness.min(w / 2.0).min(h / 2.0); list.push(PaintCommand::RoundedRect { x, y, width: w, height: h, radii, stroke_width: t, color, }); } fn paint_box_shadows(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { if layout_box.box_shadows.is_empty() { return; } let bb = border_box(layout_box); if bb.width <= 0.0 || bb.height <= 0.0 { return; } for shadow in &layout_box.box_shadows { paint_box_shadow(layout_box, shadow, bb, list, (tx, ty)); } } fn paint_box_shadow( layout_box: &LayoutBox, shadow: &BoxShadow, border_box: Rect, list: &mut DisplayList, translate: (f32, f32), ) { if shadow.color.a == 0 { return; } let blur = shadow.blur_radius.max(0.0); if blur <= 0.5 { paint_shadow_layer(layout_box, shadow, border_box, list, translate, 0.0, 1.0); return; } paint_blurred_shadow_layer(layout_box, shadow, border_box, list, translate, blur); } fn paint_blurred_shadow_layer( layout_box: &LayoutBox, shadow: &BoxShadow, border_box: Rect, list: &mut DisplayList, translate: (f32, f32), blur: f32, ) { let spread = shadow.spread_radius; let x = border_box.x + translate.0 + shadow.offset_x - spread; let y = border_box.y + translate.1 + shadow.offset_y - spread; let w = border_box.width + spread * 2.0; let h = border_box.height + spread * 2.0; if w <= 0.0 || h <= 0.0 { return; } let radii = [ (layout_box.border_radii[0] + spread).max(0.0), (layout_box.border_radii[1] + spread).max(0.0), (layout_box.border_radii[2] + spread).max(0.0), (layout_box.border_radii[3] + spread).max(0.0), ]; list.push(PaintCommand::BoxShadow { x, y, width: w, height: h, radii, blur_radius: blur, color: shadow.color, }); } fn paint_shadow_layer( layout_box: &LayoutBox, shadow: &BoxShadow, border_box: Rect, list: &mut DisplayList, translate: (f32, f32), expansion: f32, alpha_factor: f32, ) { let outset = shadow.spread_radius + expansion; let x = border_box.x + translate.0 + shadow.offset_x - outset; let y = border_box.y + translate.1 + shadow.offset_y - outset; let w = border_box.width + outset * 2.0; let h = border_box.height + outset * 2.0; if w <= 0.0 || h <= 0.0 { return; } let mut color = shadow.color; color.a = ((color.a as f32 * alpha_factor).round()).clamp(0.0, 255.0) as u8; if color.a == 0 { return; } let radii = [ layout_box.border_radii[0] + outset, layout_box.border_radii[1] + outset, layout_box.border_radii[2] + outset, layout_box.border_radii[3] + outset, ]; fill_rounded_rect(list, x, y, w, h, radii, color); } fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bg = layout_box.background_color; // CSS default `background-clip: border-box`: the background fills the // border box. Borders paint on top, so for solid borders the result is // identical to padding-box clipping, but transparent/gapped border // styles (dotted, dashed) show the background behind them. let bb = border_box(layout_box); if bb.width <= 0.0 || bb.height <= 0.0 { return; } if has_border_radius(layout_box) { let radii = layout_box.border_radii; // Rounded background fills the border box; a uniform solid border is // then stroked on top as a rounded ring (`paint_borders` skips it). if bg.a > 0 { fill_rounded_rect(list, bb.x + tx, bb.y + ty, bb.width, bb.height, radii, bg); } if let Some((bw, bc)) = uniform_solid_border(layout_box) { stroke_rounded_rect( list, bb.x + tx, bb.y + ty, bb.width, bb.height, radii, bw, bc, ); } } else if bg.a > 0 { list.push(PaintCommand::FillRect { x: bb.x + tx, y: bb.y + ty, width: bb.width, height: bb.height, color: bg, }); } if let Some(gradient) = &layout_box.background_gradient { list.push(PaintCommand::LinearGradient { x: bb.x + tx, y: bb.y + ty, width: bb.width, height: bb.height, gradient: gradient.clone(), }); } for index in (0..layout_box.background_radial_gradients.len()).rev() { let gradient = &layout_box.background_radial_gradients[index]; list.push(PaintCommand::RadialGradient { x: bb.x + tx, y: bb.y + ty, width: bb.width, height: bb.height, position_x: background_layer_position( &layout_box.background_position_x_layers, index, layout_box.background_position_x, ), position_y: background_layer_position( &layout_box.background_position_y_layers, index, layout_box.background_position_y, ), repeat: layout_box.background_repeat, gradient: gradient.clone(), }); } if layout_box.background_image.is_some() { if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { list.push(PaintCommand::DrawBackgroundImage { x: bb.x + tx, y: bb.y + ty, width: bb.width, height: bb.height, node_id, size: layout_box.background_size, position_x: layout_box.background_position_x, position_y: layout_box.background_position_y, repeat: layout_box.background_repeat, }); } } } fn background_layer_position( layers: &[LengthOrAuto], index: usize, fallback: LengthOrAuto, ) -> LengthOrAuto { if layers.is_empty() { return fallback; } layers[index % layers.len()] } fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { // A uniform solid border on a rounded box is painted as a ring by // `paint_background`, so skip the square per-side painter here. Non-uniform // rounded borders fall through to the square approximation below. if has_border_radius(layout_box) && uniform_solid_border(layout_box).is_some() { return; } let b = &layout_box.border; let r = &layout_box.rect; let styles = &layout_box.border_styles; let colors = &layout_box.border_colors; // Border box starts at content origin minus padding and border. let bx = r.x - layout_box.padding.left - b.left + tx; let by = r.y - layout_box.padding.top - b.top + ty; let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right; let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom; if paint_zero_sized_border_triangle(layout_box, list, bx, by, bw, bh) { return; } // Top border if b.top > 0.0 { paint_border_side(list, bx, by, bw, b.top, true, styles[0], colors[0]); } // Right border if b.right > 0.0 { paint_border_side( list, bx + bw - b.right, by, b.right, bh, false, styles[1], colors[1], ); } // Bottom border if b.bottom > 0.0 { paint_border_side( list, bx, by + bh - b.bottom, bw, b.bottom, true, styles[2], colors[2], ); } // Left border if b.left > 0.0 { paint_border_side(list, bx, by, b.left, bh, false, styles[3], colors[3]); } } fn paint_zero_sized_border_triangle( layout_box: &LayoutBox, list: &mut DisplayList, bx: f32, by: f32, bw: f32, bh: f32, ) -> bool { if layout_box.rect.width > 0.0 || layout_box.rect.height > 0.0 || layout_box.padding.left > 0.0 || layout_box.padding.right > 0.0 || layout_box.padding.top > 0.0 || layout_box.padding.bottom > 0.0 { return false; } let b = &layout_box.border; let styles = &layout_box.border_styles; let colors = &layout_box.border_colors; let sides = [ (b.top, styles[0], colors[0]), (b.right, styles[1], colors[1]), (b.bottom, styles[2], colors[2]), (b.left, styles[3], colors[3]), ]; let visible: Vec = sides .iter() .enumerate() .filter_map(|(idx, (width, style, color))| { (*width > 0.0 && border_style_paints_solid(*style) && color.a > 0).then_some(idx) }) .collect(); if visible.len() != 1 { return false; } paint_border_triangle(list, bx, by, bw, bh, visible[0], sides[visible[0]].2); true } fn border_style_paints_solid(style: BorderStyle) -> bool { matches!( style, BorderStyle::Solid | BorderStyle::Groove | BorderStyle::Ridge | BorderStyle::Inset | BorderStyle::Outset ) } fn paint_border_triangle( list: &mut DisplayList, x: f32, y: f32, width: f32, height: f32, side: usize, color: Color, ) { if width <= 0.0 || height <= 0.0 { return; } let steps = match side { 0 | 2 => height.ceil().max(1.0) as u32, _ => width.ceil().max(1.0) as u32, }; let cx = x + width / 2.0; let cy = y + height / 2.0; for i in 0..steps { let start = i as f32 / steps as f32; let end = (i + 1) as f32 / steps as f32; let mid = (start + end) / 2.0; match side { // border-top: base at top, apex at bottom. 0 => { let w = width * (1.0 - mid); list.push(PaintCommand::FillRect { x: cx - w / 2.0, y: y + height * start, width: w, height: height * (end - start), color, }); } // border-right: base at right, apex at left. 1 => { let h = height * mid; list.push(PaintCommand::FillRect { x: x + width * start, y: cy - h / 2.0, width: width * (end - start), height: h, color, }); } // border-bottom: base at bottom, apex at top. 2 => { let w = width * mid; list.push(PaintCommand::FillRect { x: cx - w / 2.0, y: y + height * start, width: w, height: height * (end - start), color, }); } // border-left: base at left, apex at right. _ => { let h = height * (1.0 - mid); list.push(PaintCommand::FillRect { x: x + width * start, y: cy - h / 2.0, width: width * (end - start), height: h, color, }); } } } } /// Paint one side of a border. `horizontal` selects whether the strip runs /// along the x axis (top/bottom) or the y axis (left/right). The strip /// extends across the corners; dashed/dotted patterns start at the strip's /// origin and any overlap with adjacent sides repaints the same color so /// corners join cleanly. #[allow(clippy::too_many_arguments)] fn paint_border_side( list: &mut DisplayList, x: f32, y: f32, width: f32, height: f32, horizontal: bool, style: BorderStyle, color: Color, ) { if width <= 0.0 || height <= 0.0 { return; } match style { BorderStyle::None | BorderStyle::Hidden => {} BorderStyle::Dashed => { let thickness = if horizontal { height } else { width }; let length = if horizontal { width } else { height }; let dash = (thickness * 3.0).max(1.0); let gap = thickness.max(1.0); let period = dash + gap; let mut cur = 0.0f32; while cur < length { let len = dash.min(length - cur); if horizontal { list.push(PaintCommand::FillRect { x: x + cur, y, width: len, height, color, }); } else { list.push(PaintCommand::FillRect { x, y: y + cur, width, height: len, color, }); } cur += period; } } BorderStyle::Dotted => { let thickness = if horizontal { height } else { width }; let length = if horizontal { width } else { height }; let dot = thickness.max(1.0); let gap = thickness.max(1.0); let period = dot + gap; let mut cur = 0.0f32; while cur + dot <= length + 0.001 { if horizontal { list.push(PaintCommand::FillRect { x: x + cur, y, width: dot, height, color, }); } else { list.push(PaintCommand::FillRect { x, y: y + cur, width, height: dot, color, }); } cur += period; } } BorderStyle::Double => { let thickness = if horizontal { height } else { width }; if thickness < 3.0 { // Spec: when border-width is too small to split into three // visible bands, render as a single solid line. list.push(PaintCommand::FillRect { x, y, width, height, color, }); return; } let line = (thickness / 3.0).floor().max(1.0); if horizontal { list.push(PaintCommand::FillRect { x, y, width, height: line, color, }); list.push(PaintCommand::FillRect { x, y: y + height - line, width, height: line, color, }); } else { list.push(PaintCommand::FillRect { x, y, width: line, height, color, }); list.push(PaintCommand::FillRect { x: x + width - line, y, width: line, height, color, }); } } BorderStyle::Solid | BorderStyle::Groove | BorderStyle::Ridge | BorderStyle::Inset | BorderStyle::Outset => { list.push(PaintCommand::FillRect { x, y, width, height, color, }); } } } fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { for line in &layout_box.lines { let color = line.color; let font_size = line.font_size; // Record index before pushing glyphs so we can insert the background // before the text in painter's order. let glyph_idx = list.len(); // Create a translated copy of the text line. let mut translated_line = line.clone(); translated_line.x += tx; translated_line.y += ty; for shadow in &line.text_shadows { if shadow.color.a == 0 { continue; } let mut shadow_line = translated_line.clone(); shadow_line.x += shadow.offset_x; shadow_line.y += shadow.offset_y; shadow_line.color = shadow.color; list.push(PaintCommand::DrawGlyphs { line: shadow_line, font_size, color: shadow.color, }); } list.push(PaintCommand::DrawGlyphs { line: translated_line, font_size, color, }); // Draw text decoration as a 1px line. Position depends on the variant: // Underline — just below the baseline. // Overline — at the top of the text line. // LineThrough — around the x-height (mid-line). if line.width > 0.0 { let baseline_y = line.y + ty + line.baseline_offset; let decoration_y = match line.text_decoration { TextDecoration::Underline => Some(baseline_y + 2.0), TextDecoration::Overline => Some(line.y + ty), TextDecoration::LineThrough => Some(baseline_y - font_size * 0.3), TextDecoration::None => None, }; if let Some(y) = decoration_y { list.push(PaintCommand::FillRect { x: line.x + tx, y, width: line.width, height: 1.0, color, }); } } // Draw inline background if not transparent. if line.background_color.a > 0 && line.width > 0.0 { list.insert( glyph_idx, PaintCommand::FillRect { x: line.x + tx, y: line.y + ty, width: line.width, height: line.line_height, color: line.background_color, }, ); } } } /// Paint the list-item marker (bullet) for a box with `display: list-item`. /// /// The marker is drawn as a "•" glyph positioned just inside the start of the /// content area, offset to its left. Per CSS lists, the marker sits in the /// padding-left area: when the UA stylesheet sets `padding-left: 40px` on /// `
    `/`
      `, the bullet lands roughly in the middle of that gutter. fn paint_list_marker(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { if layout_box.display != Display::ListItem { return; } let font_size = layout_box.font_size; // Marker is centered ~1em to the left of the content edge so it sits in // the gutter created by the parent `
        `/`
          ` padding-left. let marker_x = layout_box.rect.x - font_size * 1.0 + tx; let marker_y = layout_box.rect.y + ty; let marker_text = "\u{2022}".to_string(); // BULLET (•) let marker_width = font_size * 0.6; list.push(PaintCommand::DrawGlyphs { line: TextLine { text: marker_text, x: marker_x, y: marker_y, width: marker_width, font_size, baseline_offset: font_size, line_height: font_size * 1.2, font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: layout_box.color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: layout_box.bold, italic: false, }, font_size, color: layout_box.color, }); } /// Paint scroll bars for a scrollable box. fn paint_scrollbars( layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32, scroll_state: &ScrollState, ) { let pad = padding_box(layout_box); let viewport_height = pad.height; let content_height = layout_box.content_height; // Determine whether scroll bars should be shown. let show_vertical = match layout_box.overflow { Overflow::Scroll => true, Overflow::Auto => content_height > viewport_height, _ => false, }; if !show_vertical || viewport_height <= 0.0 { return; } // Scroll bar track: right edge of the padding box. let track_x = pad.x + pad.width - SCROLLBAR_WIDTH + tx; let track_y = pad.y + ty; let track_height = viewport_height; // Paint the track background. list.push(PaintCommand::FillRect { x: track_x, y: track_y, width: SCROLLBAR_WIDTH, height: track_height, color: SCROLLBAR_TRACK_COLOR, }); // Compute thumb size and position. let max_content = content_height.max(viewport_height); let thumb_ratio = viewport_height / max_content; let thumb_height = (thumb_ratio * track_height).max(20.0).min(track_height); // Get current scroll offset. let scroll_y = node_id_from_box_type(&layout_box.box_type) .and_then(|id| scroll_state.get(&id)) .map(|&(_, sy)| sy) .unwrap_or(0.0); let max_scroll = (content_height - viewport_height).max(0.0); let scroll_ratio = if max_scroll > 0.0 { scroll_y / max_scroll } else { 0.0 }; let thumb_y = track_y + scroll_ratio * (track_height - thumb_height); // Paint the thumb. list.push(PaintCommand::FillRect { x: track_x, y: thumb_y, width: SCROLLBAR_WIDTH, height: thumb_height, color: SCROLLBAR_THUMB_COLOR, }); } // --------------------------------------------------------------------------- // Form control painting // --------------------------------------------------------------------------- /// Colors for native-style form control rendering. const FC_BORDER_COLOR: Color = Color { r: 118, g: 118, b: 118, a: 255, }; const FC_BG_COLOR: Color = Color { r: 255, g: 255, b: 255, a: 255, }; const FC_BUTTON_BG: Color = Color { r: 239, g: 239, b: 239, a: 255, }; const FC_BUTTON_BORDER_LIGHT: Color = Color { r: 200, g: 200, b: 200, a: 255, }; const FC_BUTTON_BORDER_DARK: Color = Color { r: 133, g: 133, b: 133, a: 255, }; const FC_CHECK_COLOR: Color = Color { r: 0, g: 0, b: 0, a: 255, }; const FC_DISABLED_BG: Color = Color { r: 235, g: 235, b: 228, a: 255, }; const FC_DISABLED_TEXT: Color = Color { r: 169, g: 169, b: 169, a: 255, }; const FC_TEXT_COLOR: Color = Color { r: 0, g: 0, b: 0, a: 255, }; const FC_PLACEHOLDER_TEXT: Color = Color { r: 117, g: 117, b: 117, a: 255, }; /// Focus ring color (blue outline, matching typical browser defaults). const FC_FOCUS_RING_COLOR: Color = Color { r: 0, g: 95, b: 204, a: 255, }; /// Text selection highlight color (standard macOS blue selection). const FC_SELECTION_COLOR: Color = Color { r: 179, g: 215, b: 254, a: 255, }; /// Text cursor (caret) color. const FC_CURSOR_COLOR: Color = Color { r: 0, g: 0, b: 0, a: 255, }; /// Dropdown menu background. const FC_DROPDOWN_BG: Color = Color { r: 255, g: 255, b: 255, a: 255, }; /// Dropdown highlighted option background. const FC_DROPDOWN_HIGHLIGHT: Color = Color { r: 0, g: 95, b: 204, a: 255, }; /// Dropdown highlighted option text color. const FC_DROPDOWN_HIGHLIGHT_TEXT: Color = Color { r: 255, g: 255, b: 255, a: 255, }; /// Dropdown border color. const FC_DROPDOWN_BORDER: Color = Color { r: 180, g: 180, b: 180, a: 255, }; /// Dropdown shadow color (semi-transparent). const FC_DROPDOWN_SHADOW: Color = Color { r: 0, g: 0, b: 0, a: 40, }; /// Optgroup label text color. const FC_OPTGROUP_TEXT: Color = Color { r: 100, g: 100, b: 100, a: 255, }; /// Paint a form control with native-style appearance. fn paint_form_control( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { let bb = border_box(layout_box); if bb.width <= 0.0 || bb.height <= 0.0 { return; } match fc.control_type { FormControlType::TextInput | FormControlType::Password => { paint_text_input(layout_box, fc, list, tx, ty); } FormControlType::Textarea => { paint_textarea(layout_box, fc, list, tx, ty); } FormControlType::Checkbox => { paint_checkbox(layout_box, fc, list, tx, ty); } FormControlType::Radio => { paint_radio(layout_box, fc, list, tx, ty); } FormControlType::Submit | FormControlType::Reset | FormControlType::Button => { paint_button(layout_box, fc, list, tx, ty); } FormControlType::Select => { paint_select(layout_box, fc, list, tx, ty); } } // Paint focus ring around focused controls. if fc.focused { paint_focus_ring(layout_box, list, tx, ty); } } /// Paint a focus ring (blue outline) around a focused form control. fn paint_focus_ring(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bb = border_box(layout_box); // The focus ring sits 1px outside the border box. let offset = 1.0f32; let thickness = 2.0f32; let x = bb.x + tx - offset; let y = bb.y + ty - offset; let w = bb.width + offset * 2.0; let h = bb.height + offset * 2.0; let color = FC_FOCUS_RING_COLOR; // A rounded control gets a rounded (concentric) focus ring; the outer // radius grows by the offset so the ring stays parallel to the border. if has_border_radius(layout_box) { let radii = [ layout_box.border_radii[0] + offset, layout_box.border_radii[1] + offset, layout_box.border_radii[2] + offset, layout_box.border_radii[3] + offset, ]; stroke_rounded_rect(list, x, y, w, h, radii, thickness, color); return; } // Top list.push(PaintCommand::FillRect { x, y, width: w, height: thickness, color, }); // Bottom list.push(PaintCommand::FillRect { x, y: y + h - thickness, width: w, height: thickness, color, }); // Left list.push(PaintCommand::FillRect { x, y, width: thickness, height: h, color, }); // Right list.push(PaintCommand::FillRect { x: x + w - thickness, y, width: thickness, height: h, color, }); } /// Paint a text input or password field: inset border, white background, value text. fn paint_text_input( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { let bb = border_box(layout_box); let x = bb.x + tx; let y = bb.y + ty; let w = bb.width; let h = bb.height; let bg = if fc.disabled { FC_DISABLED_BG } else { FC_BG_COLOR }; let is_placeholder = fc.value.is_empty() && !fc.placeholder.is_empty(); let text_color = if fc.disabled { FC_DISABLED_TEXT } else if is_placeholder { FC_PLACEHOLDER_TEXT } else { FC_TEXT_COLOR }; if layout_box.appearance == Appearance::None { paint_background(layout_box, list, tx, ty); paint_borders(layout_box, list, tx, ty); } else if has_border_radius(layout_box) { // CSS-styled input (e.g. the browser chrome address bar): honor the // author's background and rounded border instead of the default inset // look. A transparent CSS background falls back to the field default. let fill = if layout_box.background_color.a > 0 { layout_box.background_color } else { bg }; let radii = layout_box.border_radii; fill_rounded_rect(list, x, y, w, h, radii, fill); if let Some((bw, bc)) = uniform_solid_border(layout_box) { stroke_rounded_rect(list, x, y, w, h, radii, bw, bc); } } else { // Background list.push(PaintCommand::FillRect { x, y, width: w, height: h, color: bg, }); // Inset border: darker on top/left, lighter on bottom/right. let border = 1.0f32; // Top list.push(PaintCommand::FillRect { x, y, width: w, height: border, color: FC_BUTTON_BORDER_DARK, }); // Left list.push(PaintCommand::FillRect { x, y, width: border, height: h, color: FC_BUTTON_BORDER_DARK, }); // Bottom list.push(PaintCommand::FillRect { x, y: y + h - border, width: w, height: border, color: FC_BUTTON_BORDER_LIGHT, }); // Right list.push(PaintCommand::FillRect { x: x + w - border, y, width: border, height: h, color: FC_BUTTON_BORDER_LIGHT, }); } // Content area for text. A single line of text is vertically centered in // the content box; `text_y` is the line-box top, and the caret/selection // span the line height (not the whole content box). let font_size = layout_box.font_size; let content_h = layout_box.rect.height; let line_h = font_size * 1.2; let v_offset = ((content_h - line_h) / 2.0).max(0.0); let text_x = layout_box.rect.x + tx; let text_y = layout_box.rect.y + ty + v_offset; // For password fields, map byte offsets through the bullet-character mapping. let display_text = if is_placeholder { fc.placeholder.clone() } else if fc.control_type == FormControlType::Password { "\u{2022}".repeat(fc.value.chars().count()) } else { fc.value.clone() }; // Approximate character width (monospace fallback when glyph-accurate // metrics are unavailable, e.g. controls laid out without a font). let char_width = font_size * 0.6; let metrics = layout_box.input_text_metrics; // Paint selection highlight (behind text) when focused and selection exists. if fc.focused && fc.cursor != fc.selection_anchor { let (sel_x, sel_w) = if let Some(m) = metrics { (text_x + m.sel_start_x, m.sel_end_x - m.sel_start_x) } else { let (sel_start, sel_end) = if fc.cursor <= fc.selection_anchor { (fc.cursor, fc.selection_anchor) } else { (fc.selection_anchor, fc.cursor) }; let start_chars = char_count_for_bytes(&fc.value, sel_start); let end_chars = char_count_for_bytes(&fc.value, sel_end); ( text_x + start_chars as f32 * char_width, (end_chars - start_chars) as f32 * char_width, ) }; list.push(PaintCommand::FillRect { x: sel_x, y: text_y, width: sel_w, height: line_h, color: FC_SELECTION_COLOR, }); } // Value text (clipped to content area). if !display_text.is_empty() { list.push(PaintCommand::DrawGlyphs { line: TextLine { text: display_text, x: text_x, y: text_y, width: layout_box.rect.width, font_size, baseline_offset: font_size, line_height: font_size * 1.2, font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: layout_box.bold, italic: layout_box.italic, }, font_size, color: text_color, }); } // Paint cursor (caret) when focused and no selection. if fc.focused && fc.cursor == fc.selection_anchor { let cursor_x = if let Some(m) = metrics { text_x + m.caret_x } else { let cursor_chars = char_count_for_bytes(&fc.value, fc.cursor); text_x + cursor_chars as f32 * char_width }; let cursor_w = 1.0f32; list.push(PaintCommand::FillRect { x: cursor_x, y: text_y, width: cursor_w, height: line_h, color: FC_CURSOR_COLOR, }); } } /// Count the number of Unicode characters in `s[..byte_offset]`. fn char_count_for_bytes(s: &str, byte_offset: usize) -> usize { let clamped = byte_offset.min(s.len()); s[..clamped].chars().count() } /// Paint a textarea: same style as text input but taller. fn paint_textarea( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { // Reuse text input painting — same visual style. paint_text_input(layout_box, fc, list, tx, ty); } /// Paint a checkbox: small bordered square with optional checkmark. fn paint_checkbox( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { let bb = border_box(layout_box); let x = bb.x + tx; let y = bb.y + ty; let size = bb.width.min(bb.height); let bg = if fc.disabled { FC_DISABLED_BG } else { FC_BG_COLOR }; // Background list.push(PaintCommand::FillRect { x, y, width: size, height: size, color: bg, }); // Border let border = 1.0f32; let bc = FC_BORDER_COLOR; // Top list.push(PaintCommand::FillRect { x, y, width: size, height: border, color: bc, }); // Bottom list.push(PaintCommand::FillRect { x, y: y + size - border, width: size, height: border, color: bc, }); // Left list.push(PaintCommand::FillRect { x, y, width: border, height: size, color: bc, }); // Right list.push(PaintCommand::FillRect { x: x + size - border, y, width: border, height: size, color: bc, }); // Indeterminate indicator: horizontal dash in the center. if fc.indeterminate { let ind_color = if fc.disabled { FC_DISABLED_TEXT } else { FC_CHECK_COLOR }; let inset = (size * 0.25).max(2.0); let thick = (size * 0.15).max(1.5); let dash_y = y + (size - thick) / 2.0; list.push(PaintCommand::FillRect { x: x + inset, y: dash_y, width: size - inset * 2.0, height: thick, color: ind_color, }); } // Checkmark when checked: draw a simple check using two rectangles // forming an "L" rotated, approximated as two thick lines. else if fc.checked { let check_color = if fc.disabled { FC_DISABLED_TEXT } else { FC_CHECK_COLOR }; let inset = (size * 0.2).max(2.0); let thick = (size * 0.15).max(1.5); // Short leg of check (going down-right from left) let leg_x = x + inset; let leg_y = y + size * 0.45; let leg_w = size * 0.25; let leg_h = size * 0.35; list.push(PaintCommand::FillRect { x: leg_x, y: leg_y, width: thick, height: leg_h, color: check_color, }); // Long leg of check (going up-right) let arm_x = leg_x + thick; let arm_y = y + inset; list.push(PaintCommand::FillRect { x: arm_x, y: arm_y, width: leg_w, height: thick, color: check_color, }); // Diagonal approximation: draw from bottom-left to top-right as // a series of small filled rects. // Short descending leg: from (inset, center) to (center, bottom-inset) let cx = x + size * 0.38; let cy = y + size - inset; let steps = ((size * 0.3) as i32).max(2); for i in 0..steps { let t = i as f32 / steps as f32; let rx = leg_x + t * (cx - leg_x); let ry = leg_y + t * (cy - leg_y); list.push(PaintCommand::FillRect { x: rx, y: ry, width: thick, height: thick, color: check_color, }); } // Long ascending leg: from (center, bottom-inset) to (right-inset, top+inset) let ex = x + size - inset; let ey = y + inset; let steps2 = ((size * 0.6) as i32).max(3); for i in 0..=steps2 { let t = i as f32 / steps2 as f32; let rx = cx + t * (ex - cx); let ry = cy + t * (ey - cy); list.push(PaintCommand::FillRect { x: rx, y: ry, width: thick, height: thick, color: check_color, }); } } } /// Paint a radio button: bordered circle approximation with optional filled dot. fn paint_radio( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { let bb = border_box(layout_box); let x = bb.x + tx; let y = bb.y + ty; let size = bb.width.min(bb.height); let cx = x + size / 2.0; let cy = y + size / 2.0; let radius = size / 2.0; let bg = if fc.disabled { FC_DISABLED_BG } else { FC_BG_COLOR }; // Draw circle approximation using filled rects in a circular pattern. // Outer circle (border). paint_circle_filled(list, cx, cy, radius, FC_BORDER_COLOR); // Inner circle (background). paint_circle_filled(list, cx, cy, radius - 1.0, bg); // Selected dot. if fc.checked { let dot_color = if fc.disabled { FC_DISABLED_TEXT } else { FC_CHECK_COLOR }; paint_circle_filled(list, cx, cy, radius * 0.4, dot_color); } } /// Approximate a filled circle using horizontal FillRect scanlines. fn paint_circle_filled(list: &mut DisplayList, cx: f32, cy: f32, radius: f32, color: Color) { if radius <= 0.0 { return; } let r = radius as i32; for dy in -r..=r { let dx = (radius * radius - (dy as f32 * dy as f32)).sqrt(); if dx > 0.0 { list.push(PaintCommand::FillRect { x: cx - dx, y: cy + dy as f32, width: dx * 2.0, height: 1.0, color, }); } } } /// Paint a button (submit, reset, or generic): raised border with centered label. fn paint_button( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { let bb = border_box(layout_box); let x = bb.x + tx; let y = bb.y + ty; let w = bb.width; let h = bb.height; let bg = if fc.disabled { FC_DISABLED_BG } else { FC_BUTTON_BG }; let text_color = if fc.disabled { FC_DISABLED_TEXT } else { FC_TEXT_COLOR }; // Background list.push(PaintCommand::FillRect { x, y, width: w, height: h, color: bg, }); // Raised border: lighter on top/left, darker on bottom/right. let border = 1.0f32; // Top (light) list.push(PaintCommand::FillRect { x, y, width: w, height: border, color: FC_BUTTON_BORDER_LIGHT, }); // Left (light) list.push(PaintCommand::FillRect { x, y, width: border, height: h, color: FC_BUTTON_BORDER_LIGHT, }); // Bottom (dark) list.push(PaintCommand::FillRect { x, y: y + h - border, width: w, height: border, color: FC_BUTTON_BORDER_DARK, }); // Right (dark) list.push(PaintCommand::FillRect { x: x + w - border, y, width: border, height: h, color: FC_BUTTON_BORDER_DARK, }); // Centered label text if !fc.value.is_empty() { let font_size = layout_box.font_size; let char_width = font_size * 0.6; let text_width = fc.value.len() as f32 * char_width; let text_x = x + (w - text_width) / 2.0; let text_y = y + (h - font_size) / 2.0; list.push(PaintCommand::DrawGlyphs { line: TextLine { text: fc.value.clone(), x: text_x, y: text_y, width: text_width, font_size, baseline_offset: font_size, line_height: font_size * 1.2, font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: layout_box.bold, italic: layout_box.italic, }, font_size, color: text_color, }); } } /// Paint a select control: either a dropdown button (single) or a listbox (multiple). fn paint_select( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { if fc.multiple { paint_select_listbox(layout_box, fc, list, tx, ty); } else { paint_select_button(layout_box, fc, list, tx, ty); } } /// Paint a single-select dropdown button: bordered rectangle with selected text and arrow. fn paint_select_button( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { let bb = border_box(layout_box); let x = bb.x + tx; let y = bb.y + ty; let w = bb.width; let h = bb.height; let bg = if fc.disabled { FC_DISABLED_BG } else { FC_BUTTON_BG }; let text_color = if fc.disabled { FC_DISABLED_TEXT } else { FC_TEXT_COLOR }; // Background list.push(PaintCommand::FillRect { x, y, width: w, height: h, color: bg, }); // Border paint_rect_border(list, x, y, w, h, 1.0, FC_BORDER_COLOR); // Selected option text let font_size = layout_box.font_size; let text_x = layout_box.rect.x + tx; let text_y = layout_box.rect.y + ty; if !fc.value.is_empty() { list.push(PaintCommand::DrawGlyphs { line: TextLine { text: fc.value.clone(), x: text_x, y: text_y, width: layout_box.rect.width - 20.0, // leave room for arrow font_size, baseline_offset: font_size, line_height: font_size * 1.2, font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: layout_box.bold, italic: layout_box.italic, }, font_size, color: text_color, }); } // Dropdown arrow (▼) as a small triangle approximated with rects. let arrow_size = font_size * 0.4; let arrow_x = x + w - 14.0; let arrow_y = y + (h - arrow_size) / 2.0; let arrow_color = if fc.disabled { FC_DISABLED_TEXT } else { FC_TEXT_COLOR }; let rows = (arrow_size as i32).max(3); for i in 0..rows { let t = i as f32 / rows as f32; let row_width = arrow_size * (1.0 - t); let row_x = arrow_x + (arrow_size - row_width) / 2.0; list.push(PaintCommand::FillRect { x: row_x, y: arrow_y + i as f32, width: row_width, height: 1.0, color: arrow_color, }); } } /// Paint a multiple-select listbox: bordered white box showing visible options. fn paint_select_listbox( layout_box: &LayoutBox, fc: &FormControlInfo, list: &mut DisplayList, tx: f32, ty: f32, ) { let bb = border_box(layout_box); let x = bb.x + tx; let y = bb.y + ty; let w = bb.width; let h = bb.height; let bg = if fc.disabled { FC_DISABLED_BG } else { FC_BG_COLOR }; let text_color = if fc.disabled { FC_DISABLED_TEXT } else { FC_TEXT_COLOR }; // Background list.push(PaintCommand::FillRect { x, y, width: w, height: h, color: bg, }); // Border paint_rect_border(list, x, y, w, h, 1.0, FC_BORDER_COLOR); // Paint visible options let font_size = layout_box.font_size; let line_height = font_size * 1.2; let padding = 2.0f32; let mut oy = y + 1.0 + padding; for (i, opt) in fc.options.iter().enumerate() { if oy + line_height > y + h - 1.0 { break; // Clipped } let opt_text_color = if opt.disabled { FC_DISABLED_TEXT } else if opt.selected { FC_DROPDOWN_HIGHLIGHT_TEXT } else { text_color }; // Highlight selected options if opt.selected && !opt.is_group_label { list.push(PaintCommand::FillRect { x: x + 1.0, y: oy, width: w - 2.0, height: line_height, color: FC_DROPDOWN_HIGHLIGHT, }); } // Highlighted (keyboard-navigated) option if fc.highlighted_index == Some(i) && !opt.is_group_label { paint_rect_border( list, x + 1.0, oy, w - 2.0, line_height, 1.0, FC_FOCUS_RING_COLOR, ); } let indent = if opt.is_group_label { 0.0 } else { 4.0 }; let opt_color = if opt.is_group_label { FC_OPTGROUP_TEXT } else { opt_text_color }; list.push(PaintCommand::DrawGlyphs { line: TextLine { text: opt.text.clone(), x: x + padding + indent + 1.0, y: oy, width: w - padding * 2.0 - indent - 2.0, font_size: if opt.is_group_label { font_size * 0.9 } else { font_size }, baseline_offset: if opt.is_group_label { font_size * 0.9 } else { font_size }, line_height: if opt.is_group_label { font_size * 0.9 * 1.2 } else { font_size * 1.2 }, font_weight: if opt.is_group_label { 700.0 } else { 400.0 }, letter_spacing: 0.0, font_family: String::new(), color: opt_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: opt.is_group_label, italic: false, }, font_size: if opt.is_group_label { font_size * 0.9 } else { font_size }, color: opt_color, }); oy += line_height; } } /// Paint a simple rectangular border (4 sides). fn paint_rect_border( list: &mut DisplayList, x: f32, y: f32, w: f32, h: f32, border: f32, color: Color, ) { // Top list.push(PaintCommand::FillRect { x, y, width: w, height: border, color, }); // Bottom list.push(PaintCommand::FillRect { x, y: y + h - border, width: w, height: border, color, }); // Left list.push(PaintCommand::FillRect { x, y, width: border, height: h, color, }); // Right list.push(PaintCommand::FillRect { x: x + w - border, y, width: border, height: h, color, }); } /// Paint a dropdown overlay popup for an open single-select. /// Called after the main paint pass so it renders on top of all content. fn paint_dropdown_overlay(dd: &PendingDropdown, list: &mut DisplayList) { let font_size = dd.font_size; let line_height = font_size * 1.2; let padding = 4.0f32; let border = 1.0f32; let option_count = dd.options.len(); let menu_height = option_count as f32 * line_height + padding * 2.0 + border * 2.0; let menu_width = dd.width.max(100.0); // Position below the select control. let menu_x = dd.x; let menu_y = dd.y + dd.height; // Shadow (offset 2px down, slightly larger) list.push(PaintCommand::FillRect { x: menu_x + 2.0, y: menu_y + 2.0, width: menu_width, height: menu_height, color: FC_DROPDOWN_SHADOW, }); // Background list.push(PaintCommand::FillRect { x: menu_x, y: menu_y, width: menu_width, height: menu_height, color: FC_DROPDOWN_BG, }); // Border paint_rect_border( list, menu_x, menu_y, menu_width, menu_height, border, FC_DROPDOWN_BORDER, ); // Paint options let mut oy = menu_y + border + padding; for (i, opt) in dd.options.iter().enumerate() { let is_highlighted = dd.highlighted_index == Some(i); // Highlight background if is_highlighted && !opt.is_group_label && !opt.disabled { list.push(PaintCommand::FillRect { x: menu_x + border, y: oy, width: menu_width - border * 2.0, height: line_height, color: FC_DROPDOWN_HIGHLIGHT, }); } let text_color = if opt.is_group_label { FC_OPTGROUP_TEXT } else if opt.disabled { FC_DISABLED_TEXT } else if is_highlighted { FC_DROPDOWN_HIGHLIGHT_TEXT } else { FC_TEXT_COLOR }; let indent = if opt.is_group_label { padding } else { // Indent options within optgroups a bit more. padding + 8.0 }; let opt_font_size = if opt.is_group_label { font_size * 0.85 } else { font_size }; list.push(PaintCommand::DrawGlyphs { line: TextLine { text: opt.text.clone(), x: menu_x + indent, y: oy, width: menu_width - indent - padding, font_size: opt_font_size, baseline_offset: opt_font_size, line_height: opt_font_size * 1.2, font_weight: if opt.is_group_label { 700.0 } else { 400.0 }, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: opt.is_group_label, italic: false, }, font_size: opt_font_size, color: text_color, }); oy += line_height; } } pub(crate) fn resolve_background_image_size( size: BackgroundSize, box_width: f32, box_height: f32, intrinsic_width: f32, intrinsic_height: f32, ) -> (f32, f32) { match size { BackgroundSize::Cover => { let scale = (box_width / intrinsic_width).max(box_height / intrinsic_height); (intrinsic_width * scale, intrinsic_height * scale) } BackgroundSize::Contain => { let scale = (box_width / intrinsic_width).min(box_height / intrinsic_height); (intrinsic_width * scale, intrinsic_height * scale) } BackgroundSize::Explicit(w, h) => { let resolved_w = resolve_background_length(w, box_width); let resolved_h = resolve_background_length(h, box_height); match (resolved_w, resolved_h) { (Some(w), Some(h)) => (w, h), (Some(w), None) => (w, w * intrinsic_height / intrinsic_width), (None, Some(h)) => (h * intrinsic_width / intrinsic_height, h), (None, None) => (intrinsic_width, intrinsic_height), } } BackgroundSize::Auto => (intrinsic_width, intrinsic_height), } } fn resolve_background_length(value: LengthOrAuto, reference: f32) -> Option { match value { LengthOrAuto::Length(px) => Some(px), LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), LengthOrAuto::Calc(px, pct) => Some(px + pct / 100.0 * reference), LengthOrAuto::ClampCalc { min_px, px_offset, pct, max_px, } => Some( (px_offset + pct / 100.0 * reference) .max(min_px) .min(max_px), ), LengthOrAuto::FitContent | LengthOrAuto::Auto => None, } } pub(crate) fn background_position_offset( value: LengthOrAuto, free_space: f32, reference: f32, ) -> f32 { match value { LengthOrAuto::Percentage(p) => p / 100.0 * free_space, LengthOrAuto::Length(px) => px, LengthOrAuto::Calc(px, pct) => px + pct / 100.0 * reference, LengthOrAuto::ClampCalc { min_px, px_offset, pct, max_px, } => (px_offset + pct / 100.0 * reference) .max(min_px) .min(max_px), LengthOrAuto::FitContent | LengthOrAuto::Auto => 0.0, } } #[cfg(test)] mod tests { use super::*; use we_dom::Document; use we_style::computed::{extract_stylesheets, resolve_styles}; use we_text::font::Font; fn test_font() -> Font { let paths = [ "/System/Library/Fonts/Geneva.ttf", "/System/Library/Fonts/Monaco.ttf", ]; for path in &paths { let p = std::path::Path::new(path); if p.exists() { return Font::from_file(p).expect("failed to parse font"); } } panic!("no test font found"); } fn layout_doc(doc: &Document) -> we_layout::LayoutTree { let font = test_font(); let sheets = extract_stylesheets(doc); let styled = resolve_styles(doc, &sheets, (800.0, 600.0)).unwrap(); we_layout::layout( &styled, doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ) } #[test] fn display_list_from_empty_layout() { let doc = Document::new(); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)); if let Some(styled) = styled { let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); assert!(list.len() <= 1); } } #[test] fn transform_scale_zero_suppresses_subtree_paint() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_fill = |color| { list.iter().any(|cmd| { matches!( cmd, PaintCommand::FillRect { color: fill, .. } if *fill == color ) }) }; assert!(!has_fill(Color::rgb(255, 0, 0))); assert!(!has_fill(Color::rgb(0, 0, 255))); assert!(has_fill(Color::rgb(0, 128, 0))); } #[test] fn transform_translate_offsets_subtree_paint() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let child_rect = list .iter() .find_map(|cmd| match cmd { PaintCommand::FillRect { x, y, width, height, color, } if *color == Color::rgb(0, 0, 255) => Some((*x, *y, *width, *height)), _ => None, }) .expect("child fill rect"); assert_eq!(child_rect, (25.0, 10.0, 30.0, 20.0)); } #[test] fn canvas_background_propagates_from_body() { // example.com pattern: `body { background:#eee }`, html transparent. // The body background must propagate to the whole viewport canvas. let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); doc.set_attribute(body, "style", "background:#eee"); doc.append_child(root, html); doc.append_child(html, body); let tree = layout_doc(&doc); assert_eq!( canvas_background_color(&tree), Some(Color::rgb(0xee, 0xee, 0xee)) ); } #[test] fn canvas_background_prefers_root_over_body() { // When the root (html) has its own background, it wins over the body's. let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); doc.set_attribute(html, "style", "background:#123456"); doc.set_attribute(body, "style", "background:#abcdef"); doc.append_child(root, html); doc.append_child(html, body); let tree = layout_doc(&doc); assert_eq!( canvas_background_color(&tree), Some(Color::rgb(0x12, 0x34, 0x56)) ); } #[test] fn canvas_background_none_when_transparent() { // No author background anywhere: callers keep their default canvas. let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); doc.append_child(root, html); doc.append_child(html, body); let tree = layout_doc(&doc); assert_eq!(canvas_background_color(&tree), None); } #[test] fn display_list_has_background_and_text() { let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); let p = doc.create_element("p"); let text = doc.create_text("Hello world"); doc.append_child(root, html); doc.append_child(html, body); doc.append_child(body, p); doc.append_child(p, text); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_text = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); assert!(has_text, "should have at least one DrawGlyphs"); } #[test] fn inline_flex_svg_icon_emits_draw_image() { let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); let icon = doc.create_element("i"); doc.set_attribute(icon, "class", "icon-component"); let svg = doc.create_element_ns("svg", Some("http://www.w3.org/2000/svg")); doc.set_attribute(svg, "width", "24"); doc.set_attribute(svg, "height", "24"); doc.set_attribute(svg, "viewBox", "0 0 24 24"); doc.append_child(root, html); doc.append_child(html, body); doc.append_child(body, icon); doc.append_child(icon, svg); let font = test_font(); let ss = we_css::parser::Parser::parse( "body{margin:0}.icon-component{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px}.icon-component svg{width:100%;height:auto}", ); let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); let mut sizes = std::collections::HashMap::new(); sizes.insert(svg, we_layout::ReplacedContent::Size(24.0, 24.0)); let tree = we_layout::layout(&styled, &doc, 800.0, 600.0, &font, &sizes); let list = build_display_list(&tree); let draw = list .iter() .find_map(|cmd| match cmd { PaintCommand::DrawImage { node_id, x, y, width, height, .. } if *node_id == svg => Some((*x, *y, *width, *height)), _ => None, }) .expect("svg draw image command"); assert_eq!(draw, (0.0, 0.0, 24.0, 24.0)); } #[test] fn paint_heading_produces_larger_glyphs() { let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); let h1 = doc.create_element("h1"); let h1_text = doc.create_text("Big"); let p = doc.create_element("p"); let p_text = doc.create_text("Small"); doc.append_child(root, html); doc.append_child(html, body); doc.append_child(body, h1); doc.append_child(h1, h1_text); doc.append_child(body, p); doc.append_child(p, p_text); let tree = layout_doc(&doc); let list = build_display_list(&tree); // There should be DrawGlyphs commands with different font sizes. let font_sizes: Vec = list .iter() .filter_map(|c| match c { PaintCommand::DrawGlyphs { font_size, .. } => Some(*font_size), _ => None, }) .collect(); assert!(font_sizes.len() >= 2, "should have at least 2 text lines"); // h1 has font_size 32, p has font_size 16. assert!( font_sizes.iter().any(|&s| s > 20.0), "should have a heading with large font size" ); assert!( font_sizes.iter().any(|&s| s < 20.0), "should have a paragraph with normal font size" ); } #[test] fn css_color_renders_correctly() { let html_str = r#"

          Red text

          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let text_colors: Vec<&Color> = list .iter() .filter_map(|c| match c { PaintCommand::DrawGlyphs { color, .. } => Some(color), _ => None, }) .collect(); assert!(!text_colors.is_empty()); // Text should be red. assert_eq!(*text_colors[0], Color::rgb(255, 0, 0)); } #[test] fn css_text_shadow_paints_before_foreground_text() { let html_str = r#"

          Shadowed

          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let glyphs: Vec<(usize, Color, f32, f32)> = list .iter() .enumerate() .filter_map(|(idx, cmd)| match cmd { PaintCommand::DrawGlyphs { line, color, .. } if line.text == "Shadowed" => { Some((idx, *color, line.x, line.y)) } _ => None, }) .collect(); assert_eq!(glyphs.len(), 2, "shadow and foreground glyphs should paint"); assert_eq!(glyphs[0].1, Color::new(0, 0, 0, 128)); assert_eq!(glyphs[1].1, Color::rgb(255, 255, 255)); assert!(glyphs[0].0 < glyphs[1].0); assert!((glyphs[0].2 - glyphs[1].2 - 2.0).abs() < 0.01); assert!((glyphs[0].3 - glyphs[1].3 - 3.0).abs() < 0.01); } #[test] fn css_background_color_renders() { let html_str = r#"
          Content
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let fill_colors: Vec<&Color> = list .iter() .filter_map(|c| match c { PaintCommand::FillRect { color, .. } => Some(color), _ => None, }) .collect(); // Should have a yellow fill rect for the div background. assert!(fill_colors.iter().any(|c| **c == Color::rgb(255, 255, 0))); } #[test] fn css_background_image_emits_background_paint_command() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); assert!( list.iter() .any(|c| matches!(c, PaintCommand::DrawBackgroundImage { .. })), "background-image should produce a background image paint command" ); } #[test] fn zero_sized_border_triangle_paints_tapered_strips() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let black_rects: Vec<(f32, f32)> = list .iter() .filter_map(|cmd| match cmd { PaintCommand::FillRect { width, height, color, .. } if *color == Color::rgb(0, 0, 0) => Some((*width, *height)), _ => None, }) .collect(); assert!( black_rects.len() > 1, "triangle should be approximated by multiple tapered strips, got {black_rects:?}" ); assert!( !black_rects .iter() .any(|(w, h)| (*w - 6.0).abs() < 0.1 && (*h - 4.0).abs() < 0.1), "triangle must not paint a full 6x4 square: {black_rects:?}" ); let min_width = black_rects .iter() .map(|(w, _)| *w) .fold(f32::INFINITY, f32::min); let max_width = black_rects.iter().map(|(w, _)| *w).fold(0.0, f32::max); assert!(max_width > min_width, "strip widths should taper"); } #[test] fn css_linear_gradient_emits_gradient_paint_command() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let gradient = list .iter() .find_map(|c| match c { PaintCommand::LinearGradient { width, height, gradient, .. } => Some((*width, *height, gradient)), _ => None, }) .expect("linear-gradient should produce a gradient paint command"); assert!((gradient.0 - 100.0).abs() < 0.5); assert!((gradient.1 - 50.0).abs() < 0.5); assert_eq!(gradient.2.stops[0].color, Color::new(0, 0, 0, 153)); assert_eq!(gradient.2.stops[1].color, Color::new(0, 0, 0, 0)); } #[test] fn css_radial_gradient_emits_gradient_paint_command() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let gradient = list .iter() .find_map(|c| match c { PaintCommand::RadialGradient { width, height, position_x, position_y, repeat, gradient, .. } => Some((*width, *height, *position_x, *position_y, *repeat, gradient)), _ => None, }) .expect("radial-gradient should produce a gradient paint command"); assert!((gradient.0 - 100.0).abs() < 0.5); assert!((gradient.1 - 50.0).abs() < 0.5); assert_eq!(gradient.2, LengthOrAuto::Length(480.0)); assert_eq!(gradient.3, LengthOrAuto::Length(-36.0)); assert_eq!(gradient.4, BackgroundRepeat::Repeat); assert_eq!(gradient.5.stops[0].color, Color::new(87, 41, 255, 59)); assert_eq!(gradient.5.stops[1].color, Color::new(0, 0, 0, 0)); } #[test] fn empty_before_pseudo_element_paints_gradient_box() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let gradient = list .iter() .find_map(|c| match c { PaintCommand::LinearGradient { x, y, width, height, gradient, } => Some((*x, *y, *width, *height, gradient)), _ => None, }) .expect("empty ::before should paint its gradient background"); assert!((gradient.0 - 0.0).abs() < 0.5); assert!((gradient.1 - 0.0).abs() < 0.5); assert!((gradient.2 - 200.0).abs() < 0.5); assert!((gradient.3 - 60.0).abs() < 0.5); assert_eq!(gradient.4.stops[0].color, Color::new(0, 0, 0, 153)); assert_eq!(gradient.4.stops[1].color, Color::new(0, 0, 0, 0)); } #[test] fn css_box_shadow_paints_before_rounded_background() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let shadow_index = list .iter() .position(|cmd| { matches!( cmd, PaintCommand::BoxShadow { color, .. } if color.r == 0 && color.g == 0 && color.b == 0 && color.a > 0 ) }) .expect("box-shadow should emit blurred shadow paint"); let background_index = list .iter() .position(|cmd| { matches!( cmd, PaintCommand::RoundedRect { color, .. } if *color == Color::rgb(255, 255, 255) ) }) .expect("rounded background should paint"); assert!( shadow_index < background_index, "outer shadow must paint behind the element background" ); } #[test] fn hsl_flex_descendant_backgrounds_render_at_flex_positions() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let fills: Vec<(f32, f32, f32, f32)> = list .iter() .filter_map(|cmd| match cmd { PaintCommand::FillRect { x, y, width, height, color, } if *color == Color::rgb(227, 227, 227) => Some((*x, *y, *width, *height)), _ => None, }) .collect(); assert!( fills.iter().any(|(x, y, width, height)| { (*x - 0.0).abs() < 1.0 && (*y - 0.0).abs() < 1.0 && (*width - 20.0).abs() < 1.0 && (*height - 20.0).abs() < 1.0 }), "first hsl fill should paint at the first flex item origin: {fills:?}" ); assert!( fills.iter().any(|(x, y, width, height)| { (*x - 100.0).abs() < 1.0 && (*y - 0.0).abs() < 1.0 && (*width - 20.0).abs() < 1.0 && (*height - 20.0).abs() < 1.0 }), "second hsl fill should move with the second flex item: {fills:?}" ); } #[test] fn background_extends_to_border_box() { // CSS default is `background-clip: border-box`: an element's // background-color must fill the entire border box (content + // padding + border area), not just the content rect. let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let red_fill = list .iter() .find_map(|c| match c { PaintCommand::FillRect { x, y, width, height, color, } if *color == Color::rgb(255, 0, 0) => Some((*x, *y, *width, *height)), _ => None, }) .expect("background fill should be in display list"); // Border box is content (100x50) + padding (16+16) + border (4+4) // = 140 wide x 90 tall, starting at (0,0). assert_eq!(red_fill.0, 0.0, "background x should be at border box left"); assert_eq!(red_fill.1, 0.0, "background y should be at border box top"); assert_eq!( red_fill.2, 140.0, "background width should equal border-box width" ); assert_eq!( red_fill.3, 90.0, "background height should equal border-box height" ); } #[test] fn border_rendering() { let html_str = r#"
          Bordered
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let red_fills: Vec<_> = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0))) .collect(); // Should have 4 border fills (top, right, bottom, left). assert_eq!(red_fills.len(), 4, "should have 4 border edges"); } #[test] fn dashed_border_produces_multiple_segments_per_side() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let red_fills: Vec<_> = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0))) .collect(); // Solid would emit exactly 4 (one per side). Dashed must emit many more. assert!( red_fills.len() > 4, "dashed border should produce multiple segments per side, got {}", red_fills.len() ); } #[test] fn dotted_border_produces_dot_segments() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let blue_fills: Vec<_> = list .iter() .filter_map(|c| match c { PaintCommand::FillRect { width, height, color, .. } if *color == Color::rgb(0, 0, 255) => Some((*width, *height)), _ => None, }) .collect(); assert!( blue_fills.len() > 4, "dotted border should produce multiple dots per side, got {}", blue_fills.len() ); // Each dot should be square-ish (width == height for top/bottom dots, // or width == thickness for left/right dots) — never a long strip. for (w, h) in &blue_fills { assert!( (*w - 2.0).abs() < 0.001 || (*h - 2.0).abs() < 0.001, "dotted segment should be 2px in at least one axis, got {w}x{h}" ); assert!( *w <= 4.0 && *h <= 4.0, "dotted segment should not be a long strip, got {w}x{h}" ); } } #[test] fn double_border_produces_two_strips_per_side() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); let tree = we_layout::layout( &styled, &doc, 800.0, 600.0, &font, &std::collections::HashMap::new(), ); let list = build_display_list(&tree); let green_fills: Vec<_> = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(0, 128, 0))) .collect(); // Two strips per side × 4 sides = 8. assert_eq!( green_fills.len(), 8, "double border should produce two strips per side (8 total)" ); } // --- Visibility painting tests --- #[test] fn visibility_hidden_not_painted() { let html_str = r#"
          Hidden
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // No red background should be in the display list. let red_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(255, 0, 0)) }); assert_eq!(red_fills.count(), 0, "hidden element should not be painted"); // No text should be rendered for the hidden element. let hidden_text = list.iter().filter( |c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Hidden"), ); assert_eq!( hidden_text.count(), 0, "hidden element text should not be painted" ); } #[test] fn visibility_visible_child_of_hidden_parent_is_painted() { let html_str = r#"

          Visible

          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // The visible child's text should appear in the display list. let visible_text = list.iter().filter( |c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text.contains("Visible")), ); assert!( visible_text.count() > 0, "visible child of hidden parent should be painted" ); } #[test] fn visibility_collapse_not_painted() { let html_str = r#"
          Collapsed
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let blue_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(0, 0, 255)) }); assert_eq!( blue_fills.count(), 0, "collapse element should not be painted" ); } // --- Overflow clipping tests --- #[test] fn overflow_hidden_generates_clip_commands() { let html_str = r#"

          Content

          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let push_count = list .iter() .filter(|c| matches!(c, PaintCommand::PushClip { .. })) .count(); let pop_count = list .iter() .filter(|c| matches!(c, PaintCommand::PopClip)) .count(); assert!(push_count >= 1, "overflow:hidden should emit PushClip"); assert_eq!(push_count, pop_count, "PushClip/PopClip must be balanced"); // A plain rectangular overflow clip carries all-zero corner radii. let rect_clip = list .iter() .find_map(|c| match c { PaintCommand::PushClip { radii, .. } => Some(*radii), _ => None, }) .expect("expected a PushClip"); assert_eq!(rect_clip, [0.0; 4], "rectangular clip must have zero radii"); } #[test] fn overflow_hidden_with_border_radius_emits_rounded_clip() { // An overflow:hidden container with border-radius must emit a PushClip // whose radii are non-zero so the renderer rounds the clipped corners. let html_str = r#"

          Clipped

          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Find the PushClip matching the container's padding box (100x80, no // border so padding box == border box). let radii = list .iter() .find_map(|c| match c { PaintCommand::PushClip { width, height, radii, .. } if (*width - 100.0).abs() < 0.5 && (*height - 80.0).abs() < 0.5 => Some(*radii), _ => None, }) .expect("rounded container must emit a PushClip for its padding box"); assert!( radii.iter().all(|&r| (r - 20.0).abs() < 0.5), "border-radius:20px with no border should yield ~20px clip radii, got {radii:?}" ); } #[test] fn padding_box_radii_shrink_by_border_width() { // With a border, the padding-box (clip) radius is the border-radius minus // the border width, floored at zero. let html_str = r#"

          Clipped

          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Padding box is 100x80 (content+padding), inside the 5px border. let radii = list .iter() .find_map(|c| match c { PaintCommand::PushClip { width, height, radii, .. } if (*width - 100.0).abs() < 0.5 && (*height - 80.0).abs() < 0.5 => Some(*radii), _ => None, }) .expect("rounded bordered container must emit a PushClip"); // 20px outer radius minus 5px border = ~15px inner radius. assert!( radii.iter().all(|&r| (r - 15.0).abs() < 0.5), "expected ~15px inner clip radii, got {radii:?}" ); } #[test] fn overflow_visible_no_clip_commands() { let html_str = r#"

          Content

          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let push_count = list .iter() .filter(|c| matches!(c, PaintCommand::PushClip { .. })) .count(); assert_eq!( push_count, 0, "overflow:visible should not emit clip commands" ); } #[test] fn overflow_hidden_clips_own_text() { // A narrow box with overflow:hidden contains a long unbreakable word // whose natural width far exceeds the content box. The DrawGlyphs // command for the line must be issued after PushClip so per-pixel // clipping in the rasterizer drops the overflowing glyphs. let html_str = r#"
          SuperLongUnbreakableWord
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Locate the PushClip for the .box (padding box at x=0,y=0,w=40,h=20). let push_idx = list.iter().position(|c| { matches!(c, PaintCommand::PushClip { width, height, .. } if (*width - 40.0).abs() < 0.5 && (*height - 20.0).abs() < 0.5) }).expect(".box must emit a PushClip for its padding box"); let pop_idx = list[push_idx..] .iter() .position(|c| matches!(c, PaintCommand::PopClip)) .map(|i| i + push_idx) .expect("matching PopClip must follow"); // The glyph line for the .box's own text content must lie inside the // PushClip/PopClip pair so the rasterizer clips it. let glyph_inside_clip = list[push_idx..pop_idx] .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { .. })); assert!( glyph_inside_clip, "DrawGlyphs for the box's own text must be enclosed by PushClip/PopClip" ); } #[test] fn display_none_not_in_display_list() { let html_str = r#"

          Visible

          Gone
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let green_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == Color::rgb(0, 128, 0)) }); assert_eq!( green_fills.count(), 0, "display:none element should not be in display list" ); } // --- Overflow scrolling tests --- #[test] fn overflow_scroll_renders_scrollbar_always() { // overflow:scroll should always render scroll bars, even when content fits. let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Should have scroll bar track and thumb FillRect commands. let scrollbar_fills: Vec<_> = list .iter() .filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) }) .collect(); assert!( scrollbar_fills.len() >= 2, "overflow:scroll should render track and thumb (got {} fills)", scrollbar_fills.len() ); } #[test] fn overflow_auto_scrollbar_only_when_overflows() { // overflow:auto with content that fits should NOT show scroll bars. let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let scrollbar_fills = list.iter().filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) }); assert_eq!( scrollbar_fills.count(), 0, "overflow:auto should not render scroll bars when content fits" ); } #[test] fn overflow_auto_scrollbar_when_content_overflows() { // overflow:auto with content taller than container should show scroll bars. let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let scrollbar_fills: Vec<_> = list .iter() .filter(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) }) .collect(); assert!( scrollbar_fills.len() >= 2, "overflow:auto should render scroll bars when content overflows (got {} fills)", scrollbar_fills.len() ); } #[test] fn scroll_bar_thumb_proportional() { // Thumb size should be proportional to viewport/content ratio. let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Find the thumb FillRect (SCROLLBAR_THUMB_COLOR). let thumb = list.iter().find(|c| { matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_THUMB_COLOR) }); assert!(thumb.is_some(), "should have a scroll bar thumb"); if let Some(PaintCommand::FillRect { height, .. }) = thumb { // Container height is 100, content is 400. // Thumb ratio = 100/400 = 0.25, track height = 100. // Thumb height = max(0.25 * 100, 20) = 25. assert!( *height >= 20.0 && *height <= 100.0, "thumb height {} should be proportional and within bounds", height ); } } // ----------------------------------------------------------------------- // Sticky positioning paint-time tests // ----------------------------------------------------------------------- #[test] fn sticky_sticks_to_top_when_scrolled() { // A sticky element with top:0 inside a tall container should // stick to the top of the viewport when page-scrolled past it. let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); doc.append_child(root, html); doc.append_child(html, body); // Container tall enough to scroll. let container = doc.create_element("div"); doc.append_child(body, container); doc.set_attribute(container, "style", "width: 400px; height: 1000px;"); // Spacer pushes sticky down. let spacer = doc.create_element("div"); doc.append_child(container, spacer); doc.set_attribute(spacer, "style", "height: 100px;"); // Sticky element. let sticky = doc.create_element("div"); let text = doc.create_text("Sticky"); doc.append_child(container, sticky); doc.append_child(sticky, text); doc.set_attribute( sticky, "style", "position: sticky; top: 0; height: 30px; background-color: red;", ); let tree = layout_doc(&doc); let scroll_state: ScrollState = HashMap::new(); // With no scroll, the display list should show the sticky at its // normal flow position. let list_no_scroll = build_display_list_with_page_scroll(&tree, 0.0, &scroll_state); // Find the red FillRect — that's our sticky background. let sticky_bg_no_scroll = list_no_scroll .iter() .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) .expect("should find sticky red background"); let no_scroll_y = match sticky_bg_no_scroll { PaintCommand::FillRect { y, .. } => *y, _ => unreachable!(), }; // Normal position: body has default 8px margin, spacer is 100px, // so sticky should be around y≈108. assert!( no_scroll_y > 90.0, "without scroll, sticky should be at its normal position, got y={}", no_scroll_y, ); // Now scroll 200px — the sticky element's normal position would be // around 108 - 200 = -92 (off screen), but it should stick at y=0. let list_scrolled = build_display_list_with_page_scroll(&tree, 200.0, &scroll_state); let sticky_bg_scrolled = list_scrolled .iter() .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) .expect("should find sticky red background when scrolled"); let scrolled_y = match sticky_bg_scrolled { PaintCommand::FillRect { y, .. } => *y, _ => unreachable!(), }; // The sticky element should be pinned near y=0 (content box y // accounts for padding/border/margin). assert!( scrolled_y >= -1.0 && scrolled_y < 20.0, "with 200px scroll, sticky should be near top (y≈0), got y={}", scrolled_y, ); } #[test] fn sticky_constrained_by_parent() { // When the containing block scrolls past, the sticky element should // unstick and scroll away with its container. let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); let body = doc.create_element("body"); doc.append_child(root, html); doc.append_child(html, body); // Small container that will scroll off. let container = doc.create_element("div"); doc.append_child(body, container); doc.set_attribute(container, "style", "width: 400px; height: 200px;"); // Sticky element inside. let sticky = doc.create_element("div"); let text = doc.create_text("Sticky"); doc.append_child(container, sticky); doc.append_child(sticky, text); doc.set_attribute( sticky, "style", "position: sticky; top: 0; height: 30px; background-color: green;", ); // After container — more content so page can scroll further. let after = doc.create_element("div"); doc.append_child(body, after); doc.set_attribute(after, "style", "height: 2000px;"); let tree = layout_doc(&doc); let scroll_state: ScrollState = HashMap::new(); // Scroll far enough that the container is completely off-screen. // Container is at roughly y=8 (body margin) with height 200, // so bottom is at y≈208. Scroll by 400 should move it well off screen. let list = build_display_list_with_page_scroll(&tree, 400.0, &scroll_state); let sticky_bg = list.iter().find(|cmd| { matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 0 && color.g == 128 && color.b == 0) }); if let Some(PaintCommand::FillRect { y, .. }) = sticky_bg { // The sticky element should have scrolled off with its container. // Its screen y should be negative (off screen). assert!( y < &0.0, "sticky should be off-screen when container scrolled away, got y={}", y, ); } // If not found, the element might not be painted (which is also // acceptable if it's off-screen). } // ----------------------------------------------------------------------- // Compositing layer tests // ----------------------------------------------------------------------- #[test] fn opacity_element_generates_push_pop_layer() { let html = "
          Hello
          "; let doc = we_html::parse_html(html); let tree = layout_doc(&doc); let list = build_display_list(&tree); // There should be at least one PushLayer with opacity 0.5. let push_count = list .iter() .filter(|cmd| matches!(cmd, PaintCommand::PushLayer { opacity, .. } if *opacity < 1.0)) .count(); assert!(push_count >= 1, "expected PushLayer for opacity < 1.0"); let pop_count = list .iter() .filter(|cmd| matches!(cmd, PaintCommand::PopLayer)) .count(); assert_eq!(push_count, pop_count, "PushLayer/PopLayer must be balanced"); } #[test] fn full_opacity_no_isolation_layer() { // A fully opaque div should not generate PushLayer with opacity < 1.0. let html = "
          Hi
          "; let doc = we_html::parse_html(html); let tree = layout_doc(&doc); let list = build_display_list(&tree); let isolated_layers = list .iter() .filter(|cmd| matches!(cmd, PaintCommand::PushLayer { opacity, .. } if *opacity < 1.0)) .count(); assert_eq!( isolated_layers, 0, "opacity=1.0 should not produce isolated layers" ); } #[test] fn needs_compositing_layer_opacity() { use we_style::computed::ComputedStyle; let mut style = ComputedStyle::default(); style.display = we_style::computed::Display::Block; style.opacity = 0.5; let b = we_layout::LayoutBox::from_style( we_layout::BoxType::Block(NodeId::from_index(0)), &style, ); assert!( needs_compositing_layer(&b), "opacity < 1.0 should need a layer" ); } #[test] fn needs_compositing_layer_will_change() { use we_style::computed::{ComputedStyle, WillChange}; let mut style = ComputedStyle::default(); style.display = we_style::computed::Display::Block; style.will_change = WillChange { transform: true, opacity: false, }; let b = we_layout::LayoutBox::from_style( we_layout::BoxType::Block(NodeId::from_index(0)), &style, ); assert!( needs_compositing_layer(&b), "will-change: transform should need a layer" ); } #[test] fn no_layer_for_normal_element() { use we_style::computed::ComputedStyle; let mut style = ComputedStyle::default(); style.display = we_style::computed::Display::Block; let b = we_layout::LayoutBox::from_style( we_layout::BoxType::Block(NodeId::from_index(0)), &style, ); assert!( !needs_compositing_layer(&b), "normal element should not need a layer" ); } // --- Form control rendering tests --- #[test] fn text_input_renders_with_border_and_background() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Should have FillRect commands (background + border) for the input. let fill_count = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { .. })) .count(); assert!( fill_count >= 5, "text input should have background + 4 border rects, got {fill_count}" ); // Should have a DrawGlyphs for the value text. let has_value_text = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "hello")); assert!( has_value_text, "text input should render its value text 'hello'" ); } #[test] fn appearance_none_text_input_skips_native_chrome() { let html_str = r#"
          "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); assert!( list.iter().any(|cmd| matches!( cmd, PaintCommand::RoundedRect { color, .. } if *color == Color::rgb(255, 255, 255) )), "rounded parent background should still paint" ); assert!( !list.iter().any(|cmd| matches!( cmd, PaintCommand::FillRect { color, width, height, .. } if *color == FC_BG_COLOR && *width >= 100.0 && *height >= 30.0 )), "appearance:none text input should not paint native white field chrome" ); } #[test] fn empty_text_input_renders_placeholder() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let placeholder = list.iter().find_map(|c| match c { PaintCommand::DrawGlyphs { line, .. } if line.text == "you@example.com" => { Some(line.color) } _ => None, }); assert!( matches!(placeholder, Some(FC_PLACEHOLDER_TEXT)), "empty text input should render placeholder text in placeholder color" ); } #[test] fn empty_password_input_renders_placeholder_not_bullets() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_placeholder = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Password")); let has_bullets = list.iter().any(|c| { matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text.contains('\u{2022}')) }); assert!( has_placeholder, "empty password input should render placeholder text" ); assert!( !has_bullets, "empty password input should not render placeholder as bullets" ); } #[test] fn password_input_renders_bullets() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Should render bullet characters instead of actual value. let has_bullets = list.iter().any(|c| { matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "\u{2022}\u{2022}\u{2022}") }); assert!( has_bullets, "password input should render bullet characters" ); // Should NOT contain the actual password text. let has_plain = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "abc")); assert!( !has_plain, "password input should not render plaintext value" ); } #[test] fn checkbox_unchecked_renders_empty_box() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Unchecked checkbox: background + 4 border rects, no checkmark. let fill_count = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { .. })) .count(); assert!( fill_count >= 5, "unchecked checkbox should have background + borders, got {fill_count}" ); // Should not have any visible text (only placeholder empty fragments are allowed). let has_visible_text = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if !line.text.is_empty())); assert!( !has_visible_text, "unchecked checkbox should not have any visible text glyphs" ); } #[test] fn checkbox_checked_renders_checkmark() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Checked checkbox has more FillRects than unchecked (checkmark lines). let fill_count = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { .. })) .count(); assert!( fill_count > 5, "checked checkbox should have extra rects for checkmark, got {fill_count}" ); } #[test] fn radio_unchecked_renders_circle() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Radio button uses scanline circles → many FillRects. let fill_count = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { .. })) .count(); assert!( fill_count >= 5, "radio button should have circle scanline rects, got {fill_count}" ); } #[test] fn radio_checked_renders_dot() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); // Checked radio has more rects (dot circle). let unchecked_html = r#" "#; let unchecked_doc = we_html::parse_html(unchecked_html); let unchecked_tree = layout_doc(&unchecked_doc); let unchecked_list = build_display_list(&unchecked_tree); let checked_fills = list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { .. })) .count(); let unchecked_fills = unchecked_list .iter() .filter(|c| matches!(c, PaintCommand::FillRect { .. })) .count(); assert!( checked_fills > unchecked_fills, "checked radio ({checked_fills} fills) should have more rects than unchecked ({unchecked_fills})" ); } #[test] fn submit_button_renders_label() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_label = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Go")); assert!(has_label, "submit button should render 'Go' label"); } #[test] fn submit_button_default_label() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_label = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Submit")); assert!( has_label, "submit button without value should render 'Submit'" ); } #[test] fn zero_sized_submit_button_does_not_render_label() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_label = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Submit")); assert!(!has_label, "zero-sized submit button should not paint text"); } #[test] fn button_element_renders_label() { let html_str = r#" "#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_label = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Click Me")); assert!(has_label, "button element should render 'Click Me' label"); } #[test] fn icon_only_button_does_not_render_submit_label() { // A `"#; let doc = we_html::parse_html(html_str); let tree = layout_doc(&doc); let list = build_display_list(&tree); let has_label = list .iter() .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Submit")); assert!( !has_label, "icon-only