we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement overflow clipping (overflow: hidden/auto/scroll)

Add clip rect support to the rendering pipeline so that boxes with
overflow != visible clip their children's content to the padding box.

Render crate changes:
- Add PushClip/PopClip variants to PaintCommand
- Maintain a clip rect stack in Renderer that intersects nested clips
- Apply active clip rect in fill_rect, composite_glyph, and draw_image
- overflow:hidden, overflow:auto, and overflow:scroll all clip content
- overflow:visible (default) continues to render without clipping

Layout crate fix:
- Fix is_empty_block to not treat blocks with explicit CSS height as
empty (they should not self-collapse per CSS2 §8.3.1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+393 -11
+4 -1
crates/layout/src/lib.rs
··· 622 622 623 623 /// Returns `true` if a block box has no in-flow content (empty block). 624 624 fn is_empty_block(b: &LayoutBox) -> bool { 625 - b.children.is_empty() && b.lines.is_empty() && b.replaced_size.is_none() 625 + b.children.is_empty() 626 + && b.lines.is_empty() 627 + && b.replaced_size.is_none() 628 + && matches!(b.css_height, LengthOrAuto::Auto) 626 629 } 627 630 628 631 /// Pre-collapse parent-child margins (CSS2 §8.3.1).
+389 -10
crates/render/src/lib.rs
··· 8 8 use we_css::values::Color; 9 9 use we_dom::NodeId; 10 10 use we_image::pixel::Image; 11 - use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine}; 12 - use we_style::computed::{BorderStyle, TextDecoration, Visibility}; 11 + use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine}; 12 + use we_style::computed::{BorderStyle, Overflow, TextDecoration, Visibility}; 13 13 use we_text::font::Font; 14 14 15 15 /// A paint command in the display list. ··· 37 37 height: f32, 38 38 node_id: NodeId, 39 39 }, 40 + /// Push a clip rectangle onto the clip stack. All subsequent paint 41 + /// commands are clipped to the intersection of all active clip rects. 42 + PushClip { 43 + x: f32, 44 + y: f32, 45 + width: f32, 46 + height: f32, 47 + }, 48 + /// Pop the most recent clip rectangle off the clip stack. 49 + PopClip, 40 50 } 41 51 42 52 /// A flat list of paint commands in painter's order. ··· 75 85 paint_text(layout_box, list); 76 86 } 77 87 88 + // If this box has overflow clipping, push a clip rect for the padding box. 89 + let clips = layout_box.overflow != Overflow::Visible; 90 + if clips { 91 + let clip = padding_box(layout_box); 92 + list.push(PaintCommand::PushClip { 93 + x: clip.x, 94 + y: clip.y, 95 + width: clip.width, 96 + height: clip.height, 97 + }); 98 + } 99 + 78 100 // Always recurse into children — they may override visibility. 79 101 for child in &layout_box.children { 80 102 paint_box(child, list); 103 + } 104 + 105 + if clips { 106 + list.push(PaintCommand::PopClip); 107 + } 108 + } 109 + 110 + /// Compute the padding box rectangle for a layout box. 111 + /// The padding box is the content area expanded by padding. 112 + fn padding_box(layout_box: &LayoutBox) -> Rect { 113 + Rect { 114 + x: layout_box.rect.x - layout_box.padding.left, 115 + y: layout_box.rect.y - layout_box.padding.top, 116 + width: layout_box.rect.width + layout_box.padding.left + layout_box.padding.right, 117 + height: layout_box.rect.height + layout_box.padding.top + layout_box.padding.bottom, 81 118 } 82 119 } 83 120 ··· 206 243 } 207 244 } 208 245 246 + /// An axis-aligned clip rectangle. 247 + #[derive(Debug, Clone, Copy)] 248 + struct ClipRect { 249 + x0: f32, 250 + y0: f32, 251 + x1: f32, 252 + y1: f32, 253 + } 254 + 255 + impl ClipRect { 256 + /// Intersect two clip rects, returning the overlapping region. 257 + /// Returns None if they don't overlap. 258 + fn intersect(self, other: ClipRect) -> Option<ClipRect> { 259 + let x0 = self.x0.max(other.x0); 260 + let y0 = self.y0.max(other.y0); 261 + let x1 = self.x1.min(other.x1); 262 + let y1 = self.y1.min(other.y1); 263 + if x0 < x1 && y0 < y1 { 264 + Some(ClipRect { x0, y0, x1, y1 }) 265 + } else { 266 + None 267 + } 268 + } 269 + } 270 + 209 271 /// Software renderer that paints a display list into a BGRA pixel buffer. 210 272 pub struct Renderer { 211 273 width: u32, 212 274 height: u32, 213 275 /// BGRA pixel data, row-major, top-to-bottom. 214 276 buffer: Vec<u8>, 277 + /// Stack of clip rectangles. When non-empty, all drawing is clipped to 278 + /// the intersection of all active clip rects. 279 + clip_stack: Vec<ClipRect>, 215 280 } 216 281 217 282 impl Renderer { ··· 231 296 width, 232 297 height, 233 298 buffer, 299 + clip_stack: Vec::new(), 234 300 } 235 301 } 236 302 303 + /// Compute the effective clip rect from the clip stack. 304 + /// Returns None if the clip stack is empty (no clipping). 305 + fn active_clip(&self) -> Option<ClipRect> { 306 + if self.clip_stack.is_empty() { 307 + return None; 308 + } 309 + // Start with the full buffer as the initial rect, then intersect. 310 + let mut result = ClipRect { 311 + x0: 0.0, 312 + y0: 0.0, 313 + x1: self.width as f32, 314 + y1: self.height as f32, 315 + }; 316 + for clip in &self.clip_stack { 317 + match result.intersect(*clip) { 318 + Some(r) => result = r, 319 + None => { 320 + return Some(ClipRect { 321 + x0: 0.0, 322 + y0: 0.0, 323 + x1: 0.0, 324 + y1: 0.0, 325 + }) 326 + } 327 + } 328 + } 329 + Some(result) 330 + } 331 + 237 332 /// Paint a layout tree into the pixel buffer. 238 333 pub fn paint( 239 334 &mut self, ··· 271 366 self.draw_image(*x, *y, *width, *height, image); 272 367 } 273 368 } 369 + PaintCommand::PushClip { 370 + x, 371 + y, 372 + width, 373 + height, 374 + } => { 375 + self.clip_stack.push(ClipRect { 376 + x0: *x, 377 + y0: *y, 378 + x1: *x + *width, 379 + y1: *y + *height, 380 + }); 381 + } 382 + PaintCommand::PopClip => { 383 + self.clip_stack.pop(); 384 + } 274 385 } 275 386 } 276 387 } ··· 292 403 293 404 /// Fill a rectangle with a solid color. 294 405 pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { 295 - let x0 = (x as i32).max(0) as u32; 296 - let y0 = (y as i32).max(0) as u32; 297 - let x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; 298 - let y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; 406 + let mut fx0 = x; 407 + let mut fy0 = y; 408 + let mut fx1 = x + width; 409 + let mut fy1 = y + height; 410 + 411 + // Apply clip rect if active. 412 + if let Some(clip) = self.active_clip() { 413 + fx0 = fx0.max(clip.x0); 414 + fy0 = fy0.max(clip.y0); 415 + fx1 = fx1.min(clip.x1); 416 + fy1 = fy1.min(clip.y1); 417 + if fx0 >= fx1 || fy0 >= fy1 { 418 + return; 419 + } 420 + } 421 + 422 + let x0 = (fx0 as i32).max(0) as u32; 423 + let y0 = (fy0 as i32).max(0) as u32; 424 + let x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; 425 + let y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; 299 426 300 427 if color.a == 255 { 301 428 // Fully opaque — direct write. ··· 354 481 ) { 355 482 let x0 = x as i32; 356 483 let y0 = y as i32; 484 + let clip = self.active_clip(); 357 485 358 486 for by in 0..bitmap.height { 359 487 for bx in 0..bitmap.width { ··· 364 492 continue; 365 493 } 366 494 495 + // Apply clip rect. 496 + if let Some(ref c) = clip { 497 + if (px as f32) < c.x0 498 + || (px as f32) >= c.x1 499 + || (py as f32) < c.y0 500 + || (py as f32) >= c.y1 501 + { 502 + continue; 503 + } 504 + } 505 + 367 506 let coverage = bitmap.data[(by * bitmap.width + bx) as usize]; 368 507 if coverage == 0 { 369 508 continue; ··· 404 543 return; 405 544 } 406 545 407 - let dst_x0 = (x as i32).max(0) as u32; 408 - let dst_y0 = (y as i32).max(0) as u32; 409 - let dst_x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; 410 - let dst_y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; 546 + let mut fx0 = x; 547 + let mut fy0 = y; 548 + let mut fx1 = x + width; 549 + let mut fy1 = y + height; 550 + 551 + if let Some(clip) = self.active_clip() { 552 + fx0 = fx0.max(clip.x0); 553 + fy0 = fy0.max(clip.y0); 554 + fx1 = fx1.min(clip.x1); 555 + fy1 = fy1.min(clip.y1); 556 + if fx0 >= fx1 || fy0 >= fy1 { 557 + return; 558 + } 559 + } 560 + 561 + let dst_x0 = (fx0 as i32).max(0) as u32; 562 + let dst_y0 = (fy0 as i32).max(0) as u32; 563 + let dst_x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; 564 + let dst_y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; 411 565 412 566 let scale_x = image.width as f32 / width; 413 567 let scale_y = image.height as f32 / height; ··· 889 1043 0, 890 1044 "collapse element should not be painted" 891 1045 ); 1046 + } 1047 + 1048 + // --- Overflow clipping tests --- 1049 + 1050 + #[test] 1051 + fn overflow_hidden_generates_clip_commands() { 1052 + let html_str = r#"<!DOCTYPE html> 1053 + <html><head><style> 1054 + body { margin: 0; } 1055 + .container { overflow: hidden; width: 100px; height: 50px; } 1056 + </style></head> 1057 + <body><div class="container"><p>Content</p></div></body></html>"#; 1058 + let doc = we_html::parse_html(html_str); 1059 + let tree = layout_doc(&doc); 1060 + let list = build_display_list(&tree); 1061 + 1062 + let push_count = list 1063 + .iter() 1064 + .filter(|c| matches!(c, PaintCommand::PushClip { .. })) 1065 + .count(); 1066 + let pop_count = list 1067 + .iter() 1068 + .filter(|c| matches!(c, PaintCommand::PopClip)) 1069 + .count(); 1070 + 1071 + assert!(push_count >= 1, "overflow:hidden should emit PushClip"); 1072 + assert_eq!(push_count, pop_count, "PushClip/PopClip must be balanced"); 1073 + } 1074 + 1075 + #[test] 1076 + fn overflow_visible_no_clip_commands() { 1077 + let html_str = r#"<!DOCTYPE html> 1078 + <html><head><style> 1079 + body { margin: 0; } 1080 + .container { overflow: visible; width: 100px; height: 50px; } 1081 + </style></head> 1082 + <body><div class="container"><p>Content</p></div></body></html>"#; 1083 + let doc = we_html::parse_html(html_str); 1084 + let tree = layout_doc(&doc); 1085 + let list = build_display_list(&tree); 1086 + 1087 + let push_count = list 1088 + .iter() 1089 + .filter(|c| matches!(c, PaintCommand::PushClip { .. })) 1090 + .count(); 1091 + 1092 + assert_eq!( 1093 + push_count, 0, 1094 + "overflow:visible should not emit clip commands" 1095 + ); 1096 + } 1097 + 1098 + #[test] 1099 + fn overflow_hidden_clips_child_background() { 1100 + // A tall child inside a short overflow:hidden container. 1101 + // The child's red background should be clipped to the container's bounds. 1102 + let html_str = r#"<!DOCTYPE html> 1103 + <html><head><style> 1104 + body { margin: 0; } 1105 + .container { overflow: hidden; width: 100px; height: 50px; } 1106 + .child { background-color: red; width: 100px; height: 200px; } 1107 + </style></head> 1108 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1109 + let doc = we_html::parse_html(html_str); 1110 + let font = test_font(); 1111 + let tree = layout_doc(&doc); 1112 + let mut renderer = Renderer::new(200, 200); 1113 + renderer.paint(&tree, &font, &HashMap::new()); 1114 + 1115 + let pixels = renderer.pixels(); 1116 + 1117 + // Pixel at (50, 25) — inside the container — should be red. 1118 + let inside_offset = ((25 * 200 + 50) * 4) as usize; 1119 + assert_eq!(pixels[inside_offset], 0, "B inside should be 0 (red)"); 1120 + assert_eq!( 1121 + pixels[inside_offset + 2], 1122 + 255, 1123 + "R inside should be 255 (red)" 1124 + ); 1125 + 1126 + // Pixel at (50, 100) — outside the container (below 50px) — should be white. 1127 + let outside_offset = ((100 * 200 + 50) * 4) as usize; 1128 + assert_eq!( 1129 + pixels[outside_offset], 255, 1130 + "B outside should be 255 (white)" 1131 + ); 1132 + assert_eq!( 1133 + pixels[outside_offset + 1], 1134 + 255, 1135 + "G outside should be 255 (white)" 1136 + ); 1137 + assert_eq!( 1138 + pixels[outside_offset + 2], 1139 + 255, 1140 + "R outside should be 255 (white)" 1141 + ); 1142 + } 1143 + 1144 + #[test] 1145 + fn overflow_auto_clips_like_hidden() { 1146 + let html_str = r#"<!DOCTYPE html> 1147 + <html><head><style> 1148 + body { margin: 0; } 1149 + .container { overflow: auto; width: 100px; height: 50px; } 1150 + .child { background-color: blue; width: 100px; height: 200px; } 1151 + </style></head> 1152 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1153 + let doc = we_html::parse_html(html_str); 1154 + let font = test_font(); 1155 + let tree = layout_doc(&doc); 1156 + let mut renderer = Renderer::new(200, 200); 1157 + renderer.paint(&tree, &font, &HashMap::new()); 1158 + 1159 + let pixels = renderer.pixels(); 1160 + 1161 + // Pixel at (50, 100) — below the container — should be white (clipped). 1162 + let outside_offset = ((100 * 200 + 50) * 4) as usize; 1163 + assert_eq!( 1164 + pixels[outside_offset], 255, 1165 + "overflow:auto should clip content below container" 1166 + ); 1167 + } 1168 + 1169 + #[test] 1170 + fn overflow_scroll_clips_like_hidden() { 1171 + let html_str = r#"<!DOCTYPE html> 1172 + <html><head><style> 1173 + body { margin: 0; } 1174 + .container { overflow: scroll; width: 100px; height: 50px; } 1175 + .child { background-color: green; width: 100px; height: 200px; } 1176 + </style></head> 1177 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1178 + let doc = we_html::parse_html(html_str); 1179 + let font = test_font(); 1180 + let tree = layout_doc(&doc); 1181 + let mut renderer = Renderer::new(200, 200); 1182 + renderer.paint(&tree, &font, &HashMap::new()); 1183 + 1184 + let pixels = renderer.pixels(); 1185 + 1186 + // Pixel at (50, 100) — below the container — should be white (clipped). 1187 + let outside_offset = ((100 * 200 + 50) * 4) as usize; 1188 + assert_eq!( 1189 + pixels[outside_offset], 255, 1190 + "overflow:scroll should clip content below container" 1191 + ); 1192 + } 1193 + 1194 + #[test] 1195 + fn nested_overflow_clips_intersect() { 1196 + // Outer: 200x200, inner: 100x100, both overflow:hidden. 1197 + // A large red child should only appear in the inner 100x100 area. 1198 + let html_str = r#"<!DOCTYPE html> 1199 + <html><head><style> 1200 + body { margin: 0; } 1201 + .outer { overflow: hidden; width: 200px; height: 200px; } 1202 + .inner { overflow: hidden; width: 100px; height: 100px; } 1203 + .child { background-color: red; width: 500px; height: 500px; } 1204 + </style></head> 1205 + <body> 1206 + <div class="outer"><div class="inner"><div class="child"></div></div></div> 1207 + </body></html>"#; 1208 + let doc = we_html::parse_html(html_str); 1209 + let font = test_font(); 1210 + let tree = layout_doc(&doc); 1211 + let mut renderer = Renderer::new(300, 300); 1212 + renderer.paint(&tree, &font, &HashMap::new()); 1213 + 1214 + let pixels = renderer.pixels(); 1215 + 1216 + // Pixel at (50, 50) — inside inner container — should be red. 1217 + let inside_offset = ((50 * 300 + 50) * 4) as usize; 1218 + assert_eq!(pixels[inside_offset + 2], 255, "should be red inside inner"); 1219 + 1220 + // Pixel at (150, 50) — inside outer but outside inner — should be white. 1221 + let between_offset = ((50 * 300 + 150) * 4) as usize; 1222 + assert_eq!( 1223 + pixels[between_offset], 255, 1224 + "should be white outside inner clip" 1225 + ); 1226 + 1227 + // Pixel at (250, 50) — outside both — should be white. 1228 + let outside_offset = ((50 * 300 + 250) * 4) as usize; 1229 + assert_eq!( 1230 + pixels[outside_offset], 255, 1231 + "should be white outside both clips" 1232 + ); 1233 + } 1234 + 1235 + #[test] 1236 + fn clip_rect_intersect_basic() { 1237 + let a = ClipRect { 1238 + x0: 0.0, 1239 + y0: 0.0, 1240 + x1: 100.0, 1241 + y1: 100.0, 1242 + }; 1243 + let b = ClipRect { 1244 + x0: 50.0, 1245 + y0: 50.0, 1246 + x1: 150.0, 1247 + y1: 150.0, 1248 + }; 1249 + let c = a.intersect(b).unwrap(); 1250 + assert_eq!(c.x0, 50.0); 1251 + assert_eq!(c.y0, 50.0); 1252 + assert_eq!(c.x1, 100.0); 1253 + assert_eq!(c.y1, 100.0); 1254 + } 1255 + 1256 + #[test] 1257 + fn clip_rect_no_overlap() { 1258 + let a = ClipRect { 1259 + x0: 0.0, 1260 + y0: 0.0, 1261 + x1: 50.0, 1262 + y1: 50.0, 1263 + }; 1264 + let b = ClipRect { 1265 + x0: 100.0, 1266 + y0: 100.0, 1267 + x1: 200.0, 1268 + y1: 200.0, 1269 + }; 1270 + assert!(a.intersect(b).is_none()); 892 1271 } 893 1272 894 1273 #[test]