Something went wrong. Try again.
we (web engine): Experimental web browser project to understand the limits of Claude
Something went wrong. Try again.
Rust
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967//! Display list, software rasterizer, Metal GPU compositor.//!//! Walks a layout tree, generates paint commands, and rasterizes them//! into a BGRA pixel buffer suitable for display via CoreGraphics.
use std::collections::HashMap;
use we_css::values::Color;use we_dom::NodeId;use we_image::pixel::Image;use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH};use we_style::computed::{ BorderStyle, LengthOrAuto, Overflow, Position, TextDecoration, Visibility,};use we_text::font::Font;
/// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets.pub type ScrollState = HashMap<NodeId, (f32, f32)>;
/// 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, }, /// 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, node_id: NodeId, }, /// Push a clip rectangle onto the clip stack. All subsequent paint /// commands are clipped to the intersection of all active clip rects. PushClip { x: f32, y: f32, width: f32, height: f32, }, /// Pop the most recent clip rectangle off the clip stack. PopClip,}
/// A flat list of paint commands in painter's order.pub type DisplayList = Vec<PaintCommand>;
/// 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(); paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state, 0.0); 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(); paint_box( &tree.root, &mut list, (0.0, -page_scroll_y), scroll_state, 0.0, ); list}
/// Returns `true` if a box is positioned (absolute or fixed).fn is_positioned(b: &LayoutBox) -> bool { b.position == Position::Absolute || b.position == Position::Fixed}
fn paint_box( layout_box: &LayoutBox, list: &mut DisplayList, translate: (f32, f32), scroll_state: &ScrollState, sticky_ref_screen_y: f32,) { let visible = layout_box.visibility == Visibility::Visible; let tx = translate.0; let ty = translate.1;
if visible { paint_background(layout_box, list, tx, ty); paint_borders(layout_box, list, tx, ty);
// Emit image paint command for replaced elements. if let Some((rw, rh)) = layout_box.replaced_size { 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: rw, height: rh, node_id, }); } }
paint_text(layout_box, list, tx, ty); }
// 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. let clips = layout_box.overflow != Overflow::Visible; if clips { let clip = padding_box(layout_box); list.push(PaintCommand::PushClip { x: clip.x + tx, y: clip.y + ty, width: clip.width, height: clip.height, }); }
// Compute child translate: adds scroll offset for scrollable boxes. let mut child_translate = translate; // 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) + translate.1; 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<usize> = Vec::new(); let mut non_negative_z: Vec<usize> = 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, ); }
// 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); } }
// 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, ); } } 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); } }
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); }}
/// 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,) { 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); } else { paint_box( child, list, child_translate, scroll_state, sticky_ref_screen_y, ); }}
/// Resolve a `LengthOrAuto` to an optional pixel value for sticky offsets.fn resolve_sticky_px(value: LengthOrAuto, reference: f32) -> Option<f32> { match value { LengthOrAuto::Length(v) => Some(v), LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), 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, }}
/// Extract the NodeId from a BoxType, if it has one.fn node_id_from_box_type(box_type: &BoxType) -> Option<NodeId> { match box_type { BoxType::Block(id) | BoxType::Inline(id) => Some(*id), BoxType::TextRun { node, .. } => Some(*node), BoxType::Anonymous => None, }}
fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bg = layout_box.background_color; // Only paint if the background is not fully transparent and the box has area. if bg.a == 0 { return; } if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { // Background covers the padding box (content + padding), not including border. list.push(PaintCommand::FillRect { x: layout_box.rect.x + tx, y: layout_box.rect.y + ty, width: layout_box.rect.width, height: layout_box.rect.height, color: bg, }); }}
fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 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;
// Top border if b.top > 0.0 && styles[0] != BorderStyle::None && styles[0] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx, y: by, width: bw, height: b.top, color: colors[0], }); } // Right border if b.right > 0.0 && styles[1] != BorderStyle::None && styles[1] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx + bw - b.right, y: by, width: b.right, height: bh, color: colors[1], }); } // Bottom border if b.bottom > 0.0 && styles[2] != BorderStyle::None && styles[2] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx, y: by + bh - b.bottom, width: bw, height: b.bottom, color: colors[2], }); } // Left border if b.left > 0.0 && styles[3] != BorderStyle::None && styles[3] != BorderStyle::Hidden { list.push(PaintCommand::FillRect { x: bx, y: by, width: b.left, height: bh, color: colors[3], }); }}
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;
list.push(PaintCommand::DrawGlyphs { line: translated_line, font_size, color, });
// Draw underline as a 1px line below the baseline. if line.text_decoration == TextDecoration::Underline && line.width > 0.0 { let baseline_y = line.y + ty + font_size; let underline_y = baseline_y + 2.0; list.push(PaintCommand::FillRect { x: line.x + tx, y: underline_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: font_size * 1.2, color: line.background_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, });}
/// An axis-aligned clip rectangle.#[derive(Debug, Clone, Copy)]struct ClipRect { x0: f32, y0: f32, x1: f32, y1: f32,}
impl ClipRect { /// Intersect two clip rects, returning the overlapping region. /// Returns None if they don't overlap. fn intersect(self, other: ClipRect) -> Option<ClipRect> { let x0 = self.x0.max(other.x0); let y0 = self.y0.max(other.y0); let x1 = self.x1.min(other.x1); let y1 = self.y1.min(other.y1); if x0 < x1 && y0 < y1 { Some(ClipRect { x0, y0, x1, y1 }) } else { None } }}
/// Software renderer that paints a display list into a BGRA pixel buffer.pub struct Renderer { width: u32, height: u32, /// BGRA pixel data, row-major, top-to-bottom. buffer: Vec<u8>, /// Stack of clip rectangles. When non-empty, all drawing is clipped to /// the intersection of all active clip rects. clip_stack: Vec<ClipRect>,}
impl Renderer { /// Create a new renderer with the given dimensions. /// The buffer is initialized to white. pub fn new(width: u32, height: u32) -> Renderer { let size = (width as usize) * (height as usize) * 4; let mut buffer = vec![0u8; size]; // Fill with white (BGRA: B=255, G=255, R=255, A=255). for pixel in buffer.chunks_exact_mut(4) { pixel[0] = 255; // B pixel[1] = 255; // G pixel[2] = 255; // R pixel[3] = 255; // A } Renderer { width, height, buffer, clip_stack: Vec::new(), } }
/// Compute the effective clip rect from the clip stack. /// Returns None if the clip stack is empty (no clipping). fn active_clip(&self) -> Option<ClipRect> { if self.clip_stack.is_empty() { return None; } // Start with the full buffer as the initial rect, then intersect. let mut result = ClipRect { x0: 0.0, y0: 0.0, x1: self.width as f32, y1: self.height as f32, }; for clip in &self.clip_stack { match result.intersect(*clip) { Some(r) => result = r, None => { return Some(ClipRect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0, }) } } } Some(result) }
/// Paint a layout tree into the pixel buffer. pub fn paint( &mut self, layout_tree: &LayoutTree, font: &Font, images: &HashMap<NodeId, &Image>, ) { self.paint_with_scroll(layout_tree, font, images, 0.0, &HashMap::new()); }
/// Paint a layout tree with scroll state into the pixel buffer. pub fn paint_with_scroll( &mut self, layout_tree: &LayoutTree, font: &Font, images: &HashMap<NodeId, &Image>, page_scroll_y: f32, scroll_state: &ScrollState, ) { let display_list = build_display_list_with_page_scroll(layout_tree, page_scroll_y, scroll_state); for cmd in &display_list { match cmd { PaintCommand::FillRect { x, y, width, height, color, } => { self.fill_rect(*x, *y, *width, *height, *color); } PaintCommand::DrawGlyphs { line, font_size, color, } => { self.draw_text_line(line, *font_size, *color, font); } PaintCommand::DrawImage { x, y, width, height, node_id, } => { if let Some(image) = images.get(node_id) { self.draw_image(*x, *y, *width, *height, image); } } PaintCommand::PushClip { x, y, width, height, } => { self.clip_stack.push(ClipRect { x0: *x, y0: *y, x1: *x + *width, y1: *y + *height, }); } PaintCommand::PopClip => { self.clip_stack.pop(); } } } }
/// Get the BGRA pixel data. pub fn pixels(&self) -> &[u8] { &self.buffer }
/// Width in pixels. pub fn width(&self) -> u32 { self.width }
/// Height in pixels. pub fn height(&self) -> u32 { self.height }
/// Fill a rectangle with a solid color. pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { let mut fx0 = x; let mut fy0 = y; let mut fx1 = x + width; let mut fy1 = y + height;
// Apply clip rect if active. if let Some(clip) = self.active_clip() { fx0 = fx0.max(clip.x0); fy0 = fy0.max(clip.y0); fx1 = fx1.min(clip.x1); fy1 = fy1.min(clip.y1); if fx0 >= fx1 || fy0 >= fy1 { return; } }
let x0 = (fx0 as i32).max(0) as u32; let y0 = (fy0 as i32).max(0) as u32; let x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; let y1 = (fy1 as i32).max(0).min(self.height as i32) as u32;
if color.a == 255 { // Fully opaque — direct write. for py in y0..y1 { for px in x0..x1 { self.set_pixel(px, py, color); } } } else if color.a > 0 { // Semi-transparent — alpha blend. let alpha = color.a as u32; let inv_alpha = 255 - alpha; for py in y0..y1 { for px in x0..x1 { let offset = ((py * self.width + px) * 4) as usize; let dst_b = self.buffer[offset] as u32; let dst_g = self.buffer[offset + 1] as u32; let dst_r = self.buffer[offset + 2] as u32; self.buffer[offset] = ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; self.buffer[offset + 1] = ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; self.buffer[offset + 2] = ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; self.buffer[offset + 3] = 255; } } } }
/// Draw a line of text using the font to render glyphs. fn draw_text_line(&mut self, line: &TextLine, font_size: f32, color: Color, font: &Font) { let positioned = font.render_text(&line.text, font_size);
for glyph in &positioned { if let Some(ref bitmap) = glyph.bitmap { // Glyph origin: line position + glyph horizontal offset. let gx = line.x + glyph.x + bitmap.bearing_x as f32; // Baseline is at line.y + font_size (approximate: baseline at ~80% of font_size). // bearing_y is distance from baseline to top of glyph (positive = above baseline). let baseline_y = line.y + font_size; let gy = baseline_y - bitmap.bearing_y as f32;
self.composite_glyph(gx, gy, bitmap, color); } } }
/// Composite a grayscale glyph bitmap onto the buffer with anti-aliasing. fn composite_glyph( &mut self, x: f32, y: f32, bitmap: &we_text::font::rasterizer::GlyphBitmap, color: Color, ) { let x0 = x as i32; let y0 = y as i32; let clip = self.active_clip();
for by in 0..bitmap.height { for bx in 0..bitmap.width { let px = x0 + bx as i32; let py = y0 + by as i32;
if px < 0 || py < 0 || px >= self.width as i32 || py >= self.height as i32 { continue; }
// Apply clip rect. if let Some(ref c) = clip { if (px as f32) < c.x0 || (px as f32) >= c.x1 || (py as f32) < c.y0 || (py as f32) >= c.y1 { continue; } }
let coverage = bitmap.data[(by * bitmap.width + bx) as usize]; if coverage == 0 { continue; }
let px = px as u32; let py = py as u32;
// Alpha-blend the glyph coverage with the text color onto the background. let alpha = (coverage as u32 * color.a as u32) / 255; if alpha == 0 { continue; }
let offset = ((py * self.width + px) * 4) as usize; let dst_b = self.buffer[offset] as u32; let dst_g = self.buffer[offset + 1] as u32; let dst_r = self.buffer[offset + 2] as u32;
// Source-over compositing. let inv_alpha = 255 - alpha; self.buffer[offset] = ((color.b as u32 * alpha + dst_b * inv_alpha) / 255) as u8; self.buffer[offset + 1] = ((color.g as u32 * alpha + dst_g * inv_alpha) / 255) as u8; self.buffer[offset + 2] = ((color.r as u32 * alpha + dst_r * inv_alpha) / 255) as u8; self.buffer[offset + 3] = 255; // Fully opaque. } } }
/// Draw an RGBA8 image scaled to the given display dimensions. /// /// Uses nearest-neighbor sampling for scaling. The source image is in RGBA8 /// format; the buffer is BGRA. Alpha compositing is performed for semi-transparent pixels. fn draw_image(&mut self, x: f32, y: f32, width: f32, height: f32, image: &Image) { if image.width == 0 || image.height == 0 || width <= 0.0 || height <= 0.0 { return; }
let mut fx0 = x; let mut fy0 = y; let mut fx1 = x + width; let mut fy1 = y + height;
if let Some(clip) = self.active_clip() { fx0 = fx0.max(clip.x0); fy0 = fy0.max(clip.y0); fx1 = fx1.min(clip.x1); fy1 = fy1.min(clip.y1); if fx0 >= fx1 || fy0 >= fy1 { return; } }
let dst_x0 = (fx0 as i32).max(0) as u32; let dst_y0 = (fy0 as i32).max(0) as u32; let dst_x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; let dst_y1 = (fy1 as i32).max(0).min(self.height as i32) as u32;
let scale_x = image.width as f32 / width; let scale_y = image.height as f32 / height;
for dst_y in dst_y0..dst_y1 { let src_y = ((dst_y as f32 - y) * scale_y) as u32; let src_y = src_y.min(image.height - 1);
for dst_x in dst_x0..dst_x1 { let src_x = ((dst_x as f32 - x) * scale_x) as u32; let src_x = src_x.min(image.width - 1);
let src_offset = ((src_y * image.width + src_x) * 4) as usize; let r = image.data[src_offset] as u32; let g = image.data[src_offset + 1] as u32; let b = image.data[src_offset + 2] as u32; let a = image.data[src_offset + 3] as u32;
if a == 0 { continue; }
let dst_offset = ((dst_y * self.width + dst_x) * 4) as usize;
if a == 255 { // Fully opaque — direct write (RGBA → BGRA). self.buffer[dst_offset] = b as u8; self.buffer[dst_offset + 1] = g as u8; self.buffer[dst_offset + 2] = r as u8; self.buffer[dst_offset + 3] = 255; } else { // Alpha blend. let inv_a = 255 - a; let dst_b = self.buffer[dst_offset] as u32; let dst_g = self.buffer[dst_offset + 1] as u32; let dst_r = self.buffer[dst_offset + 2] as u32; self.buffer[dst_offset] = ((b * a + dst_b * inv_a) / 255) as u8; self.buffer[dst_offset + 1] = ((g * a + dst_g * inv_a) / 255) as u8; self.buffer[dst_offset + 2] = ((r * a + dst_r * inv_a) / 255) as u8; self.buffer[dst_offset + 3] = 255; } } } }
/// Set a single pixel to the given color (no blending). fn set_pixel(&mut self, x: u32, y: u32, color: Color) { if x >= self.width || y >= self.height { return; } let offset = ((y * self.width + x) * 4) as usize; self.buffer[offset] = color.b; // B self.buffer[offset + 1] = color.g; // G self.buffer[offset + 2] = color.r; // R self.buffer[offset + 3] = color.a; // A }}
#[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 renderer_new_white_background() { let r = Renderer::new(10, 10); assert_eq!(r.width(), 10); assert_eq!(r.height(), 10); assert_eq!(r.pixels().len(), 10 * 10 * 4); // All pixels should be white (BGRA: 255, 255, 255, 255). for pixel in r.pixels().chunks_exact(4) { assert_eq!(pixel, [255, 255, 255, 255]); } }
#[test] fn fill_rect_basic() { let mut r = Renderer::new(20, 20); let red = Color::new(255, 0, 0, 255); r.fill_rect(5.0, 5.0, 10.0, 10.0, red);
// Pixel at (7, 7) should be red (BGRA: 0, 0, 255, 255). let offset = ((7 * 20 + 7) * 4) as usize; assert_eq!(r.pixels()[offset], 0); // B assert_eq!(r.pixels()[offset + 1], 0); // G assert_eq!(r.pixels()[offset + 2], 255); // R assert_eq!(r.pixels()[offset + 3], 255); // A
// Pixel at (0, 0) should still be white. assert_eq!(r.pixels()[0], 255); // B assert_eq!(r.pixels()[1], 255); // G assert_eq!(r.pixels()[2], 255); // R assert_eq!(r.pixels()[3], 255); // A }
#[test] fn fill_rect_clipping() { let mut r = Renderer::new(10, 10); let blue = Color::new(0, 0, 255, 255); // Rect extends beyond the buffer — should not panic. r.fill_rect(-5.0, -5.0, 20.0, 20.0, blue);
// All pixels should be blue (BGRA: 255, 0, 0, 255). for pixel in r.pixels().chunks_exact(4) { assert_eq!(pixel, [255, 0, 0, 255]); } }
#[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 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 paint_simple_page() { 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"); doc.append_child(root, html); doc.append_child(html, body); doc.append_child(body, p); doc.append_child(p, text);
let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(800, 600); renderer.paint(&tree, &font, &HashMap::new());
let pixels = renderer.pixels();
// The buffer should have some non-white pixels (from text rendering). let has_non_white = pixels.chunks_exact(4).any(|p| p != [255, 255, 255, 255]); assert!( has_non_white, "rendered page should have non-white pixels from text" ); }
#[test] fn bgra_format_correct() { let mut r = Renderer::new(1, 1); let color = Color::new(100, 150, 200, 255); r.set_pixel(0, 0, color); let pixels = r.pixels(); // BGRA format. assert_eq!(pixels[0], 200); // B assert_eq!(pixels[1], 150); // G assert_eq!(pixels[2], 100); // R assert_eq!(pixels[3], 255); // A }
#[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<f32> = 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 renderer_zero_size() { let r = Renderer::new(0, 0); assert_eq!(r.pixels().len(), 0); }
#[test] fn glyph_compositing_anti_aliased() { // Render text and verify we get anti-aliased (partially transparent) pixels. 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("MMMMM"); doc.append_child(root, html); doc.append_child(html, body); doc.append_child(body, p); doc.append_child(p, text);
let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(800, 600); renderer.paint(&tree, &font, &HashMap::new());
let pixels = renderer.pixels(); // Find pixels that are not pure white and not pure black. // These represent anti-aliased edges of glyphs. let mut has_intermediate = false; for pixel in pixels.chunks_exact(4) { let b = pixel[0]; let g = pixel[1]; let r = pixel[2]; // Anti-aliased pixel: gray between white and black. if r > 0 && r < 255 && r == g && g == b { has_intermediate = true; break; } } assert!( has_intermediate, "should have anti-aliased (gray) pixels from glyph compositing" ); }
#[test] fn css_color_renders_correctly() { let html_str = r#"<!DOCTYPE html><html><head><style>p { color: red; }</style></head><body><p>Red text</p></body></html>"#; 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_background_color_renders() { let html_str = r#"<!DOCTYPE html><html><head><style>div { background-color: yellow; }</style></head><body><div>Content</div></body></html>"#; 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 border_rendering() { let html_str = r#"<!DOCTYPE html><html><head><style>div { border: 2px solid red; }</style></head><body><div>Bordered</div></body></html>"#; 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"); }
// --- Visibility painting tests ---
#[test] fn visibility_hidden_not_painted() { let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }div { visibility: hidden; background-color: red; width: 100px; height: 100px; }</style></head><body><div>Hidden</div></body></html>"#; 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#"<!DOCTYPE html><html><head><style>body { margin: 0; }.parent { visibility: hidden; }.child { visibility: visible; }</style></head><body><div class="parent"><p class="child">Visible</p></div></body></html>"#; 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#"<!DOCTYPE html><html><head><style>body { margin: 0; }div { visibility: collapse; background-color: blue; width: 50px; height: 50px; }</style></head><body><div>Collapsed</div></body></html>"#; 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#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: hidden; width: 100px; height: 50px; }</style></head><body><div class="container"><p>Content</p></div></body></html>"#; 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"); }
#[test] fn overflow_visible_no_clip_commands() { let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: visible; width: 100px; height: 50px; }</style></head><body><div class="container"><p>Content</p></div></body></html>"#; 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_child_background() { // A tall child inside a short overflow:hidden container. // The child's red background should be clipped to the container's bounds. let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: hidden; width: 100px; height: 50px; }.child { background-color: red; width: 100px; height: 200px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(200, 200); renderer.paint(&tree, &font, &HashMap::new());
let pixels = renderer.pixels();
// Pixel at (50, 25) — inside the container — should be red. let inside_offset = ((25 * 200 + 50) * 4) as usize; assert_eq!(pixels[inside_offset], 0, "B inside should be 0 (red)"); assert_eq!( pixels[inside_offset + 2], 255, "R inside should be 255 (red)" );
// Pixel at (50, 100) — outside the container (below 50px) — should be white. let outside_offset = ((100 * 200 + 50) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "B outside should be 255 (white)" ); assert_eq!( pixels[outside_offset + 1], 255, "G outside should be 255 (white)" ); assert_eq!( pixels[outside_offset + 2], 255, "R outside should be 255 (white)" ); }
#[test] fn overflow_auto_clips_like_hidden() { let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: auto; width: 100px; height: 50px; }.child { background-color: blue; width: 100px; height: 200px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(200, 200); renderer.paint(&tree, &font, &HashMap::new());
let pixels = renderer.pixels();
// Pixel at (50, 100) — below the container — should be white (clipped). let outside_offset = ((100 * 200 + 50) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "overflow:auto should clip content below container" ); }
#[test] fn overflow_scroll_clips_like_hidden() { let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: scroll; width: 100px; height: 50px; }.child { background-color: green; width: 100px; height: 200px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(200, 200); renderer.paint(&tree, &font, &HashMap::new());
let pixels = renderer.pixels();
// Pixel at (50, 100) — below the container — should be white (clipped). let outside_offset = ((100 * 200 + 50) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "overflow:scroll should clip content below container" ); }
#[test] fn nested_overflow_clips_intersect() { // Outer: 200x200, inner: 100x100, both overflow:hidden. // A large red child should only appear in the inner 100x100 area. let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.outer { overflow: hidden; width: 200px; height: 200px; }.inner { overflow: hidden; width: 100px; height: 100px; }.child { background-color: red; width: 500px; height: 500px; }</style></head><body><div class="outer"><div class="inner"><div class="child"></div></div></div></body></html>"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc); let mut renderer = Renderer::new(300, 300); renderer.paint(&tree, &font, &HashMap::new());
let pixels = renderer.pixels();
// Pixel at (50, 50) — inside inner container — should be red. let inside_offset = ((50 * 300 + 50) * 4) as usize; assert_eq!(pixels[inside_offset + 2], 255, "should be red inside inner");
// Pixel at (150, 50) — inside outer but outside inner — should be white. let between_offset = ((50 * 300 + 150) * 4) as usize; assert_eq!( pixels[between_offset], 255, "should be white outside inner clip" );
// Pixel at (250, 50) — outside both — should be white. let outside_offset = ((50 * 300 + 250) * 4) as usize; assert_eq!( pixels[outside_offset], 255, "should be white outside both clips" ); }
#[test] fn clip_rect_intersect_basic() { let a = ClipRect { x0: 0.0, y0: 0.0, x1: 100.0, y1: 100.0, }; let b = ClipRect { x0: 50.0, y0: 50.0, x1: 150.0, y1: 150.0, }; let c = a.intersect(b).unwrap(); assert_eq!(c.x0, 50.0); assert_eq!(c.y0, 50.0); assert_eq!(c.x1, 100.0); assert_eq!(c.y1, 100.0); }
#[test] fn clip_rect_no_overlap() { let a = ClipRect { x0: 0.0, y0: 0.0, x1: 50.0, y1: 50.0, }; let b = ClipRect { x0: 100.0, y0: 100.0, x1: 200.0, y1: 200.0, }; assert!(a.intersect(b).is_none()); }
#[test] fn display_none_not_in_display_list() { let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.gone { display: none; background-color: green; }</style></head><body><p>Visible</p><div class="gone">Gone</div></body></html>"#; 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#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: scroll; width: 200px; height: 200px; }.child { height: 50px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; 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#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: auto; width: 200px; height: 200px; }.child { height: 50px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; 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#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: auto; width: 200px; height: 100px; }.child { background-color: red; height: 500px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; 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_offset_shifts_content() { // Scrolling should shift content within a scroll container. let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: scroll; width: 200px; height: 100px; }.child { background-color: red; width: 200px; height: 500px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc);
// Find the container's NodeId to set scroll offset. let container_id = tree .root .iter() .find_map(|b| { if b.overflow == Overflow::Scroll { node_id_from_box_type(&b.box_type) } else { None } }) .expect("should find scroll container");
// Without scroll: pixel at (50, 50) should be red (inside child). let mut renderer = Renderer::new(300, 300); renderer.paint(&tree, &font, &HashMap::new()); let pixels = renderer.pixels(); let offset_50_50 = ((50 * 300 + 50) * 4) as usize; assert_eq!( pixels[offset_50_50 + 2], 255, "before scroll: (50,50) should be red" );
// With scroll offset 60px: content shifts up, so pixel at (50, 50) should // still be red (content extends to 500px), but pixel at (50, 0) should now // show content that was at y=60. let mut scroll_state = HashMap::new(); scroll_state.insert(container_id, (0.0, 60.0)); let mut renderer2 = Renderer::new(300, 300); renderer2.paint_with_scroll(&tree, &font, &HashMap::new(), 0.0, &scroll_state); let pixels2 = renderer2.pixels();
// After scrolling 60px, content starts at y=-60 in the viewport. // The container clips to its bounds, so pixel at (50, 50) is still visible // red content (it was at y=110 in original content, now at y=50). assert_eq!( pixels2[offset_50_50 + 2], 255, "after scroll: (50,50) should still be red (content is tall)" ); }
#[test] fn scroll_bar_thumb_proportional() { // Thumb size should be proportional to viewport/content ratio. let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.container { overflow: scroll; width: 200px; height: 100px; }.child { height: 400px; }</style></head><body><div class="container"><div class="child"></div></div></body></html>"#; 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 ); } }
#[test] fn page_scroll_shifts_all_content() { let html_str = r#"<!DOCTYPE html><html><head><style>body { margin: 0; }.block { background-color: red; width: 100px; height: 100px; }</style></head><body><div class="block"></div></body></html>"#; let doc = we_html::parse_html(html_str); let font = test_font(); let tree = layout_doc(&doc);
// Without page scroll: red at (50, 50). let mut r1 = Renderer::new(200, 200); r1.paint(&tree, &font, &HashMap::new()); let offset = ((50 * 200 + 50) * 4) as usize; assert_eq!(r1.pixels()[offset + 2], 255, "should be red without scroll");
// With page scroll 80px: the red block shifts up by 80px. // Pixel at (50, 50) was at y=130 in content, which is beyond the 100px block. let mut r2 = Renderer::new(200, 200); r2.paint_with_scroll(&tree, &font, &HashMap::new(), 80.0, &HashMap::new()); let pixels2 = r2.pixels(); // At (50, 50) with 80px scroll: this shows content at y=130, which is white. assert_eq!( pixels2[offset], 255, "should be white at (50,50) with 80px page scroll" );
// Pixel at (50, 10) with 80px scroll shows content at y=90, which is still red. let offset_10 = ((10 * 200 + 50) * 4) as usize; assert_eq!( pixels2[offset_10 + 2], 255, "should be red at (50,10) with 80px page scroll" ); }
// ----------------------------------------------------------------------- // 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). }}