From 32438ce25f69de99d2523b98237f62cfa90890d7 Mon Sep 17 00:00:00 2001 From: cashmere Date: Tue, 28 Jul 2026 20:14:51 +0200 Subject: feat: port domain crates to Rust (session 1 complete) All green: cargo test --workspace (~300 tests, 0 failures). - timeline (45): placement, resize/ripple, split, snap, retime, audio separation, scene helpers - animation (73): bezier solver, channel sampling, keyframe ops, split/clamp/clone, param definitions - commands (9): Command trait, BatchCommand, history, 19 concrete commands, freeze-frame port 1:1 - audio (22): audible collection, range mixing, mastering limiter, rms/peak buckets - text (19): cosmic-text layout/measure/raster, backgrounds, decorations - graphics (52): full CSS gradient parser, tiny-skia raster, 4 shape definitions - effect-defs (16): blur definition, registry, pass resolution - mask-defs (19): all 9 mask renderers via tiny-skia - renderer (6): node model + scene builder port - desktop: GPUI projects screen wired to ProjectStore - scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps) --- rust/crates/graphics/src/gradient/raster.rs | 549 ++++++++++++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 rust/crates/graphics/src/gradient/raster.rs (limited to 'rust/crates/graphics/src/gradient/raster.rs') diff --git a/rust/crates/graphics/src/gradient/raster.rs b/rust/crates/graphics/src/gradient/raster.rs new file mode 100644 index 00000000..12d30a23 --- /dev/null +++ b/rust/crates/graphics/src/gradient/raster.rs @@ -0,0 +1,549 @@ +use tiny_skia::{ + Color as SkColor, GradientStop, LinearGradient, Paint, Pixmap, Point, RadialGradient, Rect, + Shader, SpreadMode, Transform, +}; + +use super::ast::{ + Distance, GradientAst, GradientOrientation, LinearOrientation, Position, RadialOrientation, + ShapeKind, ShapeStyle, +}; +use super::parser::parse_gradient; +use super::stops::normalize_color_stops; +use crate::color::{Rgba, parse_css_color}; +use crate::error::GraphicsError; + +#[derive(Debug, Clone, PartialEq)] +pub enum BackgroundLayer { + Color(String), + Gradient(GradientAst), +} + +pub fn is_gradient_layer(segment: &str) -> bool { + let names = [ + "linear-gradient", + "repeating-linear-gradient", + "radial-gradient", + "repeating-radial-gradient", + ]; + let mut offset = 0; + let mut rest = segment; + if rest.starts_with('-') { + for vendor in ["-webkit-", "-o-", "-ms-", "-moz-"] { + if rest.len() >= vendor.len() && rest[..vendor.len()].eq_ignore_ascii_case(vendor) { + offset = vendor.len(); + break; + } + } + if offset == 0 { + return false; + } + rest = &rest[offset..]; + } + names + .iter() + .any(|name| rest.len() >= name.len() && rest[..name.len()].eq_ignore_ascii_case(name)) +} + +pub fn split_css_layers(css: &str) -> Vec { + let mut layers = Vec::new(); + let mut current = String::new(); + let mut depth = 0i32; + for ch in css.chars() { + if ch == '(' { + depth += 1; + } + if ch == ')' { + depth = depth.saturating_sub(1); + } + if ch == ',' && depth == 0 { + layers.push(current.trim().to_string()); + current = String::new(); + continue; + } + current.push(ch); + } + if !current.trim().is_empty() { + layers.push(current.trim().to_string()); + } + layers +} + +pub fn parse_background_layers(css: &str) -> Vec { + let mut layers = Vec::new(); + for segment in split_css_layers(css) { + if segment.is_empty() { + continue; + } + if is_gradient_layer(&segment) { + match parse_gradient(segment.trim()) { + Ok(parsed) => { + for gradient in parsed { + layers.push(BackgroundLayer::Gradient(gradient)); + } + continue; + } + Err(_) => { + layers.push(BackgroundLayer::Color(segment)); + continue; + } + } + } + layers.push(BackgroundLayer::Color(segment)); + } + layers +} + +pub fn draw_css_background(pixmap: &mut Pixmap, css: &str) -> Result<(), GraphicsError> { + let width = f64::from(pixmap.width()); + let height = f64::from(pixmap.height()); + let layers = parse_background_layers(css); + for layer in layers.iter().rev() { + match layer { + BackgroundLayer::Color(value) => { + let color = parse_css_color(value) + .ok_or_else(|| GraphicsError::InvalidColor(value.clone()))?; + fill_solid(pixmap, color_to_sk(color)); + } + BackgroundLayer::Gradient(gradient) => { + draw_gradient_layer(pixmap, width, height, gradient)?; + } + } + } + Ok(()) +} + +fn fill_solid(pixmap: &mut Pixmap, color: SkColor) { + let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32) + else { + return; + }; + let paint = Paint { + shader: Shader::SolidColor(color), + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); +} + +fn color_to_sk(color: Rgba) -> SkColor { + SkColor::from_rgba( + color.r.clamp(0.0, 1.0) as f32, + color.g.clamp(0.0, 1.0) as f32, + color.b.clamp(0.0, 1.0) as f32, + color.a.clamp(0.0, 1.0) as f32, + ) + .unwrap_or(SkColor::TRANSPARENT) +} + +fn build_stops( + gradient: &GradientAst, + gradient_length: f64, +) -> Result, GraphicsError> { + let normalized = normalize_color_stops(&gradient.color_stops, gradient_length); + let mut stops = Vec::with_capacity(normalized.len()); + for stop in normalized { + let color = parse_css_color(&stop.color) + .ok_or_else(|| GraphicsError::InvalidColor(stop.color.clone()))?; + stops.push(GradientStop::new(stop.offset as f32, color_to_sk(color))); + } + Ok(stops) +} + +fn full_rect(width: f64, height: f64) -> Option { + Rect::from_xywh(0.0, 0.0, width as f32, height as f32) +} + +fn draw_gradient_layer( + pixmap: &mut Pixmap, + width: f64, + height: f64, + gradient: &GradientAst, +) -> Result<(), GraphicsError> { + if gradient.kind.is_linear() { + draw_linear_gradient(pixmap, width, height, gradient) + } else { + draw_radial_gradient(pixmap, width, height, gradient) + } +} + +struct LinearPoints { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + length: f64, +} + +fn resolve_linear_points( + width: f64, + height: f64, + orientation: Option<&GradientOrientation>, +) -> LinearPoints { + let angle = resolve_linear_angle(orientation); + let radians = angle.to_radians(); + let dx = radians.sin(); + let dy = -radians.cos(); + let center_x = width / 2.0; + let center_y = height / 2.0; + let half_length = ((width * dx).abs() + (height * dy).abs()) / 2.0; + let x0 = center_x - dx * half_length; + let y0 = center_y - dy * half_length; + let x1 = center_x + dx * half_length; + let y1 = center_y + dy * half_length; + let length = (x1 - x0).hypot(y1 - y0); + LinearPoints { x0, y0, x1, y1, length } +} + +fn resolve_linear_angle(orientation: Option<&GradientOrientation>) -> f64 { + let Some(orientation) = orientation else { + return 180.0; + }; + if let GradientOrientation::Linear(linear) = orientation { + match linear { + LinearOrientation::Angular(value) => { + return value.parse::().unwrap_or(180.0); + } + LinearOrientation::Directional(value) => { + return angle_from_directional(value); + } + } + } + 180.0 +} + +fn angle_from_directional(value: &str) -> f64 { + let normalized = value.to_lowercase().replace("to", ""); + let mut dx: f64 = 0.0; + let mut dy: f64 = 0.0; + for part in normalized.split_whitespace() { + match part { + "left" => dx = -1.0, + "right" => dx = 1.0, + "top" => dy = -1.0, + "bottom" => dy = 1.0, + _ => {} + } + } + if dx == 0.0 && dy == 0.0 { + return 180.0; + } + let angle = dx.atan2(-dy).to_degrees(); + (angle + 360.0) % 360.0 +} + +fn draw_linear_gradient( + pixmap: &mut Pixmap, + width: f64, + height: f64, + gradient: &GradientAst, +) -> Result<(), GraphicsError> { + let points = resolve_linear_points(width, height, gradient.orientation.as_ref()); + let stops = build_stops(gradient, points.length)?; + let (Some(shader), Some(rect)) = ( + LinearGradient::new( + Point::from_xy(points.x0 as f32, points.y0 as f32), + Point::from_xy(points.x1 as f32, points.y1 as f32), + stops, + SpreadMode::Pad, + Transform::default(), + ), + full_rect(width, height), + ) else { + return Ok(()); + }; + let paint = Paint { + shader, + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); + Ok(()) +} + +struct RadialDimensions { + cx: f64, + cy: f64, + rx: f64, + ry: f64, +} + +fn resolve_radial_dimensions( + width: f64, + height: f64, + orientation: Option<&GradientOrientation>, +) -> RadialDimensions { + let center_fallback = (width / 2.0, height / 2.0); + let radial = match orientation { + Some(GradientOrientation::Radial(list)) => list.first(), + _ => None, + }; + + let Some(radial) = radial else { + let (rx, ry) = resolve_radial_extents( + width, + height, + center_fallback.0, + center_fallback.1, + ShapeKind::Ellipse, + "farthest-corner", + ); + return RadialDimensions { + cx: center_fallback.0, + cy: center_fallback.1, + rx, + ry, + }; + }; + + match radial { + RadialOrientation::Shape(shape) => { + let (cx, cy) = resolve_radial_center(width, height, shape.at.as_ref()); + if let Some(ShapeStyle::Position(position)) = &shape.style { + let rx = position + .x + .as_ref() + .map(|d| resolve_ellipse_dimension(d, width)) + .unwrap_or(width / 2.0); + let ry = position + .y + .as_ref() + .map(|d| resolve_ellipse_dimension(d, height)) + .unwrap_or(height / 2.0); + return RadialDimensions { cx, cy, rx, ry }; + } + if let Some(ShapeStyle::Distance(distance)) = &shape.style { + let resolved = + resolve_distance_in_pixels(distance, width.max(height)).unwrap_or(0.0); + return RadialDimensions { + cx, + cy, + rx: resolved, + ry: resolved, + }; + } + let extent = match &shape.style { + Some(ShapeStyle::ExtentKeyword(value)) => value.as_str(), + _ => "farthest-corner", + }; + let (rx, ry) = resolve_radial_extents(width, height, cx, cy, shape.value, extent); + RadialDimensions { cx, cy, rx, ry } + } + RadialOrientation::ExtentKeyword { value, at } => { + let (cx, cy) = resolve_radial_center(width, height, at.as_ref()); + let (rx, ry) = + resolve_radial_extents(width, height, cx, cy, ShapeKind::Ellipse, value); + RadialDimensions { cx, cy, rx, ry } + } + RadialOrientation::DefaultRadial { at } => { + let (cx, cy) = resolve_radial_center(width, height, Some(at)); + let (rx, ry) = resolve_radial_extents( + width, + height, + cx, + cy, + ShapeKind::Ellipse, + "farthest-corner", + ); + RadialDimensions { cx, cy, rx, ry } + } + } +} + +fn resolve_radial_center(width: f64, height: f64, position: Option<&Position>) -> (f64, f64) { + let Some(position) = position else { + return (width / 2.0, height / 2.0); + }; + let normalized = normalize_position_keywords(position); + let cx = resolve_position_value(normalized.x.as_ref(), width, true); + let cy = resolve_position_value(normalized.y.as_ref(), height, false); + (cx, cy) +} + +fn normalize_position_keywords(position: &Position) -> Position { + if let (Some(Distance::PositionKeyword(x_kw)), Some(Distance::PositionKeyword(y_kw))) = + (&position.x, &position.y) + { + let x_keyword = x_kw.to_lowercase(); + let y_keyword = y_kw.to_lowercase(); + let x_is_vertical = x_keyword == "top" || x_keyword == "bottom"; + let y_is_horizontal = y_keyword == "left" || y_keyword == "right"; + if x_is_vertical && y_is_horizontal { + return Position { + x: Some(Distance::PositionKeyword(y_keyword)), + y: Some(Distance::PositionKeyword(x_keyword)), + }; + } + } + position.clone() +} + +fn resolve_radial_extents( + width: f64, + height: f64, + cx: f64, + cy: f64, + shape: ShapeKind, + extent: &str, +) -> (f64, f64) { + let left = cx; + let right = width - cx; + let top = cy; + let bottom = height - cy; + + if shape == ShapeKind::Circle { + let distances = [ + left.hypot(top), + right.hypot(top), + left.hypot(bottom), + right.hypot(bottom), + ]; + return match extent { + "closest-side" => (left.min(right).min(top).min(bottom), 0.0), + "farthest-side" => (left.max(right).max(top).max(bottom), 0.0), + "closest-corner" => (distances.iter().cloned().fold(f64::INFINITY, f64::min), 0.0), + _ => (distances.iter().cloned().fold(f64::NEG_INFINITY, f64::max), 0.0), + }; + } + + match extent { + "closest-side" => (left.min(right), top.min(bottom)), + "farthest-side" => (left.max(right), top.max(bottom)), + _ => { + let corners = [(left, top), (right, top), (left, bottom), (right, bottom)]; + let chosen = if extent == "closest-corner" { + corners + .iter() + .min_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1))) + } else { + corners + .iter() + .max_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1))) + }; + let chosen = chosen.copied().unwrap_or((width / 2.0, height / 2.0)); + (chosen.0.abs(), chosen.1.abs()) + } + } +} + +fn resolve_position_value(distance: Option<&Distance>, axis_size: f64, is_x: bool) -> f64 { + let Some(distance) = distance else { + return axis_size / 2.0; + }; + match distance { + Distance::Percent(value) => value + .parse::() + .map(|v| (v / 100.0) * axis_size) + .unwrap_or(axis_size / 2.0), + Distance::PositionKeyword(value) => keyword_to_position(value, axis_size, is_x), + Distance::Px(value) => value.parse::().unwrap_or(axis_size / 2.0), + Distance::Em(value) => value + .parse::() + .map(|v| v * 16.0) + .unwrap_or(axis_size / 2.0), + _ => axis_size / 2.0, + } +} + +fn keyword_to_position(value: &str, axis_size: f64, is_x: bool) -> f64 { + let keyword = value.to_lowercase(); + if keyword == "center" { + return axis_size / 2.0; + } + if is_x { + if keyword == "left" { + return 0.0; + } + if keyword == "right" { + return axis_size; + } + } else { + if keyword == "top" { + return 0.0; + } + if keyword == "bottom" { + return axis_size; + } + } + axis_size / 2.0 +} + +fn resolve_distance_in_pixels(distance: &Distance, axis_size: f64) -> Option { + match distance { + Distance::Percent(value) => value.parse::().ok().map(|v| (v / 100.0) * axis_size), + Distance::Px(value) => value.parse::().ok(), + Distance::Em(value) => value.parse::().ok().map(|v| v * 16.0), + _ => None, + } +} + +fn resolve_ellipse_dimension(distance: &Distance, axis_size: f64) -> f64 { + match distance { + Distance::Percent(value) => value + .parse::() + .map(|v| (v / 100.0) * axis_size) + .unwrap_or(axis_size / 2.0), + Distance::Px(value) => value.parse::().unwrap_or(axis_size / 2.0), + Distance::Em(value) => value + .parse::() + .map(|v| v * 16.0) + .unwrap_or(axis_size / 2.0), + _ => axis_size / 2.0, + } +} + +fn draw_radial_gradient( + pixmap: &mut Pixmap, + width: f64, + height: f64, + gradient: &GradientAst, +) -> Result<(), GraphicsError> { + let dims = resolve_radial_dimensions(width, height, gradient.orientation.as_ref()); + let gradient_length = dims.rx.max(dims.ry); + let stops = build_stops(gradient, gradient_length)?; + + if dims.rx == dims.ry || dims.ry == 0.0 { + let Some(shader) = RadialGradient::new( + Point::from_xy(dims.cx as f32, dims.cy as f32), + Point::from_xy(dims.cx as f32, dims.cy as f32), + dims.rx as f32, + stops, + SpreadMode::Pad, + Transform::default(), + ) else { + return Ok(()); + }; + if let Some(rect) = full_rect(width, height) { + let paint = Paint { + shader, + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); + } + return Ok(()); + } + + if dims.rx <= 0.0 { + return Ok(()); + } + let scale_y = dims.ry / dims.rx; + if !scale_y.is_finite() || scale_y <= 0.0 { + return Ok(()); + } + let transform = Transform::from_row(1.0, 0.0, 0.0, scale_y as f32, dims.cx as f32, dims.cy as f32); + let Some(shader) = RadialGradient::new( + Point::from_xy(0.0, 0.0), + Point::from_xy(0.0, 0.0), + dims.rx as f32, + stops, + SpreadMode::Pad, + transform, + ) else { + return Ok(()); + }; + if let Some(rect) = full_rect(width, height) { + let paint = Paint { + shader, + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); + } + Ok(()) +} -- cgit v1.3.1