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 compositing layers for transforms, opacity, will-change

Add a layer tree that renders subtrees to intermediate Metal textures for
correct group compositing of opacity, will-change, fixed-position, and
scrollable elements.

CSS properties:
- Add `opacity` (0.0-1.0, clamped) to ComputedStyle, LayoutBox
- Add `will-change` (transform, opacity) to ComputedStyle, LayoutBox
- Parse both properties from CSS declarations

Display list:
- Add PushLayer/PopLayer paint commands wrapping elements that need
compositing isolation
- Detect layer-needing elements: opacity < 1.0, will-change hints,
position: fixed, scrollable overflow containers

Metal GPU renderer:
- Render-to-texture for layers with opacity < 1.0: create offscreen
BGRA render target, render layer contents, composite back with opacity
- Layers with opacity=1.0 are pass-through (no intermediate texture)
- Handle nested layers via a layer state stack
- Coordinate offset tracking for layer-local rendering
- Graceful fallback if render target creation fails

Platform (Metal FFI):
- Device::new_render_target_texture() with ShaderRead | RenderTarget usage
- make_load_pass_descriptor() for resuming rendering to existing targets

Software renderer:
- Buffer-swap compositing for opacity layers
- Alpha-blend layer buffer back with group opacity on PopLayer

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

+909 -107
+12 -1
crates/layout/src/lib.rs
··· 10 10 use we_style::computed::{ 11 11 AlignContent, AlignItems, AlignSelf, BorderStyle, BoxSizing, Clear, ComputedStyle, Display, 12 12 FlexDirection, FlexWrap, Float, JustifyContent, LengthOrAuto, Overflow, Position, StyledNode, 13 - TextAlign, TextDecoration, Visibility, 13 + TextAlign, TextDecoration, Visibility, WillChange, 14 14 }; 15 15 use we_text::font::Font; 16 16 ··· 119 119 pub sticky_constraint: Option<Rect>, 120 120 /// CSS `visibility` property. 121 121 pub visibility: Visibility, 122 + /// CSS `opacity` property (0.0–1.0). 123 + pub opacity: f32, 124 + /// CSS `will-change` property. 125 + pub will_change: WillChange, 122 126 /// CSS `float` property. 123 127 pub float: Float, 124 128 /// CSS `clear` property. ··· 143 147 } 144 148 145 149 impl LayoutBox { 150 + /// Create a `LayoutBox` from a `BoxType` and `ComputedStyle`. 151 + pub fn from_style(box_type: BoxType, style: &ComputedStyle) -> Self { 152 + Self::new(box_type, style) 153 + } 154 + 146 155 fn new(box_type: BoxType, style: &ComputedStyle) -> Self { 147 156 LayoutBox { 148 157 box_type, ··· 194 203 css_offsets: [style.top, style.right, style.bottom, style.left], 195 204 sticky_constraint: None, 196 205 visibility: style.visibility, 206 + opacity: style.opacity, 207 + will_change: style.will_change, 197 208 float: style.float, 198 209 clear: style.clear, 199 210 content_height: 0.0,
+65
crates/platform/src/metal.rs
··· 44 44 // MTLLoadAction / MTLStoreAction constants 45 45 // --------------------------------------------------------------------------- 46 46 47 + /// `MTLLoadActionLoad` — preserve existing attachment contents at the start. 48 + const MTL_LOAD_ACTION_LOAD: u64 = 1; 49 + 47 50 /// `MTLLoadActionClear` — clear the attachment at the start of a render pass. 48 51 const MTL_LOAD_ACTION_CLEAR: u64 = 2; 49 52 50 53 /// `MTLStoreActionStore` — store the rendered contents. 51 54 const MTL_STORE_ACTION_STORE: u64 = 1; 55 + 56 + // --------------------------------------------------------------------------- 57 + // MTLTextureUsage constants 58 + // --------------------------------------------------------------------------- 59 + 60 + /// `MTLTextureUsageShaderRead` — texture can be read by shaders. 61 + const MTL_TEXTURE_USAGE_SHADER_READ: u64 = 1; 62 + 63 + /// `MTLTextureUsageRenderTarget` — texture can be used as a render target. 64 + const MTL_TEXTURE_USAGE_RENDER_TARGET: u64 = 4; 52 65 53 66 // --------------------------------------------------------------------------- 54 67 // MTLClearColor ··· 624 637 self.new_texture(width, height, MTL_PIXEL_FORMAT_R8_UNORM, data, width) 625 638 } 626 639 640 + /// Create an empty BGRA8 texture that can be used as both a render target 641 + /// and a shader-readable texture (for compositing layers). 642 + pub fn new_render_target_texture(&self, width: u32, height: u32) -> Option<Texture> { 643 + if width == 0 || height == 0 { 644 + return None; 645 + } 646 + 647 + let cls = class!("MTLTextureDescriptor")?; 648 + let desc: *mut c_void = msg_send![ 649 + cls.as_ptr(), 650 + texture2DDescriptorWithPixelFormat: MTL_PIXEL_FORMAT_BGRA8_UNORM, 651 + width: width as u64, 652 + height: height as u64, 653 + mipmapped: false 654 + ]; 655 + let desc_id = unsafe { Id::from_raw(desc as *mut _) }?; 656 + 657 + // Set usage to ShaderRead | RenderTarget so the texture can be both 658 + // rendered into and sampled from in the compositing pass. 659 + let usage = MTL_TEXTURE_USAGE_SHADER_READ | MTL_TEXTURE_USAGE_RENDER_TARGET; 660 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setUsage: usage]; 661 + 662 + let tex: *mut c_void = 663 + msg_send![self.id.as_ptr(), newTextureWithDescriptor: desc_id.as_ptr()]; 664 + let tex_id = unsafe { Id::from_raw(tex as *mut _) }?; 665 + 666 + Some(Texture { 667 + id: tex_id, 668 + width, 669 + height, 670 + }) 671 + } 672 + 627 673 /// Create a sampler state with linear filtering. 628 674 pub fn new_sampler_state(&self) -> Option<SamplerState> { 629 675 let cls = class!("MTLSamplerDescriptor")?; ··· 888 934 889 935 // Set clear color 890 936 let _: *mut c_void = msg_send![attachment, setClearColor: clear_color]; 937 + 938 + Some(desc_id) 939 + } 940 + 941 + /// Create a `MTLRenderPassDescriptor` that loads existing attachment contents. 942 + /// 943 + /// Used to resume rendering to a target that already has content (e.g., after 944 + /// compositing a layer back onto the main drawable). 945 + pub fn make_load_pass_descriptor(texture: Id) -> Option<Id> { 946 + let cls = class!("MTLRenderPassDescriptor")?; 947 + let desc: *mut c_void = msg_send![cls.as_ptr(), renderPassDescriptor]; 948 + let desc_id = unsafe { Id::from_raw(desc as *mut _) }?; 949 + 950 + let attachments: *mut c_void = msg_send![desc_id.as_ptr(), colorAttachments]; 951 + let attachment: *mut c_void = msg_send![attachments, objectAtIndexedSubscript: 0u64]; 952 + 953 + let _: *mut c_void = msg_send![attachment, setTexture: texture.as_ptr()]; 954 + let _: *mut c_void = msg_send![attachment, setLoadAction: MTL_LOAD_ACTION_LOAD]; 955 + let _: *mut c_void = msg_send![attachment, setStoreAction: MTL_STORE_ACTION_STORE]; 891 956 892 957 Some(desc_id) 893 958 }
+417 -105
crates/render/src/gpu.rs
··· 4 4 //! Replaces the software `Renderer` path when Metal is available. Walks the 5 5 //! `DisplayList` in painter's order, converts each `PaintCommand` into GPU 6 6 //! vertex data, and submits batched draw calls via the Metal render pipeline. 7 + //! 8 + //! Supports compositing layers: elements with `opacity < 1.0` (or promoted via 9 + //! `will-change`) are rendered to intermediate textures and composited back 10 + //! with the correct group opacity. 7 11 8 12 use std::collections::HashMap; 9 13 ··· 11 15 use we_dom::NodeId; 12 16 use we_image::pixel::Image; 13 17 use we_platform::metal::{ 14 - self, ClearColor, CommandQueue, Device, MetalLayer, MetalRenderer, RenderCommandEncoder, 15 - SamplerState, Texture, TextureCache, Uniforms, Vertex, MTL_PIXEL_FORMAT_BGRA8_UNORM, 16 - MTL_PRIMITIVE_TYPE_TRIANGLE, 18 + self, ClearColor, CommandBuffer, CommandQueue, Device, MetalLayer, MetalRenderer, 19 + RenderCommandEncoder, SamplerState, Texture, TextureCache, Uniforms, Vertex, 20 + MTL_PIXEL_FORMAT_BGRA8_UNORM, MTL_PRIMITIVE_TYPE_TRIANGLE, 17 21 }; 18 22 use we_text::font::Font; 19 23 ··· 81 85 } 82 86 83 87 // --------------------------------------------------------------------------- 88 + // Compositing layer state 89 + // --------------------------------------------------------------------------- 90 + 91 + /// Saved state for a compositing layer that is being rendered to an 92 + /// intermediate texture. 93 + struct LayerState { 94 + /// `true` if this layer does not need an intermediate texture (opacity=1.0, 95 + /// no isolation needed). Content is rendered directly to the parent target. 96 + passthrough: bool, 97 + /// Offscreen render target texture (only for non-passthrough layers). 98 + texture: Option<Texture>, 99 + /// Layer bounds in parent screen coordinates. 100 + bounds_x: f32, 101 + bounds_y: f32, 102 + bounds_w: f32, 103 + bounds_h: f32, 104 + /// Layer opacity (applied when compositing back to parent). 105 + opacity: f32, 106 + /// Saved parent render target texture Id. 107 + parent_target_id: we_platform::objc::Id, 108 + /// Saved parent viewport dimensions (pixels). 109 + parent_vw: u32, 110 + parent_vh: u32, 111 + /// Saved parent coordinate offset. 112 + parent_coord_offset: (f32, f32), 113 + /// Saved parent clip stack. 114 + saved_clip_stack: Vec<ScissorRect>, 115 + /// Saved parent scissor rect. 116 + saved_scissor: ScissorRect, 117 + } 118 + 119 + // --------------------------------------------------------------------------- 84 120 // GpuRenderer 85 121 // --------------------------------------------------------------------------- 86 122 87 123 /// Metal GPU renderer that processes a `DisplayList` into batched draw calls. 88 124 /// 89 125 /// Owns a glyph atlas and image texture cache to avoid redundant GPU uploads. 126 + /// Supports compositing layers for correct group opacity rendering. 90 127 pub struct GpuRenderer { 91 128 renderer: MetalRenderer, 92 129 atlas: GlyphAtlas, ··· 116 153 /// Render a display list to the Metal layer. 117 154 /// 118 155 /// Gets the next drawable from the layer, encodes all paint commands as 119 - /// batched GPU draw calls, and presents the result. 156 + /// batched GPU draw calls (including compositing layers for elements with 157 + /// opacity < 1.0), and presents the result. 120 158 #[allow(clippy::too_many_arguments)] 121 159 pub fn render( 122 160 &mut self, ··· 140 178 Some(d) => d, 141 179 None => return, 142 180 }; 143 - let texture = match metal::drawable_texture(drawable) { 181 + let texture_id = match metal::drawable_texture(drawable) { 144 182 Some(t) => t, 145 183 None => return, 146 184 }; 147 185 148 - // Create command buffer and render pass. 186 + // Create command buffer — all render passes will be encoded here. 149 187 let cmd_buf = match queue.command_buffer() { 150 188 Some(b) => b, 151 189 None => return, 152 190 }; 153 - let desc = match metal::make_clear_pass_descriptor(texture, clear_color) { 154 - Some(d) => d, 155 - None => return, 156 - }; 157 - let encoder = match cmd_buf.render_command_encoder(desc) { 158 - Some(e) => e, 159 - None => return, 160 - }; 161 191 162 - // Set up pipeline state once. 163 - self.setup_encoder(&encoder, viewport_width, viewport_height); 164 - 165 - // Process display list into batched draw calls. 166 - self.encode_display_list( 192 + // Process the display list, creating/ending encoders as needed for layers. 193 + self.encode_display_list_with_layers( 167 194 display_list, 168 195 font, 169 196 images, 170 - &encoder, 197 + &cmd_buf, 198 + texture_id, 199 + clear_color, 171 200 vw, 172 201 vh, 173 - viewport_width, 174 - viewport_height, 175 202 ); 176 203 177 - encoder.end_encoding(); 178 204 cmd_buf.present_drawable(drawable); 179 205 cmd_buf.commit(); 180 206 } 181 207 182 - /// Set up the render encoder with pipeline state and uniforms. 183 - fn setup_encoder( 184 - &self, 185 - encoder: &RenderCommandEncoder, 186 - viewport_width: f32, 187 - viewport_height: f32, 188 - ) { 189 - let uniforms = Uniforms { 190 - viewport_size: [viewport_width, viewport_height], 191 - }; 192 - encoder.set_render_pipeline_state(self.renderer.pipeline()); 193 - encoder.set_vertex_bytes(&uniforms, 1); 194 - encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); 195 - } 196 - 197 - /// Process the display list and encode draw calls. 208 + /// Process the display list into Metal draw calls, handling compositing 209 + /// layers by rendering to intermediate textures when needed. 198 210 #[allow(clippy::too_many_arguments)] 199 - fn encode_display_list( 211 + fn encode_display_list_with_layers( 200 212 &mut self, 201 213 display_list: &DisplayList, 202 214 font: &Font, 203 215 images: &HashMap<NodeId, &Image>, 204 - encoder: &RenderCommandEncoder, 205 - vw: u32, 206 - vh: u32, 207 - viewport_width: f32, 208 - viewport_height: f32, 216 + cmd_buf: &CommandBuffer, 217 + initial_target_id: we_platform::objc::Id, 218 + clear_color: ClearColor, 219 + initial_vw: u32, 220 + initial_vh: u32, 209 221 ) { 210 - let full_scissor = ScissorRect::full(vw, vh); 222 + // Create the initial render pass targeting the drawable. 223 + let desc = match metal::make_clear_pass_descriptor(initial_target_id, clear_color) { 224 + Some(d) => d, 225 + None => return, 226 + }; 227 + let mut encoder = match cmd_buf.render_command_encoder(desc) { 228 + Some(e) => e, 229 + None => return, 230 + }; 231 + self.setup_encoder(&encoder, initial_vw as f32, initial_vh as f32); 232 + 233 + // Current render target state. 234 + let mut current_target_id = initial_target_id; 235 + let mut current_vw = initial_vw; 236 + let mut current_vh = initial_vh; 237 + 238 + // Coordinate offset: subtracted from display list positions when rendering 239 + // to a layer texture (maps screen coords → layer-local coords). 240 + let mut coord_offset: (f32, f32) = (0.0, 0.0); 241 + 242 + // Clip stack and scissor state for the current render target. 243 + let full_scissor = ScissorRect::full(current_vw, current_vh); 211 244 let mut clip_stack: Vec<ScissorRect> = vec![full_scissor]; 212 245 let mut current_scissor = full_scissor; 213 246 214 - // Current batch of vertices and their texture. 247 + // Layer stack for nested compositing layers. 248 + let mut layer_stack: Vec<LayerState> = Vec::new(); 249 + 250 + // Current batch of vertices and their texture key. 215 251 let mut batch_vertices: Vec<Vertex> = Vec::with_capacity(4096); 216 252 let mut batch_texture: TextureKey = TextureKey::Solid; 217 253 218 254 for cmd in display_list { 219 255 match cmd { 256 + PaintCommand::PushLayer { 257 + opacity, 258 + x, 259 + y, 260 + width, 261 + height, 262 + } => { 263 + let needs_texture = *opacity < 1.0; 264 + 265 + if needs_texture { 266 + // Flush and end current encoder before switching targets. 267 + if !batch_vertices.is_empty() { 268 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 269 + batch_vertices.clear(); 270 + } 271 + encoder.end_encoding(); 272 + 273 + // Compute layer texture dimensions (in pixels). 274 + let adjusted_x = *x + coord_offset.0; 275 + let adjusted_y = *y + coord_offset.1; 276 + let layer_w = (*width).ceil().max(1.0) as u32; 277 + let layer_h = (*height).ceil().max(1.0) as u32; 278 + 279 + // Save parent state. 280 + layer_stack.push(LayerState { 281 + passthrough: false, 282 + texture: None, // filled below 283 + bounds_x: adjusted_x, 284 + bounds_y: adjusted_y, 285 + bounds_w: *width, 286 + bounds_h: *height, 287 + opacity: *opacity, 288 + parent_target_id: current_target_id, 289 + parent_vw: current_vw, 290 + parent_vh: current_vh, 291 + parent_coord_offset: coord_offset, 292 + saved_clip_stack: clip_stack.clone(), 293 + saved_scissor: current_scissor, 294 + }); 295 + 296 + // Create offscreen render target. 297 + if let Some(offscreen) = self 298 + .renderer 299 + .device() 300 + .new_render_target_texture(layer_w, layer_h) 301 + { 302 + let transparent = ClearColor::new(0.0, 0.0, 0.0, 0.0); 303 + let off_desc = 304 + metal::make_clear_pass_descriptor(offscreen.id(), transparent); 305 + if let Some(desc) = off_desc { 306 + if let Some(enc) = cmd_buf.render_command_encoder(desc) { 307 + // Switch to the layer's render target. 308 + encoder = enc; 309 + self.setup_encoder(&encoder, layer_w as f32, layer_h as f32); 310 + 311 + // Update state for the layer. 312 + current_target_id = offscreen.id(); 313 + current_vw = layer_w; 314 + current_vh = layer_h; 315 + 316 + // Offset coordinates so screen positions map 317 + // to layer-local positions. 318 + coord_offset.0 -= *x; 319 + coord_offset.1 -= *y; 320 + 321 + // Reset clip stack for the new target. 322 + let layer_full = ScissorRect::full(layer_w, layer_h); 323 + clip_stack = vec![layer_full]; 324 + current_scissor = layer_full; 325 + 326 + // Store texture in the layer state. 327 + if let Some(state) = layer_stack.last_mut() { 328 + state.texture = Some(offscreen); 329 + } 330 + 331 + continue; 332 + } 333 + } 334 + } 335 + // If render target creation failed, fall back: reopen 336 + // the parent target with Load action and treat as passthrough. 337 + if let Some(state) = layer_stack.last_mut() { 338 + state.passthrough = true; 339 + } 340 + let load_desc = metal::make_load_pass_descriptor(current_target_id); 341 + if let Some(desc) = load_desc { 342 + if let Some(enc) = cmd_buf.render_command_encoder(desc) { 343 + encoder = enc; 344 + self.setup_encoder(&encoder, current_vw as f32, current_vh as f32); 345 + apply_scissor(&encoder, current_scissor, current_vw, current_vh); 346 + } 347 + } 348 + } else { 349 + // Opacity 1.0 — no intermediate texture needed. 350 + layer_stack.push(LayerState { 351 + passthrough: true, 352 + texture: None, 353 + bounds_x: 0.0, 354 + bounds_y: 0.0, 355 + bounds_w: 0.0, 356 + bounds_h: 0.0, 357 + opacity: 1.0, 358 + parent_target_id: current_target_id, 359 + parent_vw: current_vw, 360 + parent_vh: current_vh, 361 + parent_coord_offset: coord_offset, 362 + saved_clip_stack: Vec::new(), 363 + saved_scissor: current_scissor, 364 + }); 365 + } 366 + } 367 + 368 + PaintCommand::PopLayer => { 369 + if let Some(state) = layer_stack.pop() { 370 + if !state.passthrough { 371 + // Flush and end the layer encoder. 372 + if !batch_vertices.is_empty() { 373 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 374 + batch_vertices.clear(); 375 + } 376 + encoder.end_encoding(); 377 + 378 + // Restore parent state. 379 + current_target_id = state.parent_target_id; 380 + current_vw = state.parent_vw; 381 + current_vh = state.parent_vh; 382 + coord_offset = state.parent_coord_offset; 383 + clip_stack = state.saved_clip_stack; 384 + current_scissor = state.saved_scissor; 385 + 386 + // Resume rendering to the parent target (Load action 387 + // preserves what was already rendered). 388 + let load_desc = 389 + match metal::make_load_pass_descriptor(current_target_id) { 390 + Some(d) => d, 391 + None => continue, 392 + }; 393 + encoder = match cmd_buf.render_command_encoder(load_desc) { 394 + Some(e) => e, 395 + None => continue, 396 + }; 397 + self.setup_encoder(&encoder, current_vw as f32, current_vh as f32); 398 + apply_scissor(&encoder, current_scissor, current_vw, current_vh); 399 + 400 + // Composite the layer texture as a quad with opacity. 401 + if let Some(ref tex) = state.texture { 402 + let color = [1.0, 1.0, 1.0, state.opacity]; 403 + push_layer_quad( 404 + &mut batch_vertices, 405 + state.bounds_x, 406 + state.bounds_y, 407 + state.bounds_w, 408 + state.bounds_h, 409 + color, 410 + ); 411 + // Flush immediately with the layer texture bound. 412 + self.flush_batch_with_texture(&batch_vertices, tex, &encoder); 413 + batch_vertices.clear(); 414 + } 415 + } 416 + } 417 + } 418 + 220 419 PaintCommand::FillRect { 221 420 x, 222 421 y, ··· 226 425 } => { 227 426 let tex_key = TextureKey::Solid; 228 427 if tex_key != batch_texture && !batch_vertices.is_empty() { 229 - self.flush_batch( 230 - &batch_vertices, 231 - batch_texture, 232 - encoder, 233 - viewport_width, 234 - viewport_height, 235 - ); 428 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 236 429 batch_vertices.clear(); 237 430 } 238 431 batch_texture = tex_key; 239 432 push_solid_quad( 240 433 &mut batch_vertices, 241 - *x, 242 - *y, 434 + *x + coord_offset.0, 435 + *y + coord_offset.1, 243 436 *width, 244 437 *height, 245 438 color_to_f32(color), ··· 258 451 for quad in &quads { 259 452 let tex_key = TextureKey::AtlasPage(quad.page); 260 453 if tex_key != batch_texture && !batch_vertices.is_empty() { 261 - self.flush_batch( 262 - &batch_vertices, 263 - batch_texture, 264 - encoder, 265 - viewport_width, 266 - viewport_height, 267 - ); 454 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 268 455 batch_vertices.clear(); 269 456 } 270 457 batch_texture = tex_key; 271 - push_textured_quad(&mut batch_vertices, quad, color_to_f32(color)); 458 + push_textured_quad_offset( 459 + &mut batch_vertices, 460 + quad, 461 + color_to_f32(color), 462 + coord_offset, 463 + ); 272 464 } 273 465 } 274 466 ··· 292 484 if self.image_cache.contains(nid) { 293 485 let tex_key = TextureKey::Image(nid); 294 486 if tex_key != batch_texture && !batch_vertices.is_empty() { 295 - self.flush_batch( 296 - &batch_vertices, 297 - batch_texture, 298 - encoder, 299 - viewport_width, 300 - viewport_height, 301 - ); 487 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 302 488 batch_vertices.clear(); 303 489 } 304 490 batch_texture = tex_key; 305 - push_image_quad(&mut batch_vertices, *x, *y, *width, *height); 491 + push_image_quad( 492 + &mut batch_vertices, 493 + *x + coord_offset.0, 494 + *y + coord_offset.1, 495 + *width, 496 + *height, 497 + ); 306 498 } 307 499 } 308 500 ··· 314 506 } => { 315 507 // Flush current batch before changing scissor. 316 508 if !batch_vertices.is_empty() { 317 - self.flush_batch( 318 - &batch_vertices, 319 - batch_texture, 320 - encoder, 321 - viewport_width, 322 - viewport_height, 323 - ); 509 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 324 510 batch_vertices.clear(); 325 511 } 326 512 513 + let cx = (*x + coord_offset.0).max(0.0) as u32; 514 + let cy = (*y + coord_offset.1).max(0.0) as u32; 327 515 let new_clip = ScissorRect { 328 - x: (*x).max(0.0) as u32, 329 - y: (*y).max(0.0) as u32, 516 + x: cx, 517 + y: cy, 330 518 width: (*width).max(0.0) as u32, 331 519 height: (*height).max(0.0) as u32, 332 520 }; 333 521 current_scissor = current_scissor.intersect(new_clip); 334 522 clip_stack.push(current_scissor); 335 523 336 - // Apply scissor — ensure non-zero dimensions for Metal. 337 - apply_scissor(encoder, current_scissor, vw, vh); 524 + apply_scissor(&encoder, current_scissor, current_vw, current_vh); 338 525 } 339 526 340 527 PaintCommand::PopClip => { 341 528 // Flush current batch before changing scissor. 342 529 if !batch_vertices.is_empty() { 343 - self.flush_batch( 344 - &batch_vertices, 345 - batch_texture, 346 - encoder, 347 - viewport_width, 348 - viewport_height, 349 - ); 530 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 350 531 batch_vertices.clear(); 351 532 } 352 533 353 534 clip_stack.pop(); 354 - current_scissor = clip_stack.last().copied().unwrap_or(full_scissor); 355 - apply_scissor(encoder, current_scissor, vw, vh); 535 + current_scissor = clip_stack 536 + .last() 537 + .copied() 538 + .unwrap_or(ScissorRect::full(current_vw, current_vh)); 539 + apply_scissor(&encoder, current_scissor, current_vw, current_vh); 356 540 } 357 541 } 358 542 } 359 543 360 544 // Flush any remaining vertices. 361 545 if !batch_vertices.is_empty() { 362 - self.flush_batch( 363 - &batch_vertices, 364 - batch_texture, 365 - encoder, 366 - viewport_width, 367 - viewport_height, 368 - ); 546 + self.flush_batch(&batch_vertices, batch_texture, &encoder); 369 547 } 548 + 549 + encoder.end_encoding(); 550 + } 551 + 552 + /// Set up the render encoder with pipeline state and uniforms. 553 + fn setup_encoder( 554 + &self, 555 + encoder: &RenderCommandEncoder, 556 + viewport_width: f32, 557 + viewport_height: f32, 558 + ) { 559 + let uniforms = Uniforms { 560 + viewport_size: [viewport_width, viewport_height], 561 + }; 562 + encoder.set_render_pipeline_state(self.renderer.pipeline()); 563 + encoder.set_vertex_bytes(&uniforms, 1); 564 + encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); 370 565 } 371 566 372 567 /// Flush a batch of vertices as a single draw call. ··· 375 570 vertices: &[Vertex], 376 571 texture_key: TextureKey, 377 572 encoder: &RenderCommandEncoder, 378 - _viewport_width: f32, 379 - _viewport_height: f32, 380 573 ) { 381 574 if vertices.is_empty() { 382 575 return; ··· 417 610 if matches!(texture_key, TextureKey::Image(_)) { 418 611 encoder.set_fragment_sampler_state(self.renderer.sampler(), 0); 419 612 } 613 + } 614 + 615 + /// Flush vertices as a draw call using a specific texture (for compositing 616 + /// layer textures back onto the parent target). 617 + fn flush_batch_with_texture( 618 + &self, 619 + vertices: &[Vertex], 620 + texture: &Texture, 621 + encoder: &RenderCommandEncoder, 622 + ) { 623 + if vertices.is_empty() { 624 + return; 625 + } 626 + let buffer = match self.renderer.device().new_buffer_with_vertices(vertices) { 627 + Some(b) => b, 628 + None => return, 629 + }; 630 + encoder.set_fragment_texture(texture, 0); 631 + encoder.set_vertex_buffer(&buffer, 0, 0); 632 + encoder.draw_primitives(MTL_PRIMITIVE_TYPE_TRIANGLE, 0, vertices.len() as u64); 420 633 } 421 634 422 635 /// Ensure GPU textures exist for all atlas pages, uploading dirty pages. ··· 502 715 } 503 716 504 717 /// Push 6 vertices (2 triangles) for a textured glyph quad. 718 + #[cfg(test)] 505 719 fn push_textured_quad(out: &mut Vec<Vertex>, quad: &TexturedQuad, color: [f32; 4]) { 506 - let x0 = quad.x; 507 - let y0 = quad.y; 508 - let x1 = quad.x + quad.width; 509 - let y1 = quad.y + quad.height; 720 + push_textured_quad_offset(out, quad, color, (0.0, 0.0)); 721 + } 722 + 723 + /// Push 6 vertices for a textured glyph quad with a coordinate offset. 724 + fn push_textured_quad_offset( 725 + out: &mut Vec<Vertex>, 726 + quad: &TexturedQuad, 727 + color: [f32; 4], 728 + offset: (f32, f32), 729 + ) { 730 + let x0 = quad.x + offset.0; 731 + let y0 = quad.y + offset.1; 732 + let x1 = x0 + quad.width; 733 + let y1 = y0 + quad.height; 510 734 let u0 = quad.u0; 511 735 let v0 = quad.v0; 512 736 let u1 = quad.u1; ··· 602 826 }); 603 827 } 604 828 829 + /// Push 6 vertices for compositing a layer texture back onto the parent target. 830 + /// 831 + /// The layer texture is drawn as a full quad at the layer's screen position. 832 + /// The `color` alpha channel carries the layer opacity. 833 + fn push_layer_quad(out: &mut Vec<Vertex>, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { 834 + let x0 = x; 835 + let y0 = y; 836 + let x1 = x + w; 837 + let y1 = y + h; 838 + 839 + // Triangle 1: TL, TR, BL 840 + out.push(Vertex { 841 + position: [x0, y0], 842 + color, 843 + tex_coord: [0.0, 0.0], 844 + use_texture: 1.0, 845 + }); 846 + out.push(Vertex { 847 + position: [x1, y0], 848 + color, 849 + tex_coord: [1.0, 0.0], 850 + use_texture: 1.0, 851 + }); 852 + out.push(Vertex { 853 + position: [x0, y1], 854 + color, 855 + tex_coord: [0.0, 1.0], 856 + use_texture: 1.0, 857 + }); 858 + 859 + // Triangle 2: TR, BR, BL 860 + out.push(Vertex { 861 + position: [x1, y0], 862 + color, 863 + tex_coord: [1.0, 0.0], 864 + use_texture: 1.0, 865 + }); 866 + out.push(Vertex { 867 + position: [x1, y1], 868 + color, 869 + tex_coord: [1.0, 1.0], 870 + use_texture: 1.0, 871 + }); 872 + out.push(Vertex { 873 + position: [x0, y1], 874 + color, 875 + tex_coord: [0.0, 1.0], 876 + use_texture: 1.0, 877 + }); 878 + } 879 + 605 880 // --------------------------------------------------------------------------- 606 881 // Helpers 607 882 // --------------------------------------------------------------------------- ··· 777 1052 assert_ne!(TextureKey::AtlasPage(0), TextureKey::AtlasPage(1)); 778 1053 assert_ne!(TextureKey::Solid, TextureKey::AtlasPage(0)); 779 1054 assert_ne!(TextureKey::Solid, TextureKey::Image(0)); 1055 + } 1056 + 1057 + #[test] 1058 + fn layer_quad_vertex_count() { 1059 + let mut verts = Vec::new(); 1060 + push_layer_quad(&mut verts, 10.0, 20.0, 100.0, 50.0, [1.0, 1.0, 1.0, 0.5]); 1061 + assert_eq!(verts.len(), 6); 1062 + for v in &verts { 1063 + assert_eq!(v.use_texture, 1.0); 1064 + assert_eq!(v.color[3], 0.5); 1065 + } 1066 + assert_eq!(verts[0].tex_coord, [0.0, 0.0]); // TL 1067 + assert_eq!(verts[1].tex_coord, [1.0, 0.0]); // TR 1068 + assert_eq!(verts[4].tex_coord, [1.0, 1.0]); // BR 1069 + } 1070 + 1071 + #[test] 1072 + fn textured_quad_with_offset() { 1073 + let quad = TexturedQuad { 1074 + x: 100.0, 1075 + y: 200.0, 1076 + width: 10.0, 1077 + height: 14.0, 1078 + u0: 0.0, 1079 + v0: 0.0, 1080 + u1: 1.0, 1081 + v1: 1.0, 1082 + color: [1.0, 1.0, 1.0, 1.0], 1083 + page: 0, 1084 + }; 1085 + let mut verts = Vec::new(); 1086 + push_textured_quad_offset(&mut verts, &quad, [1.0, 1.0, 1.0, 1.0], (-50.0, -100.0)); 1087 + assert_eq!(verts.len(), 6); 1088 + // TL position should be offset 1089 + assert_eq!(verts[0].position, [50.0, 100.0]); 1090 + // BR position 1091 + assert_eq!(verts[4].position, [60.0, 114.0]); 780 1092 } 781 1093 }
+311 -1
crates/render/src/lib.rs
··· 13 13 use we_image::pixel::Image; 14 14 use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH}; 15 15 use we_style::computed::{ 16 - BorderStyle, LengthOrAuto, Overflow, Position, TextDecoration, Visibility, 16 + BorderStyle, LengthOrAuto, Overflow, Position, TextDecoration, Visibility, WillChange, 17 17 }; 18 18 use we_text::font::Font; 19 19 ··· 71 71 }, 72 72 /// Pop the most recent clip rectangle off the clip stack. 73 73 PopClip, 74 + /// Begin a compositing layer. Content within the layer is rendered to an 75 + /// intermediate surface and then composited with the given opacity. 76 + PushLayer { 77 + opacity: f32, 78 + x: f32, 79 + y: f32, 80 + width: f32, 81 + height: f32, 82 + }, 83 + /// End the current compositing layer and composite it onto the parent. 84 + PopLayer, 74 85 } 75 86 76 87 /// A flat list of paint commands in painter's order. ··· 121 132 b.position == Position::Absolute || b.position == Position::Fixed 122 133 } 123 134 135 + /// Returns `true` if a layout box requires its own compositing layer. 136 + fn needs_compositing_layer(b: &LayoutBox) -> bool { 137 + // opacity < 1.0 requires group compositing to render correctly 138 + b.opacity < 1.0 139 + // will-change hints signal the compositor to promote to a layer 140 + || b.will_change != WillChange::default() 141 + // fixed-position elements benefit from their own layer 142 + || b.position == Position::Fixed 143 + // scrollable containers with overflow render content to a layer texture 144 + || ((b.overflow == Overflow::Scroll || b.overflow == Overflow::Auto) 145 + && b.content_height > b.rect.height) 146 + } 147 + 124 148 fn paint_box( 125 149 layout_box: &LayoutBox, 126 150 list: &mut DisplayList, ··· 132 156 let tx = translate.0; 133 157 let ty = translate.1; 134 158 159 + // If this box needs a compositing layer, wrap all its paint commands. 160 + let layer = needs_compositing_layer(layout_box); 161 + if layer { 162 + let bb = border_box(layout_box); 163 + list.push(PaintCommand::PushLayer { 164 + opacity: layout_box.opacity, 165 + x: bb.x + tx, 166 + y: bb.y + ty, 167 + width: bb.width, 168 + height: bb.height, 169 + }); 170 + } 171 + 135 172 if visible { 136 173 paint_background(layout_box, list, tx, ty); 137 174 paint_borders(layout_box, list, tx, ty); ··· 255 292 if scrollable && visible { 256 293 paint_scrollbars(layout_box, list, tx, ty, scroll_state); 257 294 } 295 + 296 + if layer { 297 + list.push(PaintCommand::PopLayer); 298 + } 258 299 } 259 300 260 301 /// Paint a child box, applying sticky positioning offset when needed. ··· 357 398 } 358 399 } 359 400 401 + /// Compute the border box of a layout box (padding box + border widths). 402 + fn border_box(layout_box: &LayoutBox) -> Rect { 403 + let pb = padding_box(layout_box); 404 + Rect { 405 + x: pb.x - layout_box.border.left, 406 + y: pb.y - layout_box.border.top, 407 + width: pb.width + layout_box.border.left + layout_box.border.right, 408 + height: pb.height + layout_box.border.top + layout_box.border.bottom, 409 + } 410 + } 411 + 360 412 /// Extract the NodeId from a BoxType, if it has one. 361 413 fn node_id_from_box_type(box_type: &BoxType) -> Option<NodeId> { 362 414 match box_type { ··· 579 631 } 580 632 581 633 /// Software renderer that paints a display list into a BGRA pixel buffer. 634 + /// Saved state for a software compositing layer. 635 + struct SoftwareLayerState { 636 + /// The parent's pixel buffer that was replaced. 637 + saved_buffer: Vec<u8>, 638 + /// Layer opacity to apply when compositing back. 639 + opacity: f32, 640 + } 641 + 582 642 pub struct Renderer { 583 643 width: u32, 584 644 height: u32, ··· 587 647 /// Stack of clip rectangles. When non-empty, all drawing is clipped to 588 648 /// the intersection of all active clip rects. 589 649 clip_stack: Vec<ClipRect>, 650 + /// Stack of compositing layer states for group opacity. 651 + layer_stack: Vec<SoftwareLayerState>, 590 652 } 591 653 592 654 impl Renderer { ··· 607 669 height, 608 670 buffer, 609 671 clip_stack: Vec::new(), 672 + layer_stack: Vec::new(), 610 673 } 611 674 } 612 675 ··· 704 767 } 705 768 PaintCommand::PopClip => { 706 769 self.clip_stack.pop(); 770 + } 771 + PaintCommand::PushLayer { opacity, .. } => { 772 + if *opacity < 1.0 { 773 + // Save the current buffer and start rendering to a 774 + // fresh transparent buffer. 775 + let saved = std::mem::replace( 776 + &mut self.buffer, 777 + vec![0u8; (self.width as usize) * (self.height as usize) * 4], 778 + ); 779 + self.layer_stack.push(SoftwareLayerState { 780 + saved_buffer: saved, 781 + opacity: *opacity, 782 + }); 783 + } else { 784 + // Opacity 1.0 — no isolation needed, push a no-op. 785 + self.layer_stack.push(SoftwareLayerState { 786 + saved_buffer: Vec::new(), 787 + opacity: 1.0, 788 + }); 789 + } 790 + } 791 + PaintCommand::PopLayer => { 792 + if let Some(state) = self.layer_stack.pop() { 793 + if state.opacity < 1.0 { 794 + // Composite the layer buffer onto the saved parent 795 + // buffer with the layer opacity. 796 + let layer_buf = std::mem::replace(&mut self.buffer, state.saved_buffer); 797 + let alpha = (state.opacity * 255.0) as u32; 798 + let len = self.buffer.len(); 799 + let mut i = 0; 800 + while i + 3 < len { 801 + let src_a = layer_buf[i + 3] as u32; 802 + if src_a > 0 { 803 + // Combine layer pixel alpha with group opacity. 804 + let a = (src_a * alpha) / 255; 805 + let inv_a = 255 - a; 806 + let src_b = layer_buf[i] as u32; 807 + let src_g = layer_buf[i + 1] as u32; 808 + let src_r = layer_buf[i + 2] as u32; 809 + let dst_b = self.buffer[i] as u32; 810 + let dst_g = self.buffer[i + 1] as u32; 811 + let dst_r = self.buffer[i + 2] as u32; 812 + let dst_a = self.buffer[i + 3] as u32; 813 + self.buffer[i] = ((src_b * a + dst_b * inv_a) / 255) as u8; 814 + self.buffer[i + 1] = ((src_g * a + dst_g * inv_a) / 255) as u8; 815 + self.buffer[i + 2] = ((src_r * a + dst_r * inv_a) / 255) as u8; 816 + self.buffer[i + 3] = (a + (dst_a * inv_a) / 255).min(255) as u8; 817 + } 818 + i += 4; 819 + } 820 + } 821 + } 707 822 } 708 823 } 709 824 } ··· 1965 2080 } 1966 2081 // If not found, the element might not be painted (which is also 1967 2082 // acceptable if it's off-screen). 2083 + } 2084 + 2085 + // ----------------------------------------------------------------------- 2086 + // Compositing layer tests 2087 + // ----------------------------------------------------------------------- 2088 + 2089 + #[test] 2090 + fn opacity_element_generates_push_pop_layer() { 2091 + let html = "<html><body><div style='opacity: 0.5; background: red; width: 100px; height: 50px;'>Hello</div></body></html>"; 2092 + let doc = we_html::parse_html(html); 2093 + let tree = layout_doc(&doc); 2094 + let list = build_display_list(&tree); 2095 + 2096 + // There should be at least one PushLayer with opacity 0.5. 2097 + let push_count = list 2098 + .iter() 2099 + .filter(|cmd| matches!(cmd, PaintCommand::PushLayer { opacity, .. } if *opacity < 1.0)) 2100 + .count(); 2101 + assert!(push_count >= 1, "expected PushLayer for opacity < 1.0"); 2102 + 2103 + let pop_count = list 2104 + .iter() 2105 + .filter(|cmd| matches!(cmd, PaintCommand::PopLayer)) 2106 + .count(); 2107 + assert_eq!(push_count, pop_count, "PushLayer/PopLayer must be balanced"); 2108 + } 2109 + 2110 + #[test] 2111 + fn full_opacity_no_isolation_layer() { 2112 + // A fully opaque div should not generate PushLayer with opacity < 1.0. 2113 + let html = "<html><body><div style='background: blue; width: 100px; height: 50px;'>Hi</div></body></html>"; 2114 + let doc = we_html::parse_html(html); 2115 + let tree = layout_doc(&doc); 2116 + let list = build_display_list(&tree); 2117 + 2118 + let isolated_layers = list 2119 + .iter() 2120 + .filter(|cmd| matches!(cmd, PaintCommand::PushLayer { opacity, .. } if *opacity < 1.0)) 2121 + .count(); 2122 + assert_eq!( 2123 + isolated_layers, 0, 2124 + "opacity=1.0 should not produce isolated layers" 2125 + ); 2126 + } 2127 + 2128 + #[test] 2129 + fn software_renderer_opacity_compositing() { 2130 + // Render a red rect at 50% opacity over a white background. 2131 + let mut r = Renderer::new(10, 10); 2132 + let red = Color::new(255, 0, 0, 255); 2133 + 2134 + // Simulate PushLayer + FillRect + PopLayer through the display list. 2135 + let display_list = vec![ 2136 + PaintCommand::PushLayer { 2137 + opacity: 0.5, 2138 + x: 0.0, 2139 + y: 0.0, 2140 + width: 10.0, 2141 + height: 10.0, 2142 + }, 2143 + PaintCommand::FillRect { 2144 + x: 0.0, 2145 + y: 0.0, 2146 + width: 10.0, 2147 + height: 10.0, 2148 + color: red, 2149 + }, 2150 + PaintCommand::PopLayer, 2151 + ]; 2152 + 2153 + for cmd in &display_list { 2154 + match cmd { 2155 + PaintCommand::FillRect { 2156 + x, 2157 + y, 2158 + width, 2159 + height, 2160 + color, 2161 + } => r.fill_rect(*x, *y, *width, *height, *color), 2162 + PaintCommand::PushLayer { opacity, .. } => { 2163 + if *opacity < 1.0 { 2164 + let saved = std::mem::replace( 2165 + &mut r.buffer, 2166 + vec![0u8; (r.width as usize) * (r.height as usize) * 4], 2167 + ); 2168 + r.layer_stack.push(SoftwareLayerState { 2169 + saved_buffer: saved, 2170 + opacity: *opacity, 2171 + }); 2172 + } 2173 + } 2174 + PaintCommand::PopLayer => { 2175 + if let Some(state) = r.layer_stack.pop() { 2176 + if state.opacity < 1.0 { 2177 + let layer_buf = std::mem::replace(&mut r.buffer, state.saved_buffer); 2178 + let alpha = (state.opacity * 255.0) as u32; 2179 + let len = r.buffer.len(); 2180 + let mut i = 0; 2181 + while i + 3 < len { 2182 + let src_a = layer_buf[i + 3] as u32; 2183 + if src_a > 0 { 2184 + let a = (src_a * alpha) / 255; 2185 + let inv_a = 255 - a; 2186 + let src_b = layer_buf[i] as u32; 2187 + let src_g = layer_buf[i + 1] as u32; 2188 + let src_r = layer_buf[i + 2] as u32; 2189 + let dst_b = r.buffer[i] as u32; 2190 + let dst_g = r.buffer[i + 1] as u32; 2191 + let dst_r = r.buffer[i + 2] as u32; 2192 + let dst_a = r.buffer[i + 3] as u32; 2193 + r.buffer[i] = ((src_b * a + dst_b * inv_a) / 255) as u8; 2194 + r.buffer[i + 1] = ((src_g * a + dst_g * inv_a) / 255) as u8; 2195 + r.buffer[i + 2] = ((src_r * a + dst_r * inv_a) / 255) as u8; 2196 + r.buffer[i + 3] = (a + (dst_a * inv_a) / 255).min(255) as u8; 2197 + } 2198 + i += 4; 2199 + } 2200 + } 2201 + } 2202 + } 2203 + _ => {} 2204 + } 2205 + } 2206 + 2207 + // The result should be a blend of red and white at ~50%. 2208 + // BGRA: Red over white at 50% → approximately (128, 128, 255, 255) 2209 + // allowing for rounding: R channel should be ~128±2, B channel ~128±2. 2210 + let p = &r.pixels()[0..4]; 2211 + // B channel: red has B=0, white has B=255 → ~128 2212 + assert!( 2213 + (p[0] as i32 - 128).unsigned_abs() <= 2, 2214 + "B should be ~128, got {}", 2215 + p[0] 2216 + ); 2217 + // G channel: red has G=0, white has G=255 → ~128 2218 + assert!( 2219 + (p[1] as i32 - 128).unsigned_abs() <= 2, 2220 + "G should be ~128, got {}", 2221 + p[1] 2222 + ); 2223 + // R channel: red has R=255, white has R=255 → 255 2224 + assert!(p[2] >= 253, "R should be ~255, got {}", p[2]); 2225 + } 2226 + 2227 + #[test] 2228 + fn needs_compositing_layer_opacity() { 2229 + use we_style::computed::ComputedStyle; 2230 + let mut style = ComputedStyle::default(); 2231 + style.display = we_style::computed::Display::Block; 2232 + style.opacity = 0.5; 2233 + 2234 + let b = we_layout::LayoutBox::from_style( 2235 + we_layout::BoxType::Block(NodeId::from_index(0)), 2236 + &style, 2237 + ); 2238 + assert!( 2239 + needs_compositing_layer(&b), 2240 + "opacity < 1.0 should need a layer" 2241 + ); 2242 + } 2243 + 2244 + #[test] 2245 + fn needs_compositing_layer_will_change() { 2246 + use we_style::computed::{ComputedStyle, WillChange}; 2247 + let mut style = ComputedStyle::default(); 2248 + style.display = we_style::computed::Display::Block; 2249 + style.will_change = WillChange { 2250 + transform: true, 2251 + opacity: false, 2252 + }; 2253 + 2254 + let b = we_layout::LayoutBox::from_style( 2255 + we_layout::BoxType::Block(NodeId::from_index(0)), 2256 + &style, 2257 + ); 2258 + assert!( 2259 + needs_compositing_layer(&b), 2260 + "will-change: transform should need a layer" 2261 + ); 2262 + } 2263 + 2264 + #[test] 2265 + fn no_layer_for_normal_element() { 2266 + use we_style::computed::ComputedStyle; 2267 + let mut style = ComputedStyle::default(); 2268 + style.display = we_style::computed::Display::Block; 2269 + 2270 + let b = we_layout::LayoutBox::from_style( 2271 + we_layout::BoxType::Block(NodeId::from_index(0)), 2272 + &style, 2273 + ); 2274 + assert!( 2275 + !needs_compositing_layer(&b), 2276 + "normal element should not need a layer" 2277 + ); 1968 2278 } 1969 2279 }
+104
crates/style/src/computed.rs
··· 145 145 } 146 146 147 147 // --------------------------------------------------------------------------- 148 + // WillChange 149 + // --------------------------------------------------------------------------- 150 + 151 + /// CSS `will-change` property: hints which properties may change, allowing 152 + /// the compositor to promote elements to their own layer. 153 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 154 + pub struct WillChange { 155 + pub transform: bool, 156 + pub opacity: bool, 157 + } 158 + 159 + // --------------------------------------------------------------------------- 148 160 // Visibility 149 161 // --------------------------------------------------------------------------- 150 162 ··· 328 340 // Overflow 329 341 pub overflow: Overflow, 330 342 343 + // Opacity (not inherited) 344 + pub opacity: f32, 345 + 346 + // Will-change (not inherited) 347 + pub will_change: WillChange, 348 + 331 349 // Visibility (inherited) 332 350 pub visibility: Visibility, 333 351 ··· 405 423 clear: Clear::None, 406 424 407 425 overflow: Overflow::Visible, 426 + opacity: 1.0, 427 + will_change: WillChange::default(), 408 428 visibility: Visibility::Visible, 409 429 410 430 flex_direction: FlexDirection::Row, ··· 949 969 }; 950 970 } 951 971 972 + // Opacity (not inherited, 0.0–1.0, clamped) 973 + "opacity" => { 974 + style.opacity = match value { 975 + CssValue::Number(n) => (*n as f32).clamp(0.0, 1.0), 976 + CssValue::Zero => 0.0, 977 + _ => style.opacity, 978 + }; 979 + } 980 + 981 + // Will-change 982 + "will-change" => { 983 + style.will_change = match value { 984 + CssValue::Keyword(k) => match k.as_str() { 985 + "transform" => WillChange { 986 + transform: true, 987 + opacity: false, 988 + }, 989 + "opacity" => WillChange { 990 + transform: false, 991 + opacity: true, 992 + }, 993 + "auto" => WillChange::default(), 994 + _ => style.will_change, 995 + }, 996 + _ => style.will_change, 997 + }; 998 + } 999 + 952 1000 // Visibility (inherited) 953 1001 "visibility" => { 954 1002 style.visibility = match value { ··· 1153 1201 "float" => style.float = parent.float, 1154 1202 "clear" => style.clear = parent.clear, 1155 1203 "overflow" => style.overflow = parent.overflow, 1204 + "opacity" => style.opacity = parent.opacity, 1205 + "will-change" => style.will_change = parent.will_change, 1156 1206 "flex-direction" => style.flex_direction = parent.flex_direction, 1157 1207 "flex-wrap" => style.flex_wrap = parent.flex_wrap, 1158 1208 "justify-content" => style.justify_content = parent.justify_content, ··· 1208 1258 "float" => style.float = initial.float, 1209 1259 "clear" => style.clear = initial.clear, 1210 1260 "overflow" => style.overflow = initial.overflow, 1261 + "opacity" => style.opacity = initial.opacity, 1262 + "will-change" => style.will_change = initial.will_change, 1211 1263 "visibility" => style.visibility = initial.visibility, 1212 1264 "flex-direction" => style.flex_direction = initial.flex_direction, 1213 1265 "flex-wrap" => style.flex_wrap = initial.flex_wrap, ··· 2422 2474 let div = &body.children[0]; 2423 2475 assert_eq!(div.style.position, Position::Sticky); 2424 2476 assert_eq!(div.style.top, LengthOrAuto::Length(10.0)); 2477 + } 2478 + 2479 + #[test] 2480 + fn opacity_parsing() { 2481 + let html_str = r#"<!DOCTYPE html> 2482 + <html><head><style> 2483 + .transparent { opacity: 0.5; } 2484 + .invisible { opacity: 0; } 2485 + </style></head> 2486 + <body><div class="transparent">A</div><div class="invisible">B</div></body></html>"#; 2487 + let doc = we_html::parse_html(html_str); 2488 + let sheets = extract_stylesheets(&doc); 2489 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2490 + let body = &styled.children[0]; 2491 + let div_transparent = &body.children[0]; 2492 + let div_invisible = &body.children[1]; 2493 + assert!((div_transparent.style.opacity - 0.5).abs() < 0.001); 2494 + assert!((div_invisible.style.opacity - 0.0).abs() < 0.001); 2495 + } 2496 + 2497 + #[test] 2498 + fn opacity_clamped() { 2499 + // opacity values should be clamped to [0.0, 1.0] 2500 + let style_default = ComputedStyle::default(); 2501 + assert!((style_default.opacity - 1.0).abs() < 0.001); 2502 + } 2503 + 2504 + #[test] 2505 + fn will_change_parsing() { 2506 + let html_str = r#"<!DOCTYPE html> 2507 + <html><head><style> 2508 + .wc-transform { will-change: transform; } 2509 + .wc-opacity { will-change: opacity; } 2510 + </style></head> 2511 + <body><div class="wc-transform">A</div><div class="wc-opacity">B</div></body></html>"#; 2512 + let doc = we_html::parse_html(html_str); 2513 + let sheets = extract_stylesheets(&doc); 2514 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2515 + let body = &styled.children[0]; 2516 + let div_transform = &body.children[0]; 2517 + let div_opacity = &body.children[1]; 2518 + assert!(div_transform.style.will_change.transform); 2519 + assert!(!div_transform.style.will_change.opacity); 2520 + assert!(!div_opacity.style.will_change.transform); 2521 + assert!(div_opacity.style.will_change.opacity); 2522 + } 2523 + 2524 + #[test] 2525 + fn will_change_default_is_auto() { 2526 + let style = ComputedStyle::default(); 2527 + assert!(!style.will_change.transform); 2528 + assert!(!style.will_change.opacity); 2425 2529 } 2426 2530 }