diff --git a/.isu/issues.json b/.isu/issues.json index c397720..bdcc555 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 399, + "next_id": 400, "issues": [ { "id": 1, @@ -4869,6 +4869,19 @@ "author": "piefev", "state": "open", "created_at": "2026-07-17T10:31:54Z" + }, + { + "id": 399, + "repo": "we", + "title": "Opera parity remains blocked after radial-gradient background support", + "body": "Parent: isu issue 281\n\nA pass at issue 281 added CSS radial-gradient parsing/rendering plus layered background-position-x/y support, but crates/e2e/scenarios/real-web/opera.com.we still cannot have its xfail removed.\n\nRepro command:\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\n\nCurrent result after the radial-gradient support:\n desktop assertion L99: 63.85% match, 444144/1228500 px differ\n mobile assertion L110: 65.69% match, 112931/329160 px differ\n\nArtifacts:\n crates/e2e/artifacts/real-web/opera.com/desktop.png\n crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png\n crates/e2e/artifacts/real-web/opera.com/desktop_dom.txt\n crates/e2e/artifacts/real-web/opera.com/desktop_console.txt\n crates/e2e/artifacts/real-web/opera.com/mobile.png\n crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png\n crates/e2e/artifacts/real-web/opera.com/mobile_dom.txt\n crates/e2e/artifacts/real-web/opera.com/mobile_console.txt\n\nThe console captures are empty and the existing DOM/interactivity assertions pass. The remaining parity gap appears dominated by text metrics/line-height, desktop/mobile vertical placement, and page state differences around the hydrated survey/cookie content. Acceptance for the parent remains unchanged: remove # xfail from opera.com.we only once both desktop and mobile Chromium screenshot assertions pass.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-17T10:52:00Z" } ] } diff --git a/crates/css/src/values.rs b/crates/css/src/values.rs index bde015b..566d831 100644 --- a/crates/css/src/values.rs +++ b/crates/css/src/values.rs @@ -25,6 +25,8 @@ pub enum CssValue { Url(String), /// A parsed `linear-gradient(...)` image value. LinearGradient(LinearGradient), + /// A parsed `radial-gradient(...)` image value. + RadialGradient(RadialGradient), /// A keyword (ident). Keyword(String), /// The `auto` keyword. @@ -88,6 +90,12 @@ pub struct LinearGradient { pub stops: Vec, } +/// Minimal radial-gradient representation used by the renderer. +#[derive(Debug, Clone, PartialEq)] +pub struct RadialGradient { + pub stops: Vec, +} + // --------------------------------------------------------------------------- // CSS math expression AST (calc, min, max, clamp) // --------------------------------------------------------------------------- @@ -315,6 +323,7 @@ fn parse_function(name: &str, args: &[ComponentValue]) -> CssValue { "rgb" => parse_rgb(args, false), "rgba" => parse_rgb(args, true), "linear-gradient" => parse_linear_gradient(args), + "radial-gradient" => parse_radial_gradient(args), "hsl" => parse_hsl(args), "hsla" => parse_hsl(args), "calc" => parse_math_calc(args), @@ -364,6 +373,62 @@ fn parse_linear_gradient(args: &[ComponentValue]) -> CssValue { CssValue::LinearGradient(LinearGradient { direction, stops }) } +fn parse_radial_gradient(args: &[ComponentValue]) -> CssValue { + let mut groups: Vec> = vec![Vec::new()]; + for arg in args { + if matches!(arg, ComponentValue::Comma) { + groups.push(Vec::new()); + } else { + groups.last_mut().unwrap().push(arg.clone()); + } + } + groups.retain(|group| { + group + .iter() + .any(|v| !matches!(v, ComponentValue::Whitespace)) + }); + + if groups + .first() + .is_some_and(|group| parse_radial_gradient_shape(group)) + { + groups.remove(0); + } + + let stops: Vec = groups + .iter() + .filter_map(|group| parse_gradient_stop(group)) + .collect(); + if stops.len() < 2 { + return CssValue::Keyword("radial-gradient()".to_string()); + } + + CssValue::RadialGradient(RadialGradient { stops }) +} + +fn parse_radial_gradient_shape(group: &[ComponentValue]) -> bool { + let idents: Vec = group + .iter() + .filter_map(|cv| match cv { + ComponentValue::Ident(s) => Some(s.to_ascii_lowercase()), + ComponentValue::Whitespace => None, + _ => Some(String::new()), + }) + .collect(); + + idents.iter().any(|s| { + matches!( + s.as_str(), + "circle" + | "ellipse" + | "closest-side" + | "closest-corner" + | "farthest-side" + | "farthest-corner" + ) + }) +} + fn parse_linear_gradient_direction(group: &[ComponentValue]) -> Option { let idents: Vec = group .iter() @@ -1522,20 +1587,23 @@ fn expand_background(values: &[ComponentValue], important: bool) -> Vec = None; + let mut bg_images = Vec::new(); for val in &parsed { match val { CssValue::Color(_) | CssValue::CurrentColor | CssValue::Transparent => { bg_color = val.clone() } - CssValue::Url(_) | CssValue::LinearGradient(_) => bg_image = Some(val.clone()), + CssValue::Url(_) | CssValue::LinearGradient(_) | CssValue::RadialGradient(_) => { + bg_images.push(val.clone()) + } CssValue::Keyword(kw) => { if let Some(c) = named_color(kw) { bg_color = CssValue::Color(c); } else { match kw.as_str() { - "none" => bg_image = Some(CssValue::None), + "," => {} + "none" => bg_images.push(CssValue::None), _ => bg_color = val.clone(), } } @@ -1549,7 +1617,12 @@ fn expand_background(values: &[ComponentValue], important: bool) -> Vec, /// CSS linear-gradient background, if any. pub background_gradient: Option, + /// CSS radial-gradient background, if any. + pub background_radial_gradients: Vec, pub background_size: BackgroundSize, pub background_position_x: LengthOrAuto, pub background_position_y: LengthOrAuto, + pub background_position_x_layers: Vec, + pub background_position_y_layers: Vec, pub background_repeat: BackgroundRepeat, /// CSS `box-shadow` layers. pub box_shadows: Vec, @@ -346,9 +350,12 @@ impl LayoutBox { background_color: style.background_color, background_image: style.background_image.as_ref().map(|url| url.to_string()), background_gradient: style.background_gradient.clone(), + background_radial_gradients: style.background_radial_gradients.clone(), background_size: style.background_size, background_position_x: style.background_position_x, background_position_y: style.background_position_y, + background_position_x_layers: style.background_position_x_layers.clone(), + background_position_y_layers: style.background_position_y_layers.clone(), background_repeat: style.background_repeat, box_shadows: style.box_shadows.clone(), text_decoration: style.text_decoration, diff --git a/crates/render/src/gpu.rs b/crates/render/src/gpu.rs index f8578c1..d8075c8 100644 --- a/crates/render/src/gpu.rs +++ b/crates/render/src/gpu.rs @@ -11,7 +11,9 @@ use std::collections::HashMap; -use we_css::values::{Color, LinearGradient, LinearGradientDirection}; +use we_css::values::{ + Color, GradientStop, LinearGradient, LinearGradientDirection, RadialGradient, +}; use we_dom::NodeId; use we_image::pixel::Image; use we_platform::metal::{ @@ -20,7 +22,7 @@ use we_platform::metal::{ MTL_PIXEL_FORMAT_BGRA8_UNORM, MTL_PRIMITIVE_TYPE_TRIANGLE, TEXTURE_MODE_COLOR, TEXTURE_MODE_MASK, TEXTURE_MODE_SDF, TEXTURE_MODE_SOLID, }; -use we_style::computed::ObjectFit; +use we_style::computed::{LengthOrAuto, ObjectFit}; use we_text::font::{Font, FontRegistry}; use crate::atlas::{GlyphAtlas, TexturedQuad}; @@ -689,6 +691,29 @@ impl GpuRenderer { ); } + PaintCommand::RadialGradient { + x, + y, + width, + height, + position_x, + position_y, + gradient, + } => { + let tex_key = TextureKey::Solid; + if tex_key != batch_texture && !batch_vertices.is_empty() { + self.flush_batch(&batch_vertices, batch_texture, &encoder); + batch_vertices.clear(); + } + batch_texture = tex_key; + push_radial_gradient_quad( + &mut batch_vertices, + (*x + coord_offset.0, *y + coord_offset.1, *width, *height), + (*position_x, *position_y), + gradient, + ); + } + PaintCommand::RoundedRect { x, y, @@ -1159,7 +1184,7 @@ fn push_linear_gradient_quad( return; } - let stops = resolve_gradient_stops(gradient); + let stops = resolve_linear_gradient_stops(gradient); if stops.is_empty() { return; } @@ -1205,6 +1230,77 @@ fn push_linear_gradient_quad( } } +fn push_radial_gradient_quad( + out: &mut Vec, + rect: (f32, f32, f32, f32), + position: (LengthOrAuto, LengthOrAuto), + gradient: &RadialGradient, +) { + let (x, y, w, h) = rect; + let (position_x, position_y) = position; + if w <= 0.0 || h <= 0.0 { + return; + } + + let stops = resolve_radial_gradient_stops(gradient); + if stops.is_empty() { + return; + } + + let offset_x = crate::background_position_offset(position_x, 0.0, w); + let offset_y = crate::background_position_offset(position_y, 0.0, h); + let image_x = x + offset_x; + let image_y = y + offset_y; + let center_x = image_x + w * 0.5; + let center_y = image_y + h * 0.5; + let radius = [ + (image_x, image_y), + (image_x + w, image_y), + (image_x + w, image_y + h), + (image_x, image_y + h), + ] + .iter() + .map(|(px, py)| (*px - center_x).hypot(*py - center_y)) + .fold(0.0, f32::max); + if radius <= f32::EPSILON { + return; + } + + let cols = ((w / 18.0).ceil() as usize).clamp(8, 96); + let rows = ((h / 18.0).ceil() as usize).clamp(8, 96); + let vertex = |px: f32, py: f32| { + let t = ((px - center_x).hypot(py - center_y) / radius).clamp(0.0, 1.0); + Vertex { + position: [px, py], + color: color_to_f32(&gradient_color_at_resolved(&stops, t)), + tex_coord: [0.0, 0.0], + use_texture: TEXTURE_MODE_SOLID, + ..Vertex::default() + } + }; + + for row in 0..rows { + let y0 = y + h * row as f32 / rows as f32; + let y1 = y + h * (row + 1) as f32 / rows as f32; + for col in 0..cols { + let x0 = x + w * col as f32 / cols as f32; + let x1 = x + w * (col + 1) as f32 / cols as f32; + let top_left = vertex(x0, y0); + let top_right = vertex(x1, y0); + let bottom_right = vertex(x1, y1); + let bottom_left = vertex(x0, y1); + out.extend_from_slice(&[ + top_left, + top_right, + bottom_right, + top_left, + bottom_right, + bottom_left, + ]); + } + } +} + fn gradient_t(direction: LinearGradientDirection, x: f32, y: f32, width: f32, height: f32) -> f32 { match direction { LinearGradientDirection::ToBottom => y, @@ -1234,22 +1330,30 @@ fn diagonal_gradient_t(x: f32, y: f32, width: f32, height: f32, dir_x: f32, dir_ #[cfg(test)] fn gradient_color_at(gradient: &LinearGradient, t: f32) -> Color { - gradient_color_at_resolved(&resolve_gradient_stops(gradient), t) + gradient_color_at_resolved(&resolve_linear_gradient_stops(gradient), t) +} + +fn resolve_linear_gradient_stops(gradient: &LinearGradient) -> Vec { + resolve_gradient_stop_list(&gradient.stops) +} + +fn resolve_radial_gradient_stops(gradient: &RadialGradient) -> Vec { + resolve_gradient_stop_list(&gradient.stops) } -fn resolve_gradient_stops(gradient: &LinearGradient) -> Vec { - let len = gradient.stops.len(); +fn resolve_gradient_stop_list(stops: &[GradientStop]) -> Vec { + let len = stops.len(); if len == 0 { return Vec::new(); } if len == 1 { return vec![ResolvedGradientStop { - color: gradient.stops[0].color, - position: gradient.stops[0].position.unwrap_or(0.0), + color: stops[0].color, + position: stops[0].position.unwrap_or(0.0), }]; } - let mut positions: Vec> = gradient.stops.iter().map(|s| s.position).collect(); + let mut positions: Vec> = stops.iter().map(|s| s.position).collect(); if positions[0].is_none() { positions[0] = Some(0.0); } @@ -1278,8 +1382,7 @@ fn resolve_gradient_stops(gradient: &LinearGradient) -> Vec LengthOrAuto { + if layers.is_empty() { + return fallback; + } + layers[index % layers.len()] +} + fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { // A uniform solid border on a rounded box is painted as a ring by // `paint_background`, so skip the square per-side painter here. Non-uniform @@ -3113,6 +3154,57 @@ body { margin: 0; } assert_eq!(gradient.2.stops[1].color, Color::new(0, 0, 0, 0)); } + #[test] + fn css_radial_gradient_emits_gradient_paint_command() { + let html_str = r#" + + +
+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let tree = we_layout::layout( + &styled, + &doc, + 800.0, + 600.0, + &font, + &std::collections::HashMap::new(), + ); + + let list = build_display_list(&tree); + let gradient = list + .iter() + .find_map(|c| match c { + PaintCommand::RadialGradient { + width, + height, + position_x, + position_y, + gradient, + .. + } => Some((*width, *height, *position_x, *position_y, gradient)), + _ => None, + }) + .expect("radial-gradient should produce a gradient paint command"); + assert!((gradient.0 - 100.0).abs() < 0.5); + assert!((gradient.1 - 50.0).abs() < 0.5); + assert_eq!(gradient.2, LengthOrAuto::Length(480.0)); + assert_eq!(gradient.3, LengthOrAuto::Length(-36.0)); + assert_eq!(gradient.4.stops[0].color, Color::new(87, 41, 255, 59)); + assert_eq!(gradient.4.stops[1].color, Color::new(0, 0, 0, 0)); + } + #[test] fn empty_before_pseudo_element_paints_gradient_box() { let html_str = r#" diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 213ab59..3663c53 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -21,7 +21,7 @@ use we_css::transitions::{ }; use we_css::values::{ expand_shorthand, parse_value, Color, CssValue, LengthUnit, LinearGradient, MathExpr, - TransformLength as CssTransformLength, + RadialGradient, TransformLength as CssTransformLength, }; use we_dom::{Document, NodeData, NodeId}; use we_memory::intern::Atom; @@ -886,6 +886,9 @@ pub struct ComputedStyle { pub background_color: Color, pub background_image: Option, pub background_gradient: Option, + pub background_radial_gradients: Vec, + pub background_position_x_layers: Vec, + pub background_position_y_layers: Vec, pub background_size: BackgroundSize, pub background_position_x: LengthOrAuto, pub background_position_y: LengthOrAuto, @@ -1044,6 +1047,9 @@ impl Default for ComputedStyle { background_color: Color::new(0, 0, 0, 0), // transparent background_image: None, background_gradient: None, + background_radial_gradients: Vec::new(), + background_position_x_layers: Vec::new(), + background_position_y_layers: Vec::new(), background_size: BackgroundSize::Auto, background_position_x: LengthOrAuto::Percentage(0.0), background_position_y: LengthOrAuto::Percentage(0.0), @@ -1131,6 +1137,22 @@ impl ComputedStyle { self.animation.animations.capacity() * std::mem::size_of::(); let box_shadows = self.box_shadows.capacity() * std::mem::size_of::(); let text_shadows = self.text_shadows.capacity() * std::mem::size_of::(); + let gradient_stops = self + .background_gradient + .as_ref() + .map(|g| g.stops.capacity()) + .unwrap_or(0) + + self + .background_radial_gradients + .iter() + .map(|g| g.stops.capacity()) + .sum::(); + let gradient_stops = gradient_stops * std::mem::size_of::(); + let background_layers = self.background_radial_gradients.capacity() + * std::mem::size_of::() + + (self.background_position_x_layers.capacity() + + self.background_position_y_layers.capacity()) + * std::mem::size_of::(); let anim_strings: usize = self .animation .animations @@ -1151,6 +1173,8 @@ impl ComputedStyle { + animations + box_shadows + text_shadows + + gradient_stops + + background_layers + anim_strings + custom_props } @@ -1547,6 +1571,38 @@ fn background_position_from_value( (x, y) } +fn background_position_axis_layers_from_value( + value: &CssValue, + x_axis: bool, + current_font_size: f32, + root_font_size: f32, + viewport: (f32, f32), +) -> Vec { + let values: Vec<&CssValue> = match value { + CssValue::List(values) => values + .iter() + .filter(|v| !matches!(v, CssValue::Keyword(k) if k == ",")) + .collect(), + other => vec![other], + }; + + values + .into_iter() + .map(|value| match value { + CssValue::Keyword(k) => match (x_axis, k.as_str()) { + (true, "left") | (false, "top") => LengthOrAuto::Percentage(0.0), + (true, "right") | (false, "bottom") => LengthOrAuto::Percentage(100.0), + (_, "center") => LengthOrAuto::Percentage(50.0), + _ => LengthOrAuto::Percentage(0.0), + }, + CssValue::Percentage(_) | CssValue::Length(..) | CssValue::Zero | CssValue::Math(_) => { + resolve_layout_length_or_auto(value, current_font_size, root_font_size, viewport) + } + _ => LengthOrAuto::Percentage(0.0), + }) + .collect() +} + fn background_repeat_from_value(value: &CssValue) -> BackgroundRepeat { match value { CssValue::Keyword(k) if k == "no-repeat" => BackgroundRepeat::NoRepeat, @@ -2583,14 +2639,36 @@ fn apply_property( CssValue::Url(url) => { style.background_image = Some(Atom::new(url)); style.background_gradient = None; + style.background_radial_gradients.clear(); } CssValue::LinearGradient(gradient) => { style.background_image = None; style.background_gradient = Some(gradient.clone()); + style.background_radial_gradients.clear(); + } + CssValue::RadialGradient(gradient) => { + style.background_image = None; + style.background_gradient = None; + style.background_radial_gradients = vec![gradient.clone()]; + } + CssValue::List(values) => { + let gradients: Vec = values + .iter() + .filter_map(|value| match value { + CssValue::RadialGradient(gradient) => Some(gradient.clone()), + _ => None, + }) + .collect(); + if !gradients.is_empty() { + style.background_image = None; + style.background_gradient = None; + style.background_radial_gradients = gradients; + } } CssValue::None => { style.background_image = None; style.background_gradient = None; + style.background_radial_gradients.clear(); } _ => {} }, @@ -2603,6 +2681,34 @@ fn apply_property( background_position_from_value(value, current_fs, root_font_size, viewport); style.background_position_x = x; style.background_position_y = y; + style.background_position_x_layers = vec![x]; + style.background_position_y_layers = vec![y]; + } + "background-position-x" => { + let layers = background_position_axis_layers_from_value( + value, + true, + current_fs, + root_font_size, + viewport, + ); + if let Some(position) = layers.last().copied() { + style.background_position_x = position; + style.background_position_x_layers = layers; + } + } + "background-position-y" => { + let layers = background_position_axis_layers_from_value( + value, + false, + current_fs, + root_font_size, + viewport, + ); + if let Some(position) = layers.last().copied() { + style.background_position_y = position; + style.background_position_y_layers = layers; + } } "background-repeat" => { style.background_repeat = background_repeat_from_value(value); @@ -3574,11 +3680,14 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "background-image" => { style.background_image = parent.background_image.clone(); style.background_gradient = parent.background_gradient.clone(); + style.background_radial_gradients = parent.background_radial_gradients.clone(); } "background-size" => style.background_size = parent.background_size, "background-position" => { style.background_position_x = parent.background_position_x; style.background_position_y = parent.background_position_y; + style.background_position_x_layers = parent.background_position_x_layers.clone(); + style.background_position_y_layers = parent.background_position_y_layers.clone(); } "background-repeat" => style.background_repeat = parent.background_repeat, "box-shadow" => style.box_shadows = parent.box_shadows.clone(), @@ -3677,11 +3786,14 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "background-image" => { style.background_image = initial.background_image.clone(); style.background_gradient = initial.background_gradient.clone(); + style.background_radial_gradients = initial.background_radial_gradients.clone(); } "background-size" => style.background_size = initial.background_size, "background-position" => { style.background_position_x = initial.background_position_x; style.background_position_y = initial.background_position_y; + style.background_position_x_layers = initial.background_position_x_layers.clone(); + style.background_position_y_layers = initial.background_position_y_layers.clone(); } "background-repeat" => style.background_repeat = initial.background_repeat, "box-shadow" => style.box_shadows = initial.box_shadows, @@ -4937,6 +5049,52 @@ mod tests { assert!(div_node.style.background_image.is_none()); } + #[test] + fn author_background_radial_gradient_with_layer_position_longhands() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + doc.append_child(body, div); + + let ss = Parser::parse( + "div { + background: radial-gradient(circle, rgba(255,105,15,.23) 0, transparent 47%), + radial-gradient(circle, rgba(87,41,255,.23) 0, transparent 49%); + background-position-x: -60vw, 60vw; + background-position-y: -10vh, -6vh; + background-repeat: no-repeat; + }", + ); + let styled = resolve_styles(&doc, &[ss], (1000.0, 800.0)).unwrap(); + let body_node = &styled.children[0]; + let div_node = &body_node.children[0]; + assert_eq!(div_node.style.background_radial_gradients.len(), 2); + let first = &div_node.style.background_radial_gradients[0]; + let second = &div_node.style.background_radial_gradients[1]; + assert_eq!(first.stops[0].color, Color::new(255, 105, 15, 59)); + assert_eq!(first.stops[1].color, Color::new(0, 0, 0, 0)); + assert_eq!(second.stops[0].color, Color::new(87, 41, 255, 59)); + assert_eq!(second.stops[1].color, Color::new(0, 0, 0, 0)); + assert_eq!( + div_node.style.background_position_x, + LengthOrAuto::Length(600.0) + ); + assert_eq!( + div_node.style.background_position_y, + LengthOrAuto::Length(-48.0) + ); + assert_eq!( + div_node.style.background_position_x_layers, + vec![LengthOrAuto::Length(-600.0), LengthOrAuto::Length(600.0)] + ); + assert_eq!( + div_node.style.background_position_y_layers, + vec![LengthOrAuto::Length(-80.0), LengthOrAuto::Length(-48.0)] + ); + assert_eq!(div_node.style.background_repeat, BackgroundRepeat::NoRepeat); + assert!(div_node.style.background_image.is_none()); + assert!(div_node.style.background_gradient.is_none()); + } + #[test] fn author_box_shadow_parses_multiple_outer_layers() { let (mut doc, _, _, body) = make_doc_with_body();