Skip to main content

rustmotion_core/engine/
paint_pass.rs

1//! Paint pass — walks the BoxTree post-layout and paints each node.
2//!
3//! Order per node (top-down):
4//!   1. canvas.save()
5//!   2. apply transform (CSS `transform` → Skia matrix)
6//!   3. open opacity layer if `opacity < 1.0`
7//!   4. clip to padding-box if `overflow != visible`
8//!   5. paint outset box-shadow
9//!   6. paint background
10//!   7. paint border (border-radius aware)
11//!   8. delegate component-specific paint via `PaintDispatcher`
12//!   9. recurse children sorted by z-index
13//!  10. canvas.restore()
14//!
15//! The dispatcher hook lets the higher-level crate plug component-specific
16//! paint without coupling `rustmotion-core` to all 51 component types.
17
18use std::cell::RefCell;
19
20use skia_safe::gradient::{self, Colors as GradientColors, Gradient};
21use skia_safe::{
22    canvas::SaveLayerRec, Canvas, ClipOp, Color as SColor, Color4f, Paint, PaintStyle, PathBuilder,
23    Point, RRect, Rect, M44, V3,
24};
25
26use crate::css::style::{
27    Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, Color,
28    CssStyle, Edges, Overflow, TransformFn, TransformOrigin,
29};
30use crate::css::units::{parse_origin_component, LengthContext, LengthPercentage, ParsedLength};
31use crate::engine::box_tree::{BoxKind, BoxNode, NodeId};
32use crate::engine::layout_pass::{BoxLayout, LayoutResult};
33
34/// Frame-level paint context (timing + viewport).
35#[derive(Debug, Clone, Copy)]
36pub struct PaintFrame {
37    pub time: f64,
38    /// Seconds since the start of the *scenario* (of the view, for a `world`
39    /// view), as opposed to `time`, which restarts at every scene.
40    ///
41    /// Only the audio-reactive painters want this: an audio analysis is
42    /// indexed on the scenario's own timeline, so reading it with `time` gave
43    /// a scene starting at t=73 s the analysis at 73 s *into that scene*.
44    /// Everything else — animation progress, reveals, transitions — is
45    /// correctly scene-local and must stay on `time`.
46    pub scenario_time: f64,
47    pub frame_index: u32,
48    pub fps: u32,
49    pub video_width: u32,
50    pub video_height: u32,
51    /// Total duration of the scene in seconds — used by the dispatcher to
52    /// compute animation progress (`time / scene_duration`).
53    pub scene_duration: f64,
54    /// Resolved scene camera for per-plane parallax (issue #90). `Some` only
55    /// when the scene declares a camera AND at least one top-level child has
56    /// an explicit `style.depth` — the paint pass then applies the camera per
57    /// plane (each direct child of the root, scaled by its depth) and the
58    /// caller must NOT apply the global camera transform.
59    pub camera: Option<PlaneCamera>,
60}
61
62/// Scene camera resolved at a fixed time, ready for per-plane application.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct PlaneCamera {
65    pub pan_x: f32,
66    pub pan_y: f32,
67    pub zoom: f32,
68    pub rotation: f32,
69    /// Focal point in frame pixels (already resolved; default = frame centre).
70    pub origin_x: f32,
71    pub origin_y: f32,
72}
73
74/// Apply the scene camera scaled by a plane `depth` (issue #90):
75/// pan' = pan·d, zoom' = 1 + (zoom−1)·d, rotation' = rotation·d, around the
76/// camera origin. `depth == 1` reproduces the global camera matrix exactly;
77/// `depth == 0` is the identity (locked plane). The content-space viewport
78/// clip mirrors the global path's clip-after-camera so plane content is cut
79/// at the scene rectangle exactly like the single-transform path.
80fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) {
81    let zoom = 1.0 + (cam.zoom - 1.0) * depth;
82    let rotation = cam.rotation * depth;
83    let pan_x = cam.pan_x * depth;
84    let pan_y = cam.pan_y * depth;
85
86    canvas.translate(Point::new(cam.origin_x, cam.origin_y));
87    if rotation.abs() > 0.001 {
88        canvas.rotate(rotation, None);
89    }
90    if (zoom - 1.0).abs() > 0.001 {
91        canvas.scale((zoom, zoom));
92    }
93    canvas.translate(Point::new(-cam.origin_x - pan_x, -cam.origin_y - pan_y));
94    canvas.clip_rect(
95        Rect::from_wh(viewport.0, viewport.1),
96        ClipOp::Intersect,
97        true,
98    );
99}
100
101/// Axis-aligned bounding box of a painted node, in device (video-pixel) coords.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct HitRect {
104    pub x: f32,
105    pub y: f32,
106    pub w: f32,
107    pub h: f32,
108}
109
110/// One clickable node: its layout id and on-screen bounding box.
111#[derive(Debug, Clone, Copy, PartialEq)]
112pub struct HitNode {
113    pub node_id: NodeId,
114    pub rect: HitRect,
115}
116
117/// Hit-test map for a single painted frame, in paint order (so later entries
118/// are visually on top).
119pub type HitMap = Vec<HitNode>;
120
121/// A hit enriched with a component kind label, ready for the studio overlay.
122/// `node_id` is stable within a single rendered frame.
123#[derive(Debug, Clone, PartialEq)]
124pub struct EnrichedHit {
125    pub node_id: NodeId,
126    pub kind: String,
127    pub rect: HitRect,
128    /// JSON path relative to the scene's `children`, e.g. "/children/2".
129    pub pointer: Option<String>,
130}
131
132/// Hook to delegate component-specific painting. Implemented by the
133/// higher-level crate that owns the actual `Component` enum.
134pub trait PaintDispatcher {
135    /// Called for `BoxKind::Component(payload)`. Implementations downcast
136    /// `payload` to the concrete component type and paint into `canvas`.
137    /// The canvas is already translated to the content-box origin and
138    /// clipped if `overflow: hidden`.
139    fn dispatch(
140        &self,
141        canvas: &Canvas,
142        payload: &(dyn std::any::Any + Send + Sync),
143        css: &CssStyle,
144        layout: &BoxLayout,
145        frame: &PaintFrame,
146    );
147}
148
149/// No-op dispatcher (useful for tests where only generic box decoration is exercised).
150pub struct NoopDispatcher;
151
152impl PaintDispatcher for NoopDispatcher {
153    fn dispatch(
154        &self,
155        _canvas: &Canvas,
156        _payload: &(dyn std::any::Any + Send + Sync),
157        _css: &CssStyle,
158        _layout: &BoxLayout,
159        _frame: &PaintFrame,
160    ) {
161    }
162}
163
164/// Paint a fully-laid-out box tree onto a Skia canvas.
165pub fn paint_tree(
166    canvas: &Canvas,
167    root: &BoxNode,
168    layout: &LayoutResult,
169    frame: &PaintFrame,
170    dispatcher: &dyn PaintDispatcher,
171) {
172    let ctx = PaintContext {
173        layout,
174        frame,
175        dispatcher,
176        viewport_size: (frame.video_width as f32, frame.video_height as f32),
177        hits: None,
178    };
179    paint_node(canvas, root, &ctx, 0);
180}
181
182/// Like [`paint_tree`] but also returns the per-frame hit-map: the on-screen
183/// bounding box of every component-backed node, in paint order. Used by the
184/// studio for click-to-select; the video render path uses [`paint_tree`].
185pub fn paint_tree_with_hits(
186    canvas: &Canvas,
187    root: &BoxNode,
188    layout: &LayoutResult,
189    frame: &PaintFrame,
190    dispatcher: &dyn PaintDispatcher,
191) -> HitMap {
192    let hits = RefCell::new(Vec::new());
193    let ctx = PaintContext {
194        layout,
195        frame,
196        dispatcher,
197        viewport_size: (frame.video_width as f32, frame.video_height as f32),
198        hits: Some(&hits),
199    };
200    paint_node(canvas, root, &ctx, 0);
201    hits.into_inner()
202}
203
204struct PaintContext<'a> {
205    layout: &'a LayoutResult,
206    frame: &'a PaintFrame,
207    dispatcher: &'a dyn PaintDispatcher,
208    viewport_size: (f32, f32),
209    hits: Option<&'a RefCell<HitMap>>,
210}
211
212/// `tree_depth` counts levels below the synthetic scene root (root = 0,
213/// direct children = 1). Per-plane parallax cameras apply at level 1 only.
214fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: usize) {
215    // Visibility window (start_at/end_at): the node keeps its layout space
216    // but paints nothing — subtree included — outside the window.
217    if let Some(window) = &node.window {
218        if !window.contains(ctx.frame.time) {
219            return;
220        }
221    }
222    let Some(box_layout) = ctx.layout.get(node.id) else {
223        return;
224    };
225    if box_layout.width <= 0.0 || box_layout.height <= 0.0 {
226        return;
227    }
228
229    let length_ctx = LengthContext {
230        viewport_width: ctx.viewport_size.0,
231        viewport_height: ctx.viewport_size.1,
232        parent_size: box_layout.width.max(box_layout.height),
233        font_size: node.css.font_size_px_or(16.0),
234        root_font_size: 16.0,
235    };
236    // Per-axis contexts for `transform`'s translate percentages: CSS
237    // resolves a `translate`/`translate3d` x-component percentage against
238    // the box's own WIDTH and the y-component against its own HEIGHT — never
239    // `max(width, height)` on both axes (that's only correct for square
240    // boxes). Mirrors what `resolve_origin` already does for
241    // `transform-origin` below. `z`/`perspective()` keep the general
242    // (shared) context — CSS has no per-axis convention for them.
243    let length_ctx_x = LengthContext {
244        parent_size: box_layout.width,
245        ..length_ctx
246    };
247    let length_ctx_y = LengthContext {
248        parent_size: box_layout.height,
249        ..length_ctx
250    };
251
252    canvas.save();
253
254    // 1.5 per-plane scene camera (issue #90): every direct child of the scene
255    // root is a plane; its explicit `depth` (default 1.0) scales the camera.
256    // Deeper nodes inherit their plane's transform via the canvas matrix.
257    if tree_depth == 1 {
258        if let Some(cam) = &ctx.frame.camera {
259            let depth = node.css.depth.unwrap_or(1.0);
260            apply_plane_camera(canvas, cam, depth, ctx.viewport_size);
261        }
262    }
263
264    // 2. transform
265    if node.css.transform.is_some() || node.css.perspective.is_some() {
266        let (tx, ty, _tz) =
267            resolve_origin(node.css.transform_origin.as_ref(), box_layout, &length_ctx);
268        let transform_pivot = (tx, ty);
269
270        let perspective_pivot = if node.css.perspective_origin.is_some() {
271            let (px, py, _pz) = resolve_origin(
272                node.css.perspective_origin.as_ref(),
273                box_layout,
274                &length_ctx,
275            );
276            (px, py)
277        } else {
278            transform_pivot
279        };
280
281        let transform_list = node.css.transform.as_deref().unwrap_or(&[]);
282        let perspective_d = node
283            .css
284            .perspective
285            .as_ref()
286            .map(|l| l.resolve(&length_ctx).max(1.0));
287        let axes = TransformAxes {
288            x: length_ctx_x,
289            y: length_ctx_y,
290            general: length_ctx,
291        };
292        apply_transform(
293            canvas,
294            transform_list,
295            perspective_d,
296            transform_pivot,
297            perspective_pivot,
298            &axes,
299        );
300    }
301
302    // Hit-map: record the on-screen bbox of component-backed nodes. The canvas
303    // matrix here already includes this node's and all ancestors' transforms,
304    // so mapping the (absolute) layout rect yields the device-space AABB.
305    // Ghost nodes are intentionally excluded — they are temporal echoes for
306    // motion-blur/trail effects and must never be selectable in the studio.
307    if let (Some(hits), BoxKind::Component(_)) = (ctx.hits, &node.kind) {
308        let local = Rect::from_xywh(
309            box_layout.x,
310            box_layout.y,
311            box_layout.width,
312            box_layout.height,
313        );
314        let dev = canvas.local_to_device_as_3x3().map_rect(local).0;
315        hits.borrow_mut().push(HitNode {
316            node_id: node.id,
317            rect: HitRect {
318                x: dev.left,
319                y: dev.top,
320                w: dev.width(),
321                h: dev.height(),
322            },
323        });
324    }
325
326    // 3. backdrop-filter: filter what is *already painted behind this node*
327    // (earlier siblings, ancestor backgrounds), clipped to its own (rounded)
328    // border-box — the glassmorphism pattern. This must run BEFORE this
329    // node's own opacity/filter layer (step 4) opens: if it ran after (as it
330    // used to), the backdrop's `SaveLayerRec::backdrop()` would sample the
331    // freshly-opened, still-empty opacity layer instead of the real scene
332    // beneath it, making the blur a total no-op the instant `opacity < 1.0`
333    // or a `filter` is also present on the same node — exactly the
334    // glassmorphism + fade_in combination rules/glassmorphism.md recommends.
335    // Self-contained bracket (save/clip/layer/restore/restore): the panel is
336    // baked directly onto the canvas below, so this node's own opacity later
337    // fades its own background/border/content on top of it without
338    // re-fading the panel itself (avoiding a second, unrelated ordering
339    // hazard: a shared clip+layer would also have to stay open across
340    // background/border painting, reintroducing the overflow/shadow bug
341    // fixed below for those steps too).
342    if let Some(filters) = node.css.backdrop_filter.as_deref() {
343        if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) {
344            let radius = node
345                .css
346                .border_radius
347                .as_ref()
348                .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
349                .unwrap_or([0.0; 4]);
350            canvas.save();
351            canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true);
352            let rec = SaveLayerRec::default().backdrop(&backdrop);
353            canvas.save_layer(&rec);
354            canvas.restore();
355            canvas.restore();
356        }
357    }
358
359    // 4. opacity / filter layer — one shared layer carries both the group
360    // alpha and the CSS `filter` chain (applies to the node and its
361    // subtree). Bounded to the node's own box (padded by the filter chain's
362    // blur/drop-shadow bleed so those still bleed past the edge, unclipped):
363    // an unbounded `SaveLayerRec` sizes the layer against the current clip —
364    // usually the whole viewport — so every faded/filtered node allocates
365    // and composites a full-frame layer regardless of how small it is
366    // (measured on this repo's release binary, 1080x1920/60 frames, 30 small
367    // `opacity: 0.5` shapes, `--threads 1`: ~42-60s wall time unbounded vs.
368    // ~0.5s bounded — roughly two orders of magnitude, not a rounding
369    // error; cost scales with viewport area, not node size).
370    let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0);
371    let content_filter = node
372        .css
373        .filter
374        .as_deref()
375        .and_then(|list| filters_to_image_filter(list, &length_ctx));
376    let opened_opacity_layer = if opacity < 1.0 || content_filter.is_some() {
377        let mut paint = Paint::default();
378        if opacity < 1.0 {
379            paint.set_alpha((opacity * 255.0) as u8);
380        }
381        if let Some(filter) = content_filter {
382            paint.set_image_filter(filter);
383        }
384        let bleed = node
385            .css
386            .filter
387            .as_deref()
388            .map(|list| filter_bleed(list, &length_ctx))
389            .unwrap_or(0.0);
390        let bounds = Rect::from_xywh(
391            box_layout.x - bleed,
392            box_layout.y - bleed,
393            box_layout.width + bleed * 2.0,
394            box_layout.height + bleed * 2.0,
395        );
396        let rec = SaveLayerRec::default().paint(&paint).bounds(&bounds);
397        canvas.save_layer(&rec);
398        true
399    } else {
400        false
401    };
402
403    // 5. outset box-shadow, 6. background, 7. border — the box's own
404    // decorations. Painted BEFORE any overflow clip (step 8): CSS `overflow`
405    // clips a box's *descendants*, never the box's own border-box
406    // decorations (an outset box-shadow exists precisely outside the
407    // border-box; background/border are already shaped by border-radius on
408    // their own and gain nothing from an extra clip). They still sit inside
409    // the opacity/filter layer above so a faded node fades its whole
410    // appearance uniformly, background included.
411    if let Some(shadows) = node.css.box_shadow.as_ref() {
412        for shadow in shadows {
413            if shadow.inset.unwrap_or(false) {
414                continue;
415            }
416            paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, false);
417        }
418    }
419    if let Some(bg) = node.css.background.as_ref() {
420        paint_background(canvas, box_layout, &node.css, bg, &length_ctx);
421    }
422    // `gradient-border` replaces the standard border when present (a box
423    // has one border, not two stacked ones).
424    if let Some(gb) = node.css.gradient_border.as_ref() {
425        paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx);
426    } else if let Some(border) = node.css.border.as_ref() {
427        paint_border(canvas, box_layout, &node.css, border, &length_ctx);
428    }
429
430    // 7.5. shimmer layer. The band composites against the pixels this node
431    // paints, which do not exist yet — so an isolated layer is opened here,
432    // filled by steps 9 and 10 below, and the band is stamped onto it with
433    // `SrcATop` just before it closes.
434    let shimmer = active_shimmer(&node.css, ctx.frame.time);
435    let opened_shimmer_layer = if shimmer.is_some() {
436        let bounds = Rect::from_xywh(
437            box_layout.x,
438            box_layout.y,
439            box_layout.width,
440            box_layout.height,
441        );
442        let rec = SaveLayerRec::default().bounds(&bounds);
443        canvas.save_layer(&rec);
444        true
445    } else {
446        false
447    };
448
449    // 8. clip overflow:hidden / clip — scoped to this node's own content and
450    // its children only (see step 5-7's comment for why the box's own
451    // decorations must stay outside this clip).
452    let overflow = node.css.overflow.unwrap_or(Overflow::Visible);
453    let opened_overflow_clip = if matches!(
454        overflow,
455        Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto
456    ) {
457        let radius = node
458            .css
459            .border_radius
460            .as_ref()
461            .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
462            .unwrap_or([0.0; 4]);
463        let rrect = padding_rrect(box_layout, radius);
464        canvas.save();
465        canvas.clip_rrect(rrect, ClipOp::Intersect, true);
466        true
467    } else {
468        false
469    };
470
471    // 9. component-specific content (Ghost is painted identically to Component;
472    // the only difference is that Ghost is excluded from the hit-map above).
473    let payload_opt = match &node.kind {
474        BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p),
475        BoxKind::Container => None,
476    };
477    if let Some(payload) = payload_opt {
478        ctx.dispatcher
479            .dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame);
480    }
481
482    // 10. children (z-index ordered, then source order)
483    let mut indices: Vec<usize> = (0..node.children.len()).collect();
484    indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0));
485    for &i in &indices {
486        paint_node(canvas, &node.children[i], ctx, tree_depth + 1);
487    }
488
489    if opened_overflow_clip {
490        canvas.restore();
491    }
492
493    // inset shadows (after children so they overlay content)
494    if let Some(shadows) = node.css.box_shadow.as_ref() {
495        for shadow in shadows {
496            if shadow.inset.unwrap_or(false) {
497                paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, true);
498            }
499        }
500    }
501
502    if let Some((cfg, progress)) = shimmer {
503        paint_shimmer_band(canvas, box_layout, cfg, progress);
504    }
505    if opened_shimmer_layer {
506        canvas.restore();
507    }
508
509    if opened_opacity_layer {
510        canvas.restore();
511    }
512    canvas.restore();
513}
514
515/// The shimmer effect on this node and how far through its sweep it is at
516/// `time`, or `None` when there is none or the sweep is not running.
517///
518/// Returning `None` outside the sweep window is what keeps the cost off every
519/// other node and every other frame: no window, no isolated layer.
520fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> {
521    let cfg = css.animation.iter().find_map(|e| match e {
522        crate::schema::AnimationEffect::Shimmer(c) => Some(c),
523        _ => None,
524    })?;
525    if cfg.duration <= 0.0 || cfg.intensity <= 0.0 {
526        return None;
527    }
528    let elapsed = time - cfg.delay;
529    if elapsed < 0.0 {
530        return None;
531    }
532    let progress = if cfg.repeat {
533        (elapsed / cfg.duration).rem_euclid(1.0)
534    } else if elapsed > cfg.duration {
535        return None;
536    } else {
537        elapsed / cfg.duration
538    };
539    Some((cfg, progress as f32))
540}
541
542/// Stamp the sweeping band onto the layer built by steps 9-10, restricted to
543/// the pixels that layer actually painted.
544fn paint_shimmer_band(
545    canvas: &Canvas,
546    layout: &BoxLayout,
547    cfg: &crate::schema::ShimmerConfig,
548    progress: f32,
549) {
550    if layout.width <= 0.0 || layout.height <= 0.0 {
551        return;
552    }
553    let (r, g, b, a) = crate::engine::renderer::parse_hex_color(&cfg.color);
554    let peak_alpha = (cfg.intensity.clamp(0.0, 1.0) * a as f32) as u8;
555    let transparent = SColor::from_argb(0, r, g, b);
556    let highlight = SColor::from_argb(peak_alpha, r, g, b);
557
558    // The band's axis, and the extent of the box measured along it. Using the
559    // projected extent rather than the width keeps an angled band sweeping
560    // fully off both ends instead of stopping short on the diagonal.
561    let theta = cfg.angle.to_radians();
562    let (dx, dy) = (theta.cos(), theta.sin());
563    let cx = layout.x + layout.width / 2.0;
564    let cy = layout.y + layout.height / 2.0;
565    let half_extent = (layout.width * dx).abs() / 2.0 + (layout.height * dy).abs() / 2.0;
566
567    let band = (cfg.width.max(0.01) * half_extent * 2.0).max(1.0);
568    // Travel from fully off one end to fully off the other, so the element is
569    // clean at both ends of the sweep rather than starting mid-glint.
570    let start = -half_extent - band;
571    let centre = start + progress * (2.0 * half_extent + 2.0 * band);
572
573    let p0 = Point::new(cx + dx * (centre - band), cy + dy * (centre - band));
574    let p1 = Point::new(cx + dx * (centre + band), cy + dy * (centre + band));
575
576    let colors4f = [
577        Color4f::from(transparent),
578        Color4f::from(highlight),
579        Color4f::from(transparent),
580    ];
581    let gradient_colors = GradientColors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
582    let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
583    let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) else {
584        return;
585    };
586
587    let mut paint = Paint::default();
588    paint.set_style(PaintStyle::Fill);
589    paint.set_anti_alias(true);
590    paint.set_shader(shader);
591    // The whole point: light only the pixels the element itself painted, so
592    // the sheen reads as catching the glyphs rather than as a rectangle
593    // sliding past them.
594    paint.set_blend_mode(skia_safe::BlendMode::SrcATop);
595    canvas.draw_rect(
596        Rect::from_xywh(layout.x, layout.y, layout.width, layout.height),
597        &paint,
598    );
599}
600
601/// Conservative outward bleed (px) a `filter` chain can paint beyond the
602/// node's own box — used to size the opacity/filter layer's `SaveLayerRec`
603/// bounds generously enough that `blur`/`drop-shadow` never get clipped at
604/// the box edge (see the perf fix in step 4 above: an unbounded layer costs
605/// ~5.9x render time, but a *too-tight* one would silently clip filter
606/// bleed, trading a perf bug for a correctness one). `1.5x` the nominal
607/// radius covers the visible falloff of `image_filters::blur`'s Gaussian
608/// (sigma = radius/2, and ~3*sigma is the point the kernel is visually
609/// negligible).
610fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 {
611    use crate::css::style::FilterFn;
612    let mut bleed = 0.0f32;
613    for f in list {
614        let b = match f {
615            FilterFn::Blur { radius } => radius.resolve(ctx).max(0.0) * 1.5,
616            FilterFn::DropShadow {
617                offset_x,
618                offset_y,
619                blur,
620                ..
621            } => {
622                let blur_bleed = blur
623                    .as_ref()
624                    .map(|b| b.resolve(ctx).max(0.0) * 1.5)
625                    .unwrap_or(0.0);
626                offset_x.resolve(ctx).abs().max(offset_y.resolve(ctx).abs()) + blur_bleed
627            }
628            _ => 0.0,
629        };
630        bleed = bleed.max(b);
631    }
632    bleed
633}
634
635// ---- CSS filters ----
636
637/// Build a Skia `ImageFilter` chain from a CSS `filter`/`backdrop-filter`
638/// list. Color functions use the CSS Filter Effects spec matrices; Skia's
639/// `color_filters::matrix_row_major` expects the translation column in
640/// normalized 0..1 space (verified by `css_filter_invert_flips_colors`).
641fn filters_to_image_filter(
642    list: &[crate::css::style::FilterFn],
643    ctx: &LengthContext,
644) -> Option<skia_safe::ImageFilter> {
645    use crate::css::style::FilterFn;
646    use skia_safe::image_filters;
647
648    let mut chain: Option<skia_safe::ImageFilter> = None;
649    for f in list {
650        chain = match f {
651            FilterFn::Blur { radius } => {
652                let r = radius.resolve(ctx).max(0.0);
653                if r <= 0.0 {
654                    chain
655                } else {
656                    image_filters::blur((r / 2.0, r / 2.0), skia_safe::TileMode::Clamp, chain, None)
657                }
658            }
659            FilterFn::DropShadow {
660                offset_x,
661                offset_y,
662                blur,
663                color,
664            } => {
665                let sigma = blur.as_ref().map(|b| b.resolve(ctx) / 2.0).unwrap_or(0.0);
666                let c = color.as_ref().map(parse_color).unwrap_or(SColor::BLACK);
667                image_filters::drop_shadow(
668                    (offset_x.resolve(ctx), offset_y.resolve(ctx)),
669                    (sigma, sigma),
670                    c,
671                    None,
672                    chain,
673                    None,
674                )
675            }
676            FilterFn::Noise { intensity, seed } => {
677                match noise_image_filter(*intensity, *seed) {
678                    // Sequential CSS composition: the chain so far is the
679                    // background, the grain layer blends on top of it.
680                    Some(noise) => image_filters::blend(
681                        skia_safe::BlendMode::Overlay,
682                        chain,
683                        Some(noise),
684                        None,
685                    ),
686                    None => chain,
687                }
688            }
689            other => color_matrix_for(other)
690                .map(|m| skia_safe::color_filters::matrix_row_major(&m, None))
691                .and_then(|cf| image_filters::color_filter(cf, chain, None)),
692        };
693    }
694    chain
695}
696
697/// Build the film-grain layer for `FilterFn::Noise` as an `ImageFilter`.
698///
699/// Composition formula:
700///   1. `fractal_noise(base_frequency = (0.9, 0.9), octaves = 2, seed)` —
701///      high frequency ⇒ fine per-pixel grain; the Skia Perlin shader is a
702///      pure function of (x, y, seed): no implicit time, so the grain is
703///      byte-identical across frames/renders for a given seed.
704///   2. A 4×5 color matrix collapses RGB to luminance (0.213/0.715/0.072) for
705///      monochrome grain and scales alpha by `intensity` (0..1).
706///   3. The caller blends the result over the chain input with
707///      `BlendMode::Overlay` — grain brightens/darkens the underlying pixels
708///      proportionally to the noise-layer alpha (`intensity`).
709fn noise_image_filter(intensity: f32, seed: u64) -> Option<skia_safe::ImageFilter> {
710    use skia_safe::image_filters;
711
712    let intensity = intensity.clamp(0.0, 1.0);
713    if intensity <= 0.0 {
714        return None;
715    }
716    let noise = skia_safe::shaders::fractal_noise((0.9, 0.9), 2, seed as f32, None)?;
717    // Luminance conversion + alpha scaling in one matrix.
718    let (r, g, b) = (0.213, 0.715, 0.072);
719    #[rustfmt::skip]
720    let m = [
721        r,   g,   b,   0.0,       0.0,
722        r,   g,   b,   0.0,       0.0,
723        r,   g,   b,   0.0,       0.0,
724        0.0, 0.0, 0.0, intensity, 0.0,
725    ];
726    let cf = skia_safe::color_filters::matrix_row_major(&m, None);
727    let mono = noise.with_color_filter(cf);
728    image_filters::shader(mono, None)
729}
730
731/// 4x5 row-major color matrix for a CSS color filter function (translation
732/// column in normalized 0..1 space), or `None` for the non-matrix functions.
733fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> {
734    use crate::css::style::FilterFn;
735    #[rustfmt::skip]
736    fn saturation(s: f32) -> [f32; 20] {
737        // Luminance weights per the CSS Filter Effects spec.
738        let (r, g, b) = (0.213, 0.715, 0.072);
739        [
740            r + (1.0 - r) * s, g * (1.0 - s),       b * (1.0 - s),       0.0, 0.0,
741            r * (1.0 - s),     g + (1.0 - g) * s,   b * (1.0 - s),       0.0, 0.0,
742            r * (1.0 - s),     g * (1.0 - s),       b + (1.0 - b) * s,   0.0, 0.0,
743            0.0,               0.0,                 0.0,                 1.0, 0.0,
744        ]
745    }
746    match f {
747        FilterFn::Brightness { value } => {
748            let v = value.max(0.0);
749            #[rustfmt::skip]
750            let m = [
751                v, 0.0, 0.0, 0.0, 0.0,
752                0.0, v, 0.0, 0.0, 0.0,
753                0.0, 0.0, v, 0.0, 0.0,
754                0.0, 0.0, 0.0, 1.0, 0.0,
755            ];
756            Some(m)
757        }
758        FilterFn::Contrast { value } => {
759            let v = value.max(0.0);
760            let t = (1.0 - v) / 2.0;
761            #[rustfmt::skip]
762            let m = [
763                v, 0.0, 0.0, 0.0, t,
764                0.0, v, 0.0, 0.0, t,
765                0.0, 0.0, v, 0.0, t,
766                0.0, 0.0, 0.0, 1.0, 0.0,
767            ];
768            Some(m)
769        }
770        FilterFn::Saturate { value } => Some(saturation(value.max(0.0))),
771        FilterFn::Grayscale { value } => Some(saturation(1.0 - value.clamp(0.0, 1.0))),
772        FilterFn::HueRotate { deg } => {
773            let (sin, cos) = deg.to_radians().sin_cos();
774            let (r, g, b) = (0.213, 0.715, 0.072);
775            #[rustfmt::skip]
776            let m = [
777                r + cos * (1.0 - r) + sin * (-r),      g + cos * (-g) + sin * (-g),       b + cos * (-b) + sin * (1.0 - b), 0.0, 0.0,
778                r + cos * (-r) + sin * 0.143,          g + cos * (1.0 - g) + sin * 0.140, b + cos * (-b) + sin * (-0.283),  0.0, 0.0,
779                r + cos * (-r) + sin * (-(1.0 - r)),   g + cos * (-g) + sin * g,          b + cos * (1.0 - b) + sin * b,    0.0, 0.0,
780                0.0,                                   0.0,                               0.0,                              1.0, 0.0,
781            ];
782            Some(m)
783        }
784        FilterFn::Invert { value } => {
785            let v = value.clamp(0.0, 1.0);
786            let s = 1.0 - 2.0 * v;
787            let t = v;
788            #[rustfmt::skip]
789            let m = [
790                s, 0.0, 0.0, 0.0, t,
791                0.0, s, 0.0, 0.0, t,
792                0.0, 0.0, s, 0.0, t,
793                0.0, 0.0, 0.0, 1.0, 0.0,
794            ];
795            Some(m)
796        }
797        FilterFn::Sepia { value } => {
798            let v = value.clamp(0.0, 1.0);
799            let lerp = |a: f32, b: f32| a + (b - a) * v;
800            #[rustfmt::skip]
801            let m = [
802                lerp(1.0, 0.393), lerp(0.0, 0.769), lerp(0.0, 0.189), 0.0, 0.0,
803                lerp(0.0, 0.349), lerp(1.0, 0.686), lerp(0.0, 0.168), 0.0, 0.0,
804                lerp(0.0, 0.272), lerp(0.0, 0.534), lerp(1.0, 0.131), 0.0, 0.0,
805                0.0,              0.0,              0.0,              1.0, 0.0,
806            ];
807            Some(m)
808        }
809        FilterFn::Opacity { value } => {
810            let v = value.clamp(0.0, 1.0);
811            #[rustfmt::skip]
812            let m = [
813                1.0, 0.0, 0.0, 0.0, 0.0,
814                0.0, 1.0, 0.0, 0.0, 0.0,
815                0.0, 0.0, 1.0, 0.0, 0.0,
816                0.0, 0.0, 0.0, v, 0.0,
817            ];
818            Some(m)
819        }
820        FilterFn::Blur { .. } | FilterFn::DropShadow { .. } | FilterFn::Noise { .. } => None,
821    }
822}
823
824// ---- Transform ----
825
826/// Resolve a `transform-origin` / `perspective-origin` value to absolute
827/// viewport coordinates `(x, y)`.
828///
829/// # Resolution rules
830///
831/// - Absent `origin` → centre of the box (the pre-existing hard-coded behaviour,
832///   preserved byte-identically).
833/// - `x` / `y` values follow the CSS spec: percentages are relative to the box
834///   **width** (for x) and **height** (for y); px values are relative to the
835///   box top-left corner. The result is in absolute viewport coordinates.
836/// - Keywords (`left`, `center`, `right`, `top`, `bottom`) are handled by
837///   `parse_origin_component` which maps them to 0%/50%/100%.
838/// - An absent component (`None`) defaults to 50% on that axis.
839/// - The `z` component is returned as `f32` for use in the 3-D path (defaults
840///   to 0.0 when absent).
841fn resolve_origin(
842    origin: Option<&TransformOrigin>,
843    layout: &BoxLayout,
844    ctx: &LengthContext,
845) -> (f32, f32, f32) {
846    let Some(o) = origin else {
847        // Default: 50% 50% 0 — dead-centre of the box.
848        return (
849            layout.x + layout.width / 2.0,
850            layout.y + layout.height / 2.0,
851            0.0,
852        );
853    };
854
855    // Resolve x against box width.
856    let ox = if let Some(lp) = &o.x {
857        let parsed = match lp {
858            LengthPercentage::String(s) => {
859                parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0))
860            }
861            LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v),
862        };
863        let local_ctx = LengthContext {
864            parent_size: layout.width,
865            ..*ctx
866        };
867        layout.x + parsed.resolve(&local_ctx).unwrap_or(layout.width / 2.0)
868    } else {
869        layout.x + layout.width / 2.0
870    };
871
872    // Resolve y against box height.
873    let oy = if let Some(lp) = &o.y {
874        let parsed = match lp {
875            LengthPercentage::String(s) => {
876                parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0))
877            }
878            LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v),
879        };
880        let local_ctx = LengthContext {
881            parent_size: layout.height,
882            ..*ctx
883        };
884        layout.y + parsed.resolve(&local_ctx).unwrap_or(layout.height / 2.0)
885    } else {
886        layout.y + layout.height / 2.0
887    };
888
889    // Resolve z (optional; only used in 3D path).
890    let oz = o.z.as_ref().map(|l| l.resolve(ctx)).unwrap_or(0.0);
891
892    (ox, oy, oz)
893}
894
895fn has_3d_transform(list: &[TransformFn]) -> bool {
896    list.iter().any(|t| {
897        matches!(
898            t,
899            TransformFn::RotateX { .. }
900                | TransformFn::RotateY { .. }
901                | TransformFn::Rotate3d { .. }
902                | TransformFn::Scale3d { .. }
903                | TransformFn::ScaleZ { .. }
904                | TransformFn::TranslateZ { .. }
905                | TransformFn::Perspective { .. }
906                | TransformFn::Matrix3d { .. }
907        )
908    })
909}
910
911/// Per-axis length-resolution contexts for `transform`. CSS resolves a
912/// `translate`/`translate3d` percentage's x-component against the box's own
913/// width and its y-component against its own height — never `max(width,
914/// height)` on both axes (see `apply_transform`/`transform_to_m44`).
915/// `z`/`perspective()` have no established per-axis CSS convention, so they
916/// keep the general (pre-existing) context.
917#[derive(Clone, Copy)]
918struct TransformAxes {
919    x: LengthContext,
920    y: LengthContext,
921    general: LengthContext,
922}
923
924/// Apply CSS transform + perspective to the canvas.
925///
926/// # Parameters
927///
928/// - `transform_pivot`: the origin for the `transform` property (resolved from
929///   `transform-origin`, defaults to box centre).
930/// - `perspective_pivot`: the origin for the perspective projection (resolved
931///   from `perspective-origin`). When `perspective-origin` is absent this equals
932///   `transform_pivot` and we use the cheaper single-translate path. When they
933///   differ we bracket the perspective matrix with its own translate pair.
934fn apply_transform(
935    canvas: &Canvas,
936    list: &[TransformFn],
937    perspective_d: Option<f32>,
938    transform_pivot: (f32, f32),
939    perspective_pivot: (f32, f32),
940    axes: &TransformAxes,
941) {
942    // Detect whether perspective and transform pivots differ.
943    let pivots_equal = (transform_pivot.0 - perspective_pivot.0).abs() < 0.001
944        && (transform_pivot.1 - perspective_pivot.1).abs() < 0.001;
945
946    if perspective_d.is_none() && !has_3d_transform(list) {
947        // Fast path: 2D-only, use the native Skia 2D canvas API.
948        let pivot = transform_pivot;
949        canvas.translate(Point::new(pivot.0, pivot.1));
950        for tr in list {
951            match tr {
952                TransformFn::Translate { x, y } => {
953                    canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
954                }
955                TransformFn::TranslateX { x } => {
956                    canvas.translate(Point::new(x.resolve(&axes.x), 0.0));
957                }
958                TransformFn::TranslateY { y } => {
959                    canvas.translate(Point::new(0.0, y.resolve(&axes.y)));
960                }
961                TransformFn::Translate3d { x, y, .. } => {
962                    canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
963                }
964                TransformFn::Scale { x, y } => {
965                    canvas.scale((*x, *y));
966                }
967                TransformFn::ScaleX { x } => {
968                    canvas.scale((*x, 1.0));
969                }
970                TransformFn::ScaleY { y } => {
971                    canvas.scale((1.0, *y));
972                }
973                TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => {
974                    canvas.rotate(*deg, None);
975                }
976                TransformFn::Skew { x, y } => {
977                    canvas.skew((x.to_radians().tan(), y.to_radians().tan()));
978                }
979                TransformFn::SkewX { x } => {
980                    canvas.skew((x.to_radians().tan(), 0.0));
981                }
982                TransformFn::SkewY { y } => {
983                    canvas.skew((0.0, y.to_radians().tan()));
984                }
985                TransformFn::Matrix { values: v } => {
986                    let m = skia_safe::Matrix::new_all(
987                        v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0,
988                    );
989                    canvas.concat(&m);
990                }
991                _ => {}
992            }
993        }
994        canvas.translate(Point::new(-pivot.0, -pivot.1));
995    } else if pivots_equal {
996        // 3D path, single-pivot (fast): perspective + transforms share the same pivot.
997        // Structure: T(pivot) · Persp · Transforms · T(-pivot)
998        let pivot = transform_pivot;
999        let mut m = M44::new_identity();
1000        m.pre_concat(&M44::translate(pivot.0, pivot.1, 0.0));
1001        if let Some(d) = perspective_d {
1002            m.pre_concat(&css_perspective_m44(d));
1003        }
1004        for tr in list {
1005            m.pre_concat(&transform_to_m44(tr, axes));
1006        }
1007        m.pre_concat(&M44::translate(-pivot.0, -pivot.1, 0.0));
1008        canvas.concat_44(&m);
1009    } else {
1010        // 3D path, dual-pivot: perspective-origin ≠ transform-origin.
1011        // Structure: T(pp) · Persp · T(-pp) · T(tp) · Transforms · T(-tp)
1012        //
1013        // This matches the CSS spec where `perspective-origin` shifts the
1014        // vanishing point while `transform-origin` sets the local pivot.
1015        let tp = transform_pivot;
1016        let pp = perspective_pivot;
1017        let mut m = M44::new_identity();
1018
1019        // Outer perspective bracket (perspective-origin).
1020        m.pre_concat(&M44::translate(pp.0, pp.1, 0.0));
1021        if let Some(d) = perspective_d {
1022            m.pre_concat(&css_perspective_m44(d));
1023        }
1024        m.pre_concat(&M44::translate(-pp.0, -pp.1, 0.0));
1025
1026        // Inner transform bracket (transform-origin).
1027        m.pre_concat(&M44::translate(tp.0, tp.1, 0.0));
1028        for tr in list {
1029            m.pre_concat(&transform_to_m44(tr, axes));
1030        }
1031        m.pre_concat(&M44::translate(-tp.0, -tp.1, 0.0));
1032
1033        canvas.concat_44(&m);
1034    }
1035}
1036
1037/// CSS `perspective(d)` projection matrix in row-major form.
1038/// Maps (x, y, z, 1) → w' = 1 - z/d; perspective divide yields depth scaling.
1039fn css_perspective_m44(d: f32) -> M44 {
1040    M44::row_major(&[
1041        1.0,
1042        0.0,
1043        0.0,
1044        0.0,
1045        0.0,
1046        1.0,
1047        0.0,
1048        0.0,
1049        0.0,
1050        0.0,
1051        1.0,
1052        0.0,
1053        0.0,
1054        0.0,
1055        -1.0 / d,
1056        1.0,
1057    ])
1058}
1059
1060fn transform_to_m44(tr: &TransformFn, axes: &TransformAxes) -> M44 {
1061    match tr {
1062        TransformFn::Translate { x, y } => {
1063            M44::translate(x.resolve(&axes.x), y.resolve(&axes.y), 0.0)
1064        }
1065        TransformFn::TranslateX { x } => M44::translate(x.resolve(&axes.x), 0.0, 0.0),
1066        TransformFn::TranslateY { y } => M44::translate(0.0, y.resolve(&axes.y), 0.0),
1067        TransformFn::TranslateZ { z } => M44::translate(0.0, 0.0, z.resolve(&axes.general)),
1068        TransformFn::Translate3d { x, y, z } => M44::translate(
1069            x.resolve(&axes.x),
1070            y.resolve(&axes.y),
1071            z.resolve(&axes.general),
1072        ),
1073        TransformFn::Scale { x, y } => M44::scale(*x, *y, 1.0),
1074        TransformFn::ScaleX { x } => M44::scale(*x, 1.0, 1.0),
1075        TransformFn::ScaleY { y } => M44::scale(1.0, *y, 1.0),
1076        TransformFn::ScaleZ { z } => M44::scale(1.0, 1.0, *z),
1077        TransformFn::Scale3d { x, y, z } => M44::scale(*x, *y, *z),
1078        TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => {
1079            M44::rotate(V3::new(0.0, 0.0, 1.0), deg.to_radians())
1080        }
1081        TransformFn::RotateX { deg } => M44::rotate(V3::new(1.0, 0.0, 0.0), deg.to_radians()),
1082        TransformFn::RotateY { deg } => M44::rotate(V3::new(0.0, 1.0, 0.0), deg.to_radians()),
1083        TransformFn::Rotate3d { x, y, z, deg } => {
1084            M44::rotate(V3::new(*x, *y, *z), deg.to_radians())
1085        }
1086        TransformFn::Skew { x, y } => M44::row_major(&[
1087            1.0,
1088            x.to_radians().tan(),
1089            0.0,
1090            0.0,
1091            y.to_radians().tan(),
1092            1.0,
1093            0.0,
1094            0.0,
1095            0.0,
1096            0.0,
1097            1.0,
1098            0.0,
1099            0.0,
1100            0.0,
1101            0.0,
1102            1.0,
1103        ]),
1104        TransformFn::SkewX { x } => M44::row_major(&[
1105            1.0,
1106            x.to_radians().tan(),
1107            0.0,
1108            0.0,
1109            0.0,
1110            1.0,
1111            0.0,
1112            0.0,
1113            0.0,
1114            0.0,
1115            1.0,
1116            0.0,
1117            0.0,
1118            0.0,
1119            0.0,
1120            1.0,
1121        ]),
1122        TransformFn::SkewY { y } => M44::row_major(&[
1123            1.0,
1124            0.0,
1125            0.0,
1126            0.0,
1127            y.to_radians().tan(),
1128            1.0,
1129            0.0,
1130            0.0,
1131            0.0,
1132            0.0,
1133            1.0,
1134            0.0,
1135            0.0,
1136            0.0,
1137            0.0,
1138            1.0,
1139        ]),
1140        TransformFn::Perspective { length } => {
1141            css_perspective_m44(length.resolve(&axes.general).max(1.0))
1142        }
1143        TransformFn::Matrix { values: v } => M44::row_major(&[
1144            v[0], v[2], 0.0, v[4], v[1], v[3], 0.0, v[5], 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
1145        ]),
1146        TransformFn::Matrix3d { values: v } => M44::col_major(v),
1147    }
1148}
1149
1150// ---- Background ----
1151
1152fn paint_background(
1153    canvas: &Canvas,
1154    layout: &BoxLayout,
1155    css: &CssStyle,
1156    bg: &Background,
1157    ctx: &LengthContext,
1158) {
1159    let radius = css
1160        .border_radius
1161        .as_ref()
1162        .map(|r| resolve_border_radius(r, layout, ctx))
1163        .unwrap_or([0.0; 4]);
1164    let rrect = padding_rrect(layout, radius);
1165
1166    match bg {
1167        Background::Color(c) => {
1168            let mut paint = Paint::default();
1169            paint.set_anti_alias(true);
1170            paint.set_color(parse_color(c));
1171            canvas.draw_rrect(rrect, &paint);
1172        }
1173        Background::Single(layer) => paint_bg_layer(canvas, &rrect, layer),
1174        Background::Layers(layers) => {
1175            // Painted bottom-up per CSS spec (last layer = bottom).
1176            for layer in layers.iter().rev() {
1177                paint_bg_layer(canvas, &rrect, layer);
1178            }
1179        }
1180    }
1181}
1182
1183fn paint_bg_layer(canvas: &Canvas, rrect: &RRect, layer: &BackgroundLayer) {
1184    let mut paint = Paint::default();
1185    paint.set_anti_alias(true);
1186    match layer {
1187        BackgroundLayer::Color { color } => {
1188            paint.set_color(parse_color(color));
1189            canvas.draw_rrect(rrect, &paint);
1190        }
1191        BackgroundLayer::LinearGradient { angle, stops } => {
1192            let bounds = rrect.bounds();
1193            let (p0, p1) = gradient_endpoints(*bounds, angle.unwrap_or(180.0));
1194            let (colors, positions) = gradient_stops(stops);
1195            let gradient_colors =
1196                GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
1197            let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1198            if let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) {
1199                paint.set_shader(shader);
1200                canvas.draw_rrect(rrect, &paint);
1201            }
1202        }
1203        BackgroundLayer::RadialGradient { stops, .. } => {
1204            let bounds = rrect.bounds();
1205            let center = Point::new(
1206                bounds.left + bounds.width() / 2.0,
1207                bounds.top + bounds.height() / 2.0,
1208            );
1209            let radius = bounds.width().max(bounds.height()) / 2.0;
1210            let (colors, positions) = gradient_stops(stops);
1211            let gradient_colors =
1212                GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
1213            let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1214            if let Some(shader) = gradient::shaders::radial_gradient((center, radius), &grad, None)
1215            {
1216                paint.set_shader(shader);
1217                canvas.draw_rrect(rrect, &paint);
1218            }
1219        }
1220        BackgroundLayer::ConicGradient { stops, .. } => {
1221            // Skia has SweepGradient = conic.
1222            let bounds = rrect.bounds();
1223            let center = Point::new(
1224                bounds.left + bounds.width() / 2.0,
1225                bounds.top + bounds.height() / 2.0,
1226            );
1227            let (colors, positions) = gradient_stops(stops);
1228            let gradient_colors =
1229                GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
1230            let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1231            // `None` angles on the deprecated API defaulted to a full 0..360
1232            // sweep; the new signature makes that range mandatory.
1233            if let Some(shader) =
1234                gradient::shaders::sweep_gradient(center, (0.0, 360.0), &grad, None)
1235            {
1236                paint.set_shader(shader);
1237                canvas.draw_rrect(rrect, &paint);
1238            }
1239        }
1240        BackgroundLayer::Image { .. } => {
1241            // TODO: image background — needs a resource resolver.
1242        }
1243    }
1244}
1245
1246fn gradient_stops(stops: &[crate::css::style::GradientStop]) -> (Vec<Color4f>, Vec<f32>) {
1247    let mut colors = Vec::with_capacity(stops.len());
1248    let mut positions = Vec::with_capacity(stops.len());
1249    let n = stops.len().max(1);
1250    for (i, s) in stops.iter().enumerate() {
1251        colors.push(Color4f::from(parse_color(&s.color)));
1252        let default_offset = i as f32 / (n.saturating_sub(1).max(1) as f32);
1253        positions.push(s.offset.unwrap_or(default_offset));
1254    }
1255    (colors, positions)
1256}
1257
1258fn gradient_endpoints(bounds: Rect, angle_deg: f32) -> (Point, Point) {
1259    // CSS angle: 0deg = bottom→top, increasing clockwise.
1260    let cx = bounds.left + bounds.width() / 2.0;
1261    let cy = bounds.top + bounds.height() / 2.0;
1262    let rad = (angle_deg - 180.0).to_radians();
1263    let (sin_a, cos_a) = (rad.sin(), -rad.cos());
1264    let len = (bounds.width().abs() * sin_a.abs() + bounds.height().abs() * cos_a.abs()) / 2.0;
1265    let p0 = Point::new(cx - sin_a * len, cy - cos_a * len);
1266    let p1 = Point::new(cx + sin_a * len, cy + cos_a * len);
1267    (p0, p1)
1268}
1269
1270// ---- Border ----
1271
1272fn paint_border(
1273    canvas: &Canvas,
1274    layout: &BoxLayout,
1275    css: &CssStyle,
1276    border: &BorderEdges,
1277    ctx: &LengthContext,
1278) {
1279    // Uniform fast-path: same width on all sides + same color + solid style.
1280    let style = border.style.unwrap_or(BorderStyle::Solid);
1281    if matches!(style, BorderStyle::None) {
1282        return;
1283    }
1284    let color = border
1285        .color
1286        .as_ref()
1287        .map(parse_color)
1288        .unwrap_or(SColor::BLACK);
1289
1290    // Compute per-side widths (already resolved into BoxLayout.border by taffy).
1291    let widths = layout.border;
1292    let max_w = widths
1293        .top
1294        .max(widths.right)
1295        .max(widths.bottom)
1296        .max(widths.left);
1297    if max_w <= 0.0 {
1298        return;
1299    }
1300
1301    let radius = css
1302        .border_radius
1303        .as_ref()
1304        .map(|r| resolve_border_radius(r, layout, ctx))
1305        .unwrap_or([0.0; 4]);
1306
1307    // Outer rrect (border box) and inner rrect (padding box).
1308    let outer = border_rrect(layout, radius);
1309    let inner = inner_rrect(layout, radius);
1310
1311    let mut paint = Paint::default();
1312    paint.set_anti_alias(true);
1313    paint.set_style(PaintStyle::Fill);
1314    paint.set_color(color);
1315
1316    // Use DRRect = outer minus inner for an accurate stroked border with radius.
1317    canvas.draw_drrect(outer, inner, &paint);
1318}
1319
1320/// Paint a gradient-colored border ring (issue #87).
1321///
1322/// Painted **instead of** the standard `border` when both are set. Unlike
1323/// `border`, `gradient-border` is a pure paint decoration: it does not consume
1324/// layout space (taffy never sees it), the ring is inset from the border-box
1325/// edge by `gb.width`.
1326///
1327/// The gradient is linear along `gb.angle` with the **same angle convention as
1328/// `background` linear gradients** (see [`gradient_endpoints`]) so the two
1329/// stay visually consistent within one style block. Colors are evenly spaced.
1330fn paint_gradient_border(
1331    canvas: &Canvas,
1332    layout: &BoxLayout,
1333    css: &CssStyle,
1334    gb: &crate::schema::GradientBorder,
1335    ctx: &LengthContext,
1336) {
1337    if gb.colors.len() < 2 || gb.width <= 0.0 {
1338        return;
1339    }
1340    let width = gb.width.min(layout.width / 2.0).min(layout.height / 2.0);
1341
1342    let radius = css
1343        .border_radius
1344        .as_ref()
1345        .map(|r| resolve_border_radius(r, layout, ctx))
1346        .unwrap_or([0.0; 4]);
1347
1348    // Outer ring edge = border box; inner edge = inset by the border width.
1349    let outer = border_rrect(layout, radius);
1350    let inner_rect = Rect::from_xywh(
1351        layout.x + width,
1352        layout.y + width,
1353        (layout.width - width * 2.0).max(0.0),
1354        (layout.height - width * 2.0).max(0.0),
1355    );
1356    let inner_radius = [
1357        (radius[0] - width).max(0.0),
1358        (radius[1] - width).max(0.0),
1359        (radius[2] - width).max(0.0),
1360        (radius[3] - width).max(0.0),
1361    ];
1362    let inner = rrect_from_corners(inner_rect, inner_radius);
1363
1364    let colors: Vec<Color4f> = gb
1365        .colors
1366        .iter()
1367        .map(|c| Color4f::from(parse_color_string(c).unwrap_or_else(|| unresolved_color(c))))
1368        .collect();
1369    let n = colors.len();
1370    let positions: Vec<f32> = (0..n)
1371        .map(|i| i as f32 / (n.saturating_sub(1).max(1) as f32))
1372        .collect();
1373
1374    let bounds = outer.bounds();
1375    let (p0, p1) = gradient_endpoints(*bounds, gb.angle);
1376    let gradient_colors =
1377        GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
1378    let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1379    let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) else {
1380        return;
1381    };
1382
1383    let mut paint = Paint::default();
1384    paint.set_anti_alias(true);
1385    paint.set_style(PaintStyle::Fill);
1386    paint.set_shader(shader);
1387    canvas.draw_drrect(outer, inner, &paint);
1388}
1389
1390fn border_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
1391    let rect = Rect::from_xywh(layout.x, layout.y, layout.width, layout.height);
1392    rrect_from_corners(rect, radius)
1393}
1394
1395fn padding_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
1396    let (x, y, w, h) = layout.padding_box();
1397    let rect = Rect::from_xywh(x, y, w, h);
1398    // Inner radius: max(0, outer_radius - border_width).
1399    let r = [
1400        (radius[0] - layout.border.left.max(layout.border.top)).max(0.0),
1401        (radius[1] - layout.border.right.max(layout.border.top)).max(0.0),
1402        (radius[2] - layout.border.right.max(layout.border.bottom)).max(0.0),
1403        (radius[3] - layout.border.left.max(layout.border.bottom)).max(0.0),
1404    ];
1405    rrect_from_corners(rect, r)
1406}
1407
1408fn inner_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
1409    padding_rrect(layout, radius)
1410}
1411
1412fn rrect_from_corners(rect: Rect, radius: [f32; 4]) -> RRect {
1413    // Order: top-left, top-right, bottom-right, bottom-left.
1414    let radii = [
1415        Point::new(radius[0], radius[0]),
1416        Point::new(radius[1], radius[1]),
1417        Point::new(radius[2], radius[2]),
1418        Point::new(radius[3], radius[3]),
1419    ];
1420    RRect::new_rect_radii(rect, &radii)
1421}
1422
1423fn resolve_border_radius(r: &BorderRadius, layout: &BoxLayout, ctx: &LengthContext) -> [f32; 4] {
1424    let mut local_ctx = *ctx;
1425    local_ctx.parent_size = layout.width.min(layout.height);
1426    match r {
1427        BorderRadius::Uniform(v) => {
1428            let p = v.resolve(&local_ctx);
1429            [p, p, p, p]
1430        }
1431        BorderRadius::Corners {
1432            top_left,
1433            top_right,
1434            bottom_right,
1435            bottom_left,
1436        } => [
1437            top_left.resolve(&local_ctx),
1438            top_right.resolve(&local_ctx),
1439            bottom_right.resolve(&local_ctx),
1440            bottom_left.resolve(&local_ctx),
1441        ],
1442    }
1443}
1444
1445// ---- Box-shadow ----
1446
1447fn paint_box_shadow(
1448    canvas: &Canvas,
1449    layout: &BoxLayout,
1450    css: &CssStyle,
1451    shadow: &BoxShadow,
1452    ctx: &LengthContext,
1453    inset: bool,
1454) {
1455    let dx = shadow.offset_x.resolve(ctx);
1456    let dy = shadow.offset_y.resolve(ctx);
1457    let blur = shadow.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0);
1458    let spread = shadow
1459        .spread
1460        .as_ref()
1461        .map(|b| b.resolve(ctx))
1462        .unwrap_or(0.0);
1463    let color = shadow
1464        .color
1465        .as_ref()
1466        .map(parse_color)
1467        .unwrap_or(SColor::BLACK);
1468
1469    let radius = css
1470        .border_radius
1471        .as_ref()
1472        .map(|r| resolve_border_radius(r, layout, ctx))
1473        .unwrap_or([0.0; 4]);
1474
1475    let mut paint = Paint::default();
1476    paint.set_anti_alias(true);
1477    paint.set_color(color);
1478    if blur > 0.0 {
1479        if let Some(filter) =
1480            skia_safe::MaskFilter::blur(skia_safe::BlurStyle::Normal, blur / 2.0, None)
1481        {
1482            paint.set_mask_filter(filter);
1483        }
1484    }
1485
1486    if !inset {
1487        let rect = Rect::from_xywh(
1488            layout.x + dx - spread,
1489            layout.y + dy - spread,
1490            layout.width + spread * 2.0,
1491            layout.height + spread * 2.0,
1492        );
1493        let rrect = rrect_from_corners(rect, radius);
1494        canvas.draw_rrect(rrect, &paint);
1495    } else {
1496        // Inset: invert — paint the area outside the inner rect within the box.
1497        // Approximation: draw a stroked rrect inside the padding box.
1498        let (px, py, pw, ph) = layout.padding_box();
1499        let outer = rrect_from_corners(Rect::from_xywh(px, py, pw, ph), radius);
1500        canvas.save();
1501        canvas.clip_rrect(outer, ClipOp::Intersect, true);
1502        let inner_rect = Rect::from_xywh(
1503            px + dx + spread,
1504            py + dy + spread,
1505            (pw - spread * 2.0).max(0.0),
1506            (ph - spread * 2.0).max(0.0),
1507        );
1508        let inner = rrect_from_corners(inner_rect, radius);
1509        let mut clear = Paint::default();
1510        clear.set_color(color);
1511        clear.set_anti_alias(true);
1512        if blur > 0.0 {
1513            if let Some(filter) =
1514                skia_safe::MaskFilter::blur(skia_safe::BlurStyle::Normal, blur / 2.0, None)
1515            {
1516                clear.set_mask_filter(filter);
1517            }
1518        }
1519        // Cheap approximation — TODO: proper inset shadow with subtraction path.
1520        let mut path = PathBuilder::new();
1521        path.add_rrect(outer, None, None);
1522        path.add_rrect(inner, None, None);
1523        path.set_fill_type(skia_safe::PathFillType::EvenOdd);
1524        canvas.draw_path(&path.detach(), &clear);
1525        canvas.restore();
1526    }
1527}
1528
1529// ---- Color parsing ----
1530//
1531// Both functions below route through `renderer::parse_css_color` — the
1532// single frozen entry point (see `renderer/colors.rs`) — rather than
1533// duplicating hex/rgb/hsl/named-colour parsing here. That parser accepts
1534// every common CSS colour form (3/4/6/8-digit hex, rgb()/rgba(), hsl()/
1535// hsla(), the full CSS named-colour set) and returns `None` for anything
1536// else.
1537//
1538// A `Color::String` that fails to parse is a real authoring bug — the
1539// previous behaviour (`unwrap_or(SColor::BLACK)`) rendered it as invisible
1540// text on this tool's dark-background target style. It now falls back to
1541// `renderer::UNRESOLVED_COLOR` (opaque magenta) and logs a warning instead,
1542// so the failure is visible on screen and in the render logs. Wiring a
1543// hard validation-time error for this is out of scope here (sibling
1544// workstream); this only stops the render path from lying.
1545
1546pub fn parse_color(c: &Color) -> SColor {
1547    match c {
1548        Color::Rgba { r, g, b, a } => {
1549            let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8;
1550            SColor::from_argb(alpha, *r, *g, *b)
1551        }
1552        Color::String(s) => parse_color_string(s).unwrap_or_else(|| unresolved_color(s)),
1553    }
1554}
1555
1556fn parse_color_string(s: &str) -> Option<SColor> {
1557    crate::engine::renderer::parse_css_color(s).map(|(r, g, b, a)| SColor::from_argb(a, r, g, b))
1558}
1559
1560/// Loud, non-black fallback for a colour string that couldn't be resolved.
1561/// See the module note above `parse_color`.
1562fn unresolved_color(original: &str) -> SColor {
1563    eprintln!(
1564        "Warning: unrecognized color '{original}' — rendering as opaque magenta instead of \
1565         silently falling back to black"
1566    );
1567    let (r, g, b, a) = crate::engine::renderer::UNRESOLVED_COLOR;
1568    SColor::from_argb(a, r, g, b)
1569}
1570
1571// Suppress unused import warnings for items only used in trait-bound paths.
1572#[allow(dead_code)]
1573fn _unused_marker(_e: &Edges, _l: &LengthPercentage, _p: &ParsedLength, _f: Color4f) {}
1574
1575#[cfg(test)]
1576mod hit_tests {
1577    use super::*;
1578    use std::sync::Arc;
1579
1580    use crate::css::style::{CssStyle, Display, FlexDirection, Position, Size as CSize};
1581    use crate::css::taffy_bridge::ConversionContext;
1582    use crate::css::units::LengthPercentage as CLP;
1583    use crate::engine::box_tree::{BoxKind, BoxNode};
1584    use crate::engine::layout_pass::run_layout;
1585
1586    fn test_frame(w: u32, h: u32) -> PaintFrame {
1587        PaintFrame {
1588            time: 0.0,
1589            scenario_time: 0.0,
1590            frame_index: 0,
1591            fps: 30,
1592            video_width: w,
1593            video_height: h,
1594            scene_duration: 1.0,
1595            camera: None,
1596        }
1597    }
1598
1599    #[test]
1600    fn hitmap_reports_component_rect_for_untransformed_node() {
1601        // A component leaf, absolutely positioned at (40, 30), sized 100x80,
1602        // inside a flex-column root that fills a 400x400 viewport.
1603        let leaf = BoxNode {
1604            id: 0,
1605            kind: BoxKind::Component(Arc::new(1u32)),
1606            css: CssStyle {
1607                position: Some(Position::Absolute),
1608                left: Some(CLP::Px(40.0)),
1609                top: Some(CLP::Px(30.0)),
1610                width: Some(CSize::Length(CLP::Px(100.0))),
1611                height: Some(CSize::Length(CLP::Px(80.0))),
1612                ..Default::default()
1613            },
1614            children: vec![],
1615            intrinsic: None,
1616            source_path: None,
1617            window: None,
1618        };
1619        let mut root = BoxNode {
1620            id: 0,
1621            kind: BoxKind::Container,
1622            css: CssStyle {
1623                display: Some(Display::Flex),
1624                flex_direction: Some(FlexDirection::Column),
1625                width: Some(CSize::Length(CLP::Px(400.0))),
1626                height: Some(CSize::Length(CLP::Px(400.0))),
1627                ..Default::default()
1628            },
1629            children: vec![leaf],
1630            intrinsic: None,
1631            source_path: None,
1632            window: None,
1633        };
1634        root.assign_ids(0);
1635
1636        let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
1637        let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
1638        let hits = paint_tree_with_hits(
1639            surface.canvas(),
1640            &root,
1641            &layout,
1642            &test_frame(400, 400),
1643            &NoopDispatcher,
1644        );
1645
1646        // Only the component leaf is reported, not the synthetic container root.
1647        assert_eq!(hits.len(), 1, "expected exactly one component hit");
1648        let h = &hits[0];
1649        assert_eq!(h.node_id, root.children[0].id);
1650        assert!((h.rect.x - 40.0).abs() < 0.5, "x = {}", h.rect.x);
1651        assert!((h.rect.y - 30.0).abs() < 0.5, "y = {}", h.rect.y);
1652        assert!((h.rect.w - 100.0).abs() < 0.5, "w = {}", h.rect.w);
1653        assert!((h.rect.h - 80.0).abs() < 0.5, "h = {}", h.rect.h);
1654    }
1655
1656    #[test]
1657    fn backdrop_filter_blurs_content_behind() {
1658        use crate::css::style::{Background, Color as CssColor, FilterFn};
1659        use crate::css::units::Length;
1660
1661        // Top half black on a white root; a backdrop-blur panel straddles
1662        // the boundary. Inside the panel the boundary must smear into greys;
1663        // outside it stays a hard black/white edge.
1664        let black_top = BoxNode {
1665            id: 0,
1666            kind: BoxKind::Container,
1667            css: CssStyle {
1668                position: Some(Position::Absolute),
1669                left: Some(CLP::Px(0.0)),
1670                top: Some(CLP::Px(0.0)),
1671                width: Some(CSize::Length(CLP::Px(200.0))),
1672                height: Some(CSize::Length(CLP::Px(100.0))),
1673                background: Some(Background::Color(CssColor::String("#000000".into()))),
1674                ..Default::default()
1675            },
1676            children: vec![],
1677            intrinsic: None,
1678            source_path: None,
1679            window: None,
1680        };
1681        let panel = BoxNode {
1682            id: 0,
1683            kind: BoxKind::Container,
1684            css: CssStyle {
1685                position: Some(Position::Absolute),
1686                left: Some(CLP::Px(50.0)),
1687                top: Some(CLP::Px(50.0)),
1688                width: Some(CSize::Length(CLP::Px(100.0))),
1689                height: Some(CSize::Length(CLP::Px(100.0))),
1690                backdrop_filter: Some(vec![FilterFn::Blur {
1691                    radius: Length::Px(10.0),
1692                }]),
1693                ..Default::default()
1694            },
1695            children: vec![],
1696            intrinsic: None,
1697            source_path: None,
1698            window: None,
1699        };
1700        let mut root = BoxNode {
1701            id: 0,
1702            kind: BoxKind::Container,
1703            css: CssStyle {
1704                display: Some(Display::Flex),
1705                width: Some(CSize::Length(CLP::Px(200.0))),
1706                height: Some(CSize::Length(CLP::Px(200.0))),
1707                background: Some(Background::Color(CssColor::String("#ffffff".into()))),
1708                ..Default::default()
1709            },
1710            children: vec![black_top, panel],
1711            intrinsic: None,
1712            source_path: None,
1713            window: None,
1714        };
1715        root.assign_ids(0);
1716
1717        let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default());
1718        let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap();
1719        paint_tree(
1720            surface.canvas(),
1721            &root,
1722            &layout,
1723            &test_frame(200, 200),
1724            &NoopDispatcher,
1725        );
1726
1727        let info = skia_safe::ImageInfo::new(
1728            (200, 200),
1729            skia_safe::ColorType::RGBA8888,
1730            skia_safe::AlphaType::Unpremul,
1731            None,
1732        );
1733        let mut buf = vec![0u8; 200 * 200 * 4];
1734        assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0)));
1735        let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32;
1736
1737        // Outside the panel: hard edge preserved.
1738        assert!(red(10, 97) < 10, "outside/above must stay black");
1739        assert!(red(10, 103) > 245, "outside/below must stay white");
1740        // Inside the panel: boundary smeared to intermediate greys.
1741        let above = red(100, 97);
1742        let below = red(100, 103);
1743        assert!(
1744            above > 30,
1745            "backdrop not blurred above boundary (r={above})"
1746        );
1747        assert!(
1748            below < 225,
1749            "backdrop not blurred below boundary (r={below})"
1750        );
1751    }
1752
1753    #[test]
1754    fn backdrop_filter_survives_sibling_opacity_below_one() {
1755        // Same scene as `backdrop_filter_blurs_content_behind`, but the panel
1756        // also carries `opacity: 0.99` — a visually-imperceptible change that
1757        // must NOT disable the blur. Bug: the opacity/filter SaveLayerRec
1758        // (paint_pass step 3) used to open BEFORE the backdrop-filter's own
1759        // save_layer(backdrop) (old step 5.5), so the backdrop sampled the
1760        // freshly-opened, still-empty opacity layer instead of the real
1761        // scene beneath it — a total no-op. `opacity` alone (no
1762        // `backdrop_filter`) is not the trigger; only nodes that combine
1763        // both are affected, which is exactly the documented glassmorphism
1764        // template (glassmorphism.md pairs `backdrop-filter` with a
1765        // `fade_in`/`fade_in_up` entrance animation that drives `opacity`).
1766        use crate::css::style::{Background, Color as CssColor, FilterFn};
1767        use crate::css::units::Length;
1768
1769        let black_top = BoxNode {
1770            id: 0,
1771            kind: BoxKind::Container,
1772            css: CssStyle {
1773                position: Some(Position::Absolute),
1774                left: Some(CLP::Px(0.0)),
1775                top: Some(CLP::Px(0.0)),
1776                width: Some(CSize::Length(CLP::Px(200.0))),
1777                height: Some(CSize::Length(CLP::Px(100.0))),
1778                background: Some(Background::Color(CssColor::String("#000000".into()))),
1779                ..Default::default()
1780            },
1781            children: vec![],
1782            intrinsic: None,
1783            source_path: None,
1784            window: None,
1785        };
1786        let panel = BoxNode {
1787            id: 0,
1788            kind: BoxKind::Container,
1789            css: CssStyle {
1790                position: Some(Position::Absolute),
1791                left: Some(CLP::Px(50.0)),
1792                top: Some(CLP::Px(50.0)),
1793                width: Some(CSize::Length(CLP::Px(100.0))),
1794                height: Some(CSize::Length(CLP::Px(100.0))),
1795                backdrop_filter: Some(vec![FilterFn::Blur {
1796                    radius: Length::Px(10.0),
1797                }]),
1798                opacity: Some(0.99),
1799                ..Default::default()
1800            },
1801            children: vec![],
1802            intrinsic: None,
1803            source_path: None,
1804            window: None,
1805        };
1806        let mut root = BoxNode {
1807            id: 0,
1808            kind: BoxKind::Container,
1809            css: CssStyle {
1810                display: Some(Display::Flex),
1811                width: Some(CSize::Length(CLP::Px(200.0))),
1812                height: Some(CSize::Length(CLP::Px(200.0))),
1813                background: Some(Background::Color(CssColor::String("#ffffff".into()))),
1814                ..Default::default()
1815            },
1816            children: vec![black_top, panel],
1817            intrinsic: None,
1818            source_path: None,
1819            window: None,
1820        };
1821        root.assign_ids(0);
1822
1823        let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default());
1824        let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap();
1825        paint_tree(
1826            surface.canvas(),
1827            &root,
1828            &layout,
1829            &test_frame(200, 200),
1830            &NoopDispatcher,
1831        );
1832
1833        let info = skia_safe::ImageInfo::new(
1834            (200, 200),
1835            skia_safe::ColorType::RGBA8888,
1836            skia_safe::AlphaType::Unpremul,
1837            None,
1838        );
1839        let mut buf = vec![0u8; 200 * 200 * 4];
1840        assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0)));
1841        let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32;
1842
1843        // Outside the panel: hard edge preserved.
1844        assert!(red(10, 97) < 10, "outside/above must stay black");
1845        assert!(red(10, 103) > 245, "outside/below must stay white");
1846        // Inside the panel: boundary must still smear into greys — the bug
1847        // makes this a hard 0/255 edge identical to the outside columns.
1848        let above = red(100, 97);
1849        let below = red(100, 103);
1850        assert!(
1851            above > 30,
1852            "backdrop not blurred above boundary with opacity:0.99 (r={above})"
1853        );
1854        assert!(
1855            below < 225,
1856            "backdrop not blurred below boundary with opacity:0.99 (r={below})"
1857        );
1858    }
1859
1860    #[test]
1861    fn hitmap_reflects_node_transform() {
1862        use crate::css::style::TransformFn;
1863
1864        // Same leaf as the untransformed test, but with transform: scale(2).
1865        // The engine applies transforms around the node's center, so a 100x80
1866        // box scaled 2x grows to 200x160 (centered on the same point).
1867        let leaf = BoxNode {
1868            id: 0,
1869            kind: BoxKind::Component(Arc::new(1u32)),
1870            css: CssStyle {
1871                position: Some(Position::Absolute),
1872                left: Some(CLP::Px(40.0)),
1873                top: Some(CLP::Px(30.0)),
1874                width: Some(CSize::Length(CLP::Px(100.0))),
1875                height: Some(CSize::Length(CLP::Px(80.0))),
1876                transform: Some(vec![TransformFn::Scale { x: 2.0, y: 2.0 }]),
1877                ..Default::default()
1878            },
1879            children: vec![],
1880            intrinsic: None,
1881            source_path: None,
1882            window: None,
1883        };
1884        let mut root = BoxNode {
1885            id: 0,
1886            kind: BoxKind::Container,
1887            css: CssStyle {
1888                display: Some(Display::Flex),
1889                flex_direction: Some(FlexDirection::Column),
1890                width: Some(CSize::Length(CLP::Px(400.0))),
1891                height: Some(CSize::Length(CLP::Px(400.0))),
1892                ..Default::default()
1893            },
1894            children: vec![leaf],
1895            intrinsic: None,
1896            source_path: None,
1897            window: None,
1898        };
1899        root.assign_ids(0);
1900
1901        let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
1902        let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
1903        let hits = paint_tree_with_hits(
1904            surface.canvas(),
1905            &root,
1906            &layout,
1907            &test_frame(400, 400),
1908            &NoopDispatcher,
1909        );
1910
1911        assert_eq!(hits.len(), 1);
1912        let h = &hits[0];
1913        // Scaled 2x: width 100->200, height 80->160. Assert the dimensions
1914        // (these prove the canvas transform is reflected in the hit rect).
1915        assert!((h.rect.w - 200.0).abs() < 1.0, "w = {}", h.rect.w);
1916        assert!((h.rect.h - 160.0).abs() < 1.0, "h = {}", h.rect.h);
1917    }
1918}
1919
1920#[cfg(test)]
1921mod transform_origin_tests {
1922    //! TDD tests for `resolve_origin` and the pivot integration in `paint_node`.
1923    //!
1924    //! Strategy: paint a coloured rect, read back pixel centroids / columns, and
1925    //! verify that the transformed position matches the expected pivot behaviour.
1926
1927    use super::*;
1928
1929    use crate::css::style::{
1930        Background, Color as CssColor, CssStyle, Display, FlexDirection, Position, Size as CSize,
1931        TransformFn, TransformOrigin,
1932    };
1933    use crate::css::taffy_bridge::ConversionContext;
1934    use crate::css::units::LengthPercentage as CLP;
1935    use crate::engine::box_tree::{BoxKind, BoxNode};
1936    use crate::engine::layout_pass::run_layout;
1937
1938    fn test_frame(w: u32, h: u32) -> PaintFrame {
1939        PaintFrame {
1940            time: 0.0,
1941            scenario_time: 0.0,
1942            frame_index: 0,
1943            fps: 30,
1944            video_width: w,
1945            video_height: h,
1946            scene_duration: 1.0,
1947            camera: None,
1948        }
1949    }
1950
1951    /// Render a tree and return the pixel buffer (RGBA8888).
1952    fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
1953        root.assign_ids(0);
1954        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
1955        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
1956        paint_tree(
1957            surface.canvas(),
1958            root,
1959            &layout,
1960            &test_frame(w, h),
1961            &NoopDispatcher,
1962        );
1963        let info = skia_safe::ImageInfo::new(
1964            (w as i32, h as i32),
1965            skia_safe::ColorType::RGBA8888,
1966            skia_safe::AlphaType::Unpremul,
1967            None,
1968        );
1969        let mut buf = vec![0u8; (w * h * 4) as usize];
1970        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
1971        buf
1972    }
1973
1974    /// Red channel at pixel (x, y) in a w-wide buffer.
1975    fn r(buf: &[u8], w: u32, x: u32, y: u32) -> u8 {
1976        buf[((y * w + x) * 4) as usize]
1977    }
1978
1979    /// Count pixels in `buf` where red > 200 (i.e., predominantly red).
1980    fn count_red(buf: &[u8]) -> usize {
1981        buf.chunks(4)
1982            .filter(|px| px[0] > 200 && px[1] < 50 && px[2] < 50)
1983            .count()
1984    }
1985
1986    /// Compute column centroid (weighted x) of pixels where red > 200.
1987    fn red_centroid_x(buf: &[u8], w: u32, h: u32) -> f32 {
1988        let mut sum_x = 0.0f64;
1989        let mut count = 0.0f64;
1990        for y in 0..h {
1991            for x in 0..w {
1992                if r(buf, w, x, y) > 200
1993                    && buf[((y * w + x) * 4 + 1) as usize] < 50
1994                    && buf[((y * w + x) * 4 + 2) as usize] < 50
1995                {
1996                    sum_x += x as f64;
1997                    count += 1.0;
1998                }
1999            }
2000        }
2001        if count == 0.0 {
2002            0.0
2003        } else {
2004            (sum_x / count) as f32
2005        }
2006    }
2007
2008    /// Compute row centroid (weighted y) of pixels where red > 200.
2009    fn red_centroid_y(buf: &[u8], w: u32, h: u32) -> f32 {
2010        let mut sum_y = 0.0f64;
2011        let mut count = 0.0f64;
2012        for y in 0..h {
2013            for x in 0..w {
2014                if r(buf, w, x, y) > 200
2015                    && buf[((y * w + x) * 4 + 1) as usize] < 50
2016                    && buf[((y * w + x) * 4 + 2) as usize] < 50
2017                {
2018                    sum_y += y as f64;
2019                    count += 1.0;
2020                }
2021            }
2022        }
2023        if count == 0.0 {
2024            0.0
2025        } else {
2026            (sum_y / count) as f32
2027        }
2028    }
2029
2030    fn red_box(position: Position, x: f32, y: f32, w: f32, h: f32) -> BoxNode {
2031        BoxNode {
2032            id: 0,
2033            kind: BoxKind::Container,
2034            css: CssStyle {
2035                position: Some(position),
2036                left: Some(CLP::Px(x)),
2037                top: Some(CLP::Px(y)),
2038                width: Some(CSize::Length(CLP::Px(w))),
2039                height: Some(CSize::Length(CLP::Px(h))),
2040                background: Some(Background::Color(CssColor::String("#ff0000".into()))),
2041                ..Default::default()
2042            },
2043            children: vec![],
2044            intrinsic: None,
2045            source_path: None,
2046            window: None,
2047        }
2048    }
2049
2050    fn root_node(w: f32, h: f32, children: Vec<BoxNode>) -> BoxNode {
2051        BoxNode {
2052            id: 0,
2053            kind: BoxKind::Container,
2054            css: CssStyle {
2055                display: Some(Display::Flex),
2056                flex_direction: Some(FlexDirection::Column),
2057                width: Some(CSize::Length(CLP::Px(w))),
2058                height: Some(CSize::Length(CLP::Px(h))),
2059                ..Default::default()
2060            },
2061            children,
2062            intrinsic: None,
2063            source_path: None,
2064            window: None,
2065        }
2066    }
2067
2068    // ---- Test 1: no transform — pixel-identical to reference baseline ----
2069
2070    #[test]
2071    fn no_transform_origin_unchanged_non_regression() {
2072        // A red 100x100 box at (50, 50) with no transform: pixels must appear
2073        // exactly at (50..150, 50..150) — no origin logic involved.
2074        let mut root = root_node(
2075            300.0,
2076            300.0,
2077            vec![{
2078                let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
2079                n.css.transform = Some(vec![TransformFn::Scale { x: 1.0, y: 1.0 }]);
2080                // transform-origin absent → should default to center (no change)
2081                n
2082            }],
2083        );
2084        let buf = render_pixels(&mut root, 300, 300);
2085
2086        // Centroid must be ~100, ~100 (center of the 50..150 range).
2087        let cx = red_centroid_x(&buf, 300, 300);
2088        let cy = red_centroid_y(&buf, 300, 300);
2089        assert!((cx - 99.5).abs() < 2.0, "cx={cx}");
2090        assert!((cy - 99.5).abs() < 2.0, "cy={cy}");
2091    }
2092
2093    // ---- Test 2: 50% 50% == absent (byte-identical behavior) ----
2094
2095    #[test]
2096    fn transform_origin_50pct_is_center_identity() {
2097        let make_root = |with_origin: bool| -> Vec<u8> {
2098            let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
2099            n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]);
2100            if with_origin {
2101                n.css.transform_origin = Some(TransformOrigin {
2102                    x: Some(CLP::String("50%".into())),
2103                    y: Some(CLP::String("50%".into())),
2104                    z: None,
2105                });
2106            }
2107            let mut root = root_node(300.0, 300.0, vec![n]);
2108            render_pixels(&mut root, 300, 300)
2109        };
2110
2111        let without = make_root(false);
2112        let with_50 = make_root(true);
2113        assert_eq!(
2114            without, with_50,
2115            "transform-origin: 50% 50% must be byte-identical to absent origin"
2116        );
2117    }
2118
2119    // ---- Test 3: rotate 90° around left-top vs center — different quadrants ----
2120
2121    #[test]
2122    fn rotate_90_left_top_vs_center_occupy_different_quadrants() {
2123        // A red 100x100 box at (100, 100) rotated 90° around:
2124        //   - center (150, 150): box stays centered on itself, centroid ~(150, 150)
2125        //   - left-top (100, 100): the box pivots around top-left; centroid moves
2126        let make_root_with_origin = |ox: Option<CLP>, oy: Option<CLP>| -> Vec<u8> {
2127            let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0);
2128            n.css.transform = Some(vec![TransformFn::Rotate { deg: 90.0 }]);
2129            if ox.is_some() || oy.is_some() {
2130                n.css.transform_origin = Some(TransformOrigin {
2131                    x: ox,
2132                    y: oy,
2133                    z: None,
2134                });
2135            }
2136            let mut root = root_node(400.0, 400.0, vec![n]);
2137            render_pixels(&mut root, 400, 400)
2138        };
2139
2140        // Center pivot (default).
2141        let buf_center = make_root_with_origin(None, None);
2142        // Left-top pivot: x=0px (relative to box left), y=0px.
2143        let buf_left_top = make_root_with_origin(
2144            Some(CLP::String("0%".into())),
2145            Some(CLP::String("0%".into())),
2146        );
2147
2148        let cx_center = red_centroid_x(&buf_center, 400, 400);
2149        let cy_center = red_centroid_y(&buf_center, 400, 400);
2150        let cx_lt = red_centroid_x(&buf_left_top, 400, 400);
2151        let cy_lt = red_centroid_y(&buf_left_top, 400, 400);
2152
2153        // With center pivot (150, 150), rotate 90° CW → box centroid stays at ~(150, 150).
2154        assert!(
2155            (cx_center - 150.0).abs() < 5.0,
2156            "center-pivot cx should be ~150, got {cx_center}"
2157        );
2158        assert!(
2159            (cy_center - 150.0).abs() < 5.0,
2160            "center-pivot cy should be ~150, got {cy_center}"
2161        );
2162
2163        // With left-top pivot (100, 100), rotating 90° CW around that point:
2164        //   box center (150,150) maps to (50, 150) — centroid moves left.
2165        //   cx_lt ≈ 50, while cx_center ≈ 150. The x-shift is the distinguishing axis.
2166        assert!(
2167            cx_lt < cx_center - 50.0,
2168            "left-top pivot cx ({cx_lt}) should be well left of center-pivot cx ({cx_center})"
2169        );
2170        // The overall pixel-centroid distance should be large.
2171        let dist = ((cx_lt - cx_center).powi(2) + (cy_lt - cy_center).powi(2)).sqrt();
2172        assert!(
2173            dist > 50.0,
2174            "pivots should produce clearly distinct positions (dist={dist})"
2175        );
2176    }
2177
2178    // ---- Test 4: keyword "left top" == "0% 0%" ----
2179
2180    #[test]
2181    fn keyword_left_top_equals_zero_percent() {
2182        let make_root = |origin_x: CLP, origin_y: CLP| -> Vec<u8> {
2183            let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0);
2184            n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]);
2185            n.css.transform_origin = Some(TransformOrigin {
2186                x: Some(origin_x),
2187                y: Some(origin_y),
2188                z: None,
2189            });
2190            let mut root = root_node(400.0, 400.0, vec![n]);
2191            render_pixels(&mut root, 400, 400)
2192        };
2193
2194        let buf_kw = make_root(CLP::String("left".into()), CLP::String("top".into()));
2195        let buf_pct = make_root(CLP::String("0%".into()), CLP::String("0%".into()));
2196        assert_eq!(
2197            buf_kw, buf_pct,
2198            "keyword 'left top' must produce same pixels as '0% 0%'"
2199        );
2200    }
2201
2202    // ---- Test 5: keyword "right bottom" == "100% 100%" ----
2203
2204    #[test]
2205    fn keyword_right_bottom_equals_100_percent() {
2206        let make_root = |origin_x: CLP, origin_y: CLP| -> Vec<u8> {
2207            let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
2208            n.css.transform = Some(vec![TransformFn::Scale { x: 1.5, y: 1.5 }]);
2209            n.css.transform_origin = Some(TransformOrigin {
2210                x: Some(origin_x),
2211                y: Some(origin_y),
2212                z: None,
2213            });
2214            let mut root = root_node(400.0, 400.0, vec![n]);
2215            render_pixels(&mut root, 400, 400)
2216        };
2217
2218        let buf_kw = make_root(CLP::String("right".into()), CLP::String("bottom".into()));
2219        let buf_pct = make_root(CLP::String("100%".into()), CLP::String("100%".into()));
2220        assert_eq!(
2221            buf_kw, buf_pct,
2222            "keyword 'right bottom' must produce same pixels as '100% 100%'"
2223        );
2224    }
2225
2226    // ---- Test 6: 3D rotate_y with left-center origin — left edge stays fixed ----
2227
2228    #[test]
2229    fn rotate_y_left_origin_left_edge_is_stable() {
2230        // A 200x200 red box at (100, 100). RotateY with origin "left center"
2231        // means the left edge (x=100) is the pivot — so the leftmost painted
2232        // column should always be near x=100 regardless of angle.
2233        // With center origin, the center (x=200) is the pivot and the left edge moves.
2234        let make_root = |origin_x: Option<CLP>| -> Vec<u8> {
2235            let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0);
2236            n.css.transform = Some(vec![TransformFn::RotateY { deg: 45.0 }]);
2237            if let Some(ox) = origin_x {
2238                n.css.transform_origin = Some(TransformOrigin {
2239                    x: Some(ox),
2240                    y: Some(CLP::String("50%".into())),
2241                    z: None,
2242                });
2243            }
2244            let mut root = root_node(500.0, 500.0, vec![n]);
2245            render_pixels(&mut root, 500, 500)
2246        };
2247
2248        let buf_left = make_root(Some(CLP::String("0%".into())));
2249        let buf_center = make_root(None);
2250
2251        // Find the leftmost red pixel x for each render.
2252        fn leftmost_red(buf: &[u8], w: u32, h: u32) -> Option<u32> {
2253            for x in 0..w {
2254                for y in 0..h {
2255                    if buf[((y * w + x) * 4) as usize] > 200
2256                        && buf[((y * w + x) * 4 + 1) as usize] < 50
2257                        && buf[((y * w + x) * 4 + 2) as usize] < 50
2258                    {
2259                        return Some(x);
2260                    }
2261                }
2262            }
2263            None
2264        }
2265
2266        let left_edge_left =
2267            leftmost_red(&buf_left, 500, 500).expect("no red pixels in left-origin render") as f32;
2268        let left_edge_center = leftmost_red(&buf_center, 500, 500)
2269            .expect("no red pixels in center-origin render") as f32;
2270
2271        // Left-origin: left edge stays near x=100 (pivot is exactly there).
2272        assert!(
2273            left_edge_left > 90.0 && left_edge_left < 115.0,
2274            "left-origin left edge should be near 100, got {left_edge_left}"
2275        );
2276
2277        // Center-origin: left edge moves away from 100.
2278        assert!(
2279            left_edge_center > left_edge_left + 20.0,
2280            "center-origin left edge ({left_edge_center}) should be further right than left-origin ({left_edge_left})"
2281        );
2282    }
2283
2284    // ---- Test 7: perspective_origin ≠ transform_origin — must not panic ----
2285
2286    #[test]
2287    fn different_perspective_and_transform_origins_no_panic() {
2288        use crate::css::units::Length;
2289        let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0);
2290        n.css.transform = Some(vec![TransformFn::RotateY { deg: 30.0 }]);
2291        n.css.perspective = Some(Length::Px(800.0));
2292        // perspective-origin at top-left, transform-origin at bottom-right
2293        n.css.transform_origin = Some(TransformOrigin {
2294            x: Some(CLP::String("100%".into())),
2295            y: Some(CLP::String("100%".into())),
2296            z: None,
2297        });
2298        n.css.perspective_origin = Some(TransformOrigin {
2299            x: Some(CLP::String("0%".into())),
2300            y: Some(CLP::String("0%".into())),
2301            z: None,
2302        });
2303        let mut root = root_node(500.0, 500.0, vec![n]);
2304        // Must not panic and must produce at least some red pixels.
2305        let buf = render_pixels(&mut root, 500, 500);
2306        let red_count = count_red(&buf);
2307        assert!(
2308            red_count > 0,
2309            "expected some red pixels with distinct origins"
2310        );
2311    }
2312
2313    // ---- Test 8: translate percentages resolve per-axis, not max(w,h) ----
2314
2315    #[test]
2316    fn translate_percent_resolves_against_own_axis_not_max_dimension() {
2317        // A 200x100 red box at (0,0), `transform: translate(50%, 50%)`.
2318        // CSS resolves a translate-x percentage against the box's own WIDTH
2319        // and translate-y against its own HEIGHT — never `max(width,
2320        // height)` on both axes (only correct for square boxes). Expected:
2321        // x shifts by 100 (50% of 200) -> [100,299]; y shifts by 50 (50% of
2322        // 100) -> [50,149]. The bug instead resolved y against max(200,100)
2323        // = 200, doubling the vertical shift to +100 -> [100,199].
2324        let mut n = red_box(Position::Absolute, 0.0, 0.0, 200.0, 100.0);
2325        n.css.transform = Some(vec![TransformFn::Translate {
2326            x: CLP::String("50%".into()),
2327            y: CLP::String("50%".into()),
2328        }]);
2329        let mut root = root_node(400.0, 400.0, vec![n]);
2330        let buf = render_pixels(&mut root, 400, 400);
2331
2332        let mut min_x = u32::MAX;
2333        let mut max_x = 0u32;
2334        let mut min_y = u32::MAX;
2335        let mut max_y = 0u32;
2336        for y in 0..400u32 {
2337            for x in 0..400u32 {
2338                let i = ((y * 400 + x) * 4) as usize;
2339                if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
2340                    min_x = min_x.min(x);
2341                    max_x = max_x.max(x);
2342                    min_y = min_y.min(y);
2343                    max_y = max_y.max(y);
2344                }
2345            }
2346        }
2347        assert_ne!(min_x, u32::MAX, "expected some red pixels");
2348
2349        assert!((min_x as i32 - 100).abs() <= 2, "min_x={min_x}");
2350        assert!((max_x as i32 - 299).abs() <= 2, "max_x={max_x}");
2351        assert!(
2352            (min_y as i32 - 50).abs() <= 2,
2353            "min_y={min_y} (expected ~50; the max(w,h) bug would give ~100)"
2354        );
2355        assert!(
2356            (max_y as i32 - 149).abs() <= 2,
2357            "max_y={max_y} (expected ~149; the max(w,h) bug would give ~199)"
2358        );
2359    }
2360}
2361
2362#[cfg(test)]
2363mod glassmorphism_tests {
2364    //! TDD tests for issue #87: gradient-border painting and the `noise`
2365    //! filter function (film grain, deterministic per seed).
2366
2367    use super::*;
2368
2369    use crate::css::style::{
2370        Background, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection, Position,
2371        Size as CSize,
2372    };
2373    use crate::css::taffy_bridge::ConversionContext;
2374    use crate::css::units::LengthPercentage as CLP;
2375    use crate::engine::box_tree::{BoxKind, BoxNode};
2376    use crate::engine::layout_pass::run_layout;
2377    use crate::schema::GradientBorder;
2378
2379    fn test_frame(w: u32, h: u32) -> PaintFrame {
2380        PaintFrame {
2381            time: 0.0,
2382            scenario_time: 0.0,
2383            frame_index: 0,
2384            fps: 30,
2385            video_width: w,
2386            video_height: h,
2387            scene_duration: 1.0,
2388            camera: None,
2389        }
2390    }
2391
2392    fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
2393        root.assign_ids(0);
2394        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
2395        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
2396        paint_tree(
2397            surface.canvas(),
2398            root,
2399            &layout,
2400            &test_frame(w, h),
2401            &NoopDispatcher,
2402        );
2403        let info = skia_safe::ImageInfo::new(
2404            (w as i32, h as i32),
2405            skia_safe::ColorType::RGBA8888,
2406            skia_safe::AlphaType::Unpremul,
2407            None,
2408        );
2409        let mut buf = vec![0u8; (w * h * 4) as usize];
2410        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
2411        buf
2412    }
2413
2414    fn px(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) {
2415        let i = ((y * w + x) * 4) as usize;
2416        (buf[i], buf[i + 1], buf[i + 2], buf[i + 3])
2417    }
2418
2419    /// Unique (r,g,b,a) values within a rectangular region.
2420    fn unique_colors_in(
2421        buf: &[u8],
2422        w: u32,
2423        x0: u32,
2424        y0: u32,
2425        x1: u32,
2426        y1: u32,
2427    ) -> std::collections::HashSet<(u8, u8, u8, u8)> {
2428        let mut set = std::collections::HashSet::new();
2429        for y in y0..y1 {
2430            for x in x0..x1 {
2431                set.insert(px(buf, w, x, y));
2432            }
2433        }
2434        set
2435    }
2436
2437    fn leaf(css: CssStyle) -> BoxNode {
2438        BoxNode {
2439            id: 0,
2440            kind: BoxKind::Container,
2441            css,
2442            children: vec![],
2443            intrinsic: None,
2444            source_path: None,
2445            window: None,
2446        }
2447    }
2448
2449    fn root_node(w: f32, h: f32, background: Option<&str>, children: Vec<BoxNode>) -> BoxNode {
2450        BoxNode {
2451            id: 0,
2452            kind: BoxKind::Container,
2453            css: CssStyle {
2454                display: Some(Display::Flex),
2455                flex_direction: Some(FlexDirection::Column),
2456                width: Some(CSize::Length(CLP::Px(w))),
2457                height: Some(CSize::Length(CLP::Px(h))),
2458                background: background.map(|c| Background::Color(CssColor::String(c.to_string()))),
2459                ..Default::default()
2460            },
2461            children,
2462            intrinsic: None,
2463            source_path: None,
2464            window: None,
2465        }
2466    }
2467
2468    fn abs_box(x: f32, y: f32, w: f32, h: f32, css_extra: CssStyle) -> BoxNode {
2469        let mut css = css_extra;
2470        css.position = Some(Position::Absolute);
2471        css.left = Some(CLP::Px(x));
2472        css.top = Some(CLP::Px(y));
2473        css.width = Some(CSize::Length(CLP::Px(w)));
2474        css.height = Some(CSize::Length(CLP::Px(h)));
2475        leaf(css)
2476    }
2477
2478    // ---- gradient-border ----
2479
2480    #[test]
2481    fn gradient_border_paints_both_colors_on_perimeter_center_intact() {
2482        // 200x200 box at (100, 100) on a white root; gradient-border 12px
2483        // red→blue along the horizontal axis (angle 90). Expect: one vertical
2484        // border edge red-dominant, the other blue-dominant, centre untouched.
2485        let node = abs_box(
2486            100.0,
2487            100.0,
2488            200.0,
2489            200.0,
2490            CssStyle {
2491                gradient_border: Some(GradientBorder {
2492                    colors: vec!["#ff0000".into(), "#0000ff".into()],
2493                    width: 12.0,
2494                    angle: 90.0,
2495                }),
2496                ..Default::default()
2497            },
2498        );
2499        let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
2500        let buf = render_pixels(&mut root, 400, 400);
2501
2502        // Sample border midpoints: left edge (x=106, y=200), right edge (x=294, y=200).
2503        let left = px(&buf, 400, 106, 200);
2504        let right = px(&buf, 400, 294, 200);
2505
2506        // One side red-dominant, the other blue-dominant (convention-agnostic).
2507        let red_side = if left.0 > left.2 { left } else { right };
2508        let blue_side = if left.0 > left.2 { right } else { left };
2509        assert!(
2510            red_side.0 > 150 && red_side.2 < 100,
2511            "expected a red-dominant border edge, got {red_side:?}"
2512        );
2513        assert!(
2514            blue_side.2 > 150 && blue_side.0 < 100,
2515            "expected a blue-dominant border edge, got {blue_side:?}"
2516        );
2517        // Both extremes must actually differ (it's a gradient, not a flat color).
2518        assert_ne!(
2519            left, right,
2520            "border edges must show different gradient stops"
2521        );
2522
2523        // Centre of the box stays the root background (white).
2524        let center = px(&buf, 400, 200, 200);
2525        assert_eq!(
2526            center,
2527            (255, 255, 255, 255),
2528            "box centre must not be painted by the gradient border"
2529        );
2530
2531        // Top border midpoint is painted (not background).
2532        let top_mid = px(&buf, 400, 200, 106);
2533        assert_ne!(
2534            top_mid,
2535            (255, 255, 255, 255),
2536            "top border edge must be painted"
2537        );
2538    }
2539
2540    #[test]
2541    fn gradient_border_respects_border_radius() {
2542        use crate::css::style::BorderRadius;
2543        // Rounded 200x200 box: the square corner pixel must stay background,
2544        // while edge midpoints are painted.
2545        let node = abs_box(
2546            100.0,
2547            100.0,
2548            200.0,
2549            200.0,
2550            CssStyle {
2551                border_radius: Some(BorderRadius::Uniform(CLP::Px(60.0))),
2552                gradient_border: Some(GradientBorder {
2553                    colors: vec!["#ff0000".into(), "#0000ff".into()],
2554                    width: 10.0,
2555                    angle: 90.0,
2556                }),
2557                ..Default::default()
2558            },
2559        );
2560        let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
2561        let buf = render_pixels(&mut root, 400, 400);
2562
2563        // Square corner (inside the box bounds but outside the rounded path).
2564        let corner = px(&buf, 400, 104, 104);
2565        assert_eq!(
2566            corner,
2567            (255, 255, 255, 255),
2568            "square corner must stay background with border-radius"
2569        );
2570        // Edge midpoints painted.
2571        let left_mid = px(&buf, 400, 104, 200);
2572        let top_mid = px(&buf, 400, 200, 104);
2573        assert_ne!(left_mid, (255, 255, 255, 255), "left edge must be painted");
2574        assert_ne!(top_mid, (255, 255, 255, 255), "top edge must be painted");
2575    }
2576
2577    #[test]
2578    fn gradient_border_replaces_standard_border() {
2579        use crate::css::style::{BorderEdges, BorderStyle, Edges};
2580        // A node with BOTH border (solid green) and gradient-border (red/blue):
2581        // the gradient border wins; no green pixels appear.
2582        let node = abs_box(
2583            100.0,
2584            100.0,
2585            200.0,
2586            200.0,
2587            CssStyle {
2588                border: Some(BorderEdges {
2589                    width: Some(Edges::Uniform(CLP::Px(10.0))),
2590                    style: Some(BorderStyle::Solid),
2591                    color: Some(CssColor::String("#00ff00".into())),
2592                    ..Default::default()
2593                }),
2594                gradient_border: Some(GradientBorder {
2595                    colors: vec!["#ff0000".into(), "#0000ff".into()],
2596                    width: 10.0,
2597                    angle: 90.0,
2598                }),
2599                ..Default::default()
2600            },
2601        );
2602        let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
2603        let buf = render_pixels(&mut root, 400, 400);
2604
2605        let green_pixels = buf
2606            .chunks(4)
2607            .filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60)
2608            .count();
2609        assert_eq!(
2610            green_pixels, 0,
2611            "standard border must not be painted when gradient-border is set"
2612        );
2613    }
2614
2615    // ---- noise filter ----
2616
2617    fn gray_box_with_filter(filter: Option<Vec<FilterFn>>) -> BoxNode {
2618        abs_box(
2619            50.0,
2620            50.0,
2621            200.0,
2622            200.0,
2623            CssStyle {
2624                background: Some(Background::Color(CssColor::String("#808080".into()))),
2625                filter,
2626                ..Default::default()
2627            },
2628        )
2629    }
2630
2631    #[test]
2632    fn noise_filter_explodes_unique_color_count() {
2633        let mut root_plain = root_node(
2634            300.0,
2635            300.0,
2636            Some("#ffffff"),
2637            vec![gray_box_with_filter(None)],
2638        );
2639        let buf_plain = render_pixels(&mut root_plain, 300, 300);
2640
2641        let mut root_noise = root_node(
2642            300.0,
2643            300.0,
2644            Some("#ffffff"),
2645            vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
2646                intensity: 0.5,
2647                seed: 42,
2648            }]))],
2649        );
2650        let buf_noise = render_pixels(&mut root_noise, 300, 300);
2651
2652        // Sample well inside the box to avoid AA edges.
2653        let plain = unique_colors_in(&buf_plain, 300, 70, 70, 230, 230);
2654        let noisy = unique_colors_in(&buf_noise, 300, 70, 70, 230, 230);
2655        assert!(
2656            plain.len() <= 4,
2657            "plain box interior should be near-uniform, got {} colors",
2658            plain.len()
2659        );
2660        assert!(
2661            noisy.len() > 50,
2662            "noise filter should explode the unique color count, got {}",
2663            noisy.len()
2664        );
2665    }
2666
2667    #[test]
2668    fn noise_same_seed_is_byte_identical_across_renders() {
2669        let render = || {
2670            let mut root = root_node(
2671                300.0,
2672                300.0,
2673                Some("#ffffff"),
2674                vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
2675                    intensity: 0.4,
2676                    seed: 7,
2677                }]))],
2678            );
2679            render_pixels(&mut root, 300, 300)
2680        };
2681        let a = render();
2682        let b = render();
2683        assert_eq!(a, b, "same seed must produce byte-identical grain");
2684    }
2685
2686    #[test]
2687    fn noise_different_seeds_differ() {
2688        let render = |seed: u64| {
2689            let mut root = root_node(
2690                300.0,
2691                300.0,
2692                Some("#ffffff"),
2693                vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
2694                    intensity: 0.4,
2695                    seed,
2696                }]))],
2697            );
2698            render_pixels(&mut root, 300, 300)
2699        };
2700        let a = render(1);
2701        let b = render(2);
2702        assert_ne!(a, b, "different seeds must produce different grain");
2703    }
2704
2705    #[test]
2706    fn backdrop_noise_grains_only_the_panel_region() {
2707        // Uniform gray root; a panel at (50, 50, 100, 100) with
2708        // backdrop-filter noise. Inside the panel: grain (many colors).
2709        // Outside: untouched uniform gray.
2710        let panel = abs_box(
2711            50.0,
2712            50.0,
2713            100.0,
2714            100.0,
2715            CssStyle {
2716                backdrop_filter: Some(vec![FilterFn::Noise {
2717                    intensity: 0.5,
2718                    seed: 42,
2719                }]),
2720                ..Default::default()
2721            },
2722        );
2723        let mut root = root_node(300.0, 300.0, Some("#808080"), vec![panel]);
2724        let buf = render_pixels(&mut root, 300, 300);
2725
2726        let inside = unique_colors_in(&buf, 300, 60, 60, 140, 140);
2727        let outside = unique_colors_in(&buf, 300, 180, 180, 280, 280);
2728        assert!(
2729            inside.len() > 30,
2730            "panel interior should be grained, got {} colors",
2731            inside.len()
2732        );
2733        assert_eq!(
2734            outside.len(),
2735            1,
2736            "outside the panel must stay uniform, got {} colors",
2737            outside.len()
2738        );
2739    }
2740
2741    // ---- serde ----
2742
2743    #[test]
2744    fn css_gradient_border_and_noise_deserialize() {
2745        let json = r##"{
2746            "gradient-border": { "colors": ["#ff0000", "#0000ff"], "width": 3, "angle": 45 },
2747            "filter": [{ "fn": "noise", "intensity": 0.3, "seed": 9 }],
2748            "backdrop-filter": [{ "fn": "noise" }]
2749        }"##;
2750        let s: CssStyle = serde_json::from_str(json).unwrap();
2751        let gb = s.gradient_border.expect("gradient-border parsed");
2752        assert_eq!(gb.colors.len(), 2);
2753        assert_eq!(gb.width, 3.0);
2754        assert_eq!(gb.angle, 45.0);
2755        assert!(matches!(
2756            s.filter.as_deref(),
2757            Some([FilterFn::Noise {
2758                intensity,
2759                seed: 9
2760            }]) if (intensity - 0.3).abs() < 1e-6
2761        ));
2762        // Defaults: intensity 0.15, seed 42.
2763        assert!(matches!(
2764            s.backdrop_filter.as_deref(),
2765            Some([FilterFn::Noise {
2766                intensity,
2767                seed: 42
2768            }]) if (intensity - 0.15).abs() < 1e-6
2769        ));
2770    }
2771
2772    #[test]
2773    fn css_legacy_zombies_accepted() {
2774        // backdrop-blur / inner-shadow parse into CssStyle (accepted for
2775        // compat) — rendering is intentionally not wired; validate warns.
2776        let json = r##"{
2777            "backdrop-blur": 20,
2778            "inner-shadow": { "color": "#000000", "offset_x": 0, "offset_y": 2, "blur": 8 }
2779        }"##;
2780        let s: CssStyle = serde_json::from_str(json).unwrap();
2781        assert_eq!(s.backdrop_blur, Some(20.0));
2782        assert!(s.inner_shadow.is_some());
2783    }
2784}
2785
2786#[cfg(test)]
2787mod paint_order_tests {
2788    //! TDD tests for two paint-pass audit findings:
2789    //!   - overflow:hidden must never clip a node's OWN outset box-shadow
2790    //!     (CSS clips descendants, never the box's own decorations).
2791    //!   - the opacity/filter SaveLayerRec must be bounded to the node's box
2792    //!     (+ filter bleed), not left to size against the ambient clip
2793    //!     (usually the whole viewport) — without clipping visible blur.
2794
2795    use super::*;
2796
2797    use crate::css::style::{
2798        Background, BoxShadow, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection,
2799        Overflow, Position, Size as CSize,
2800    };
2801    use crate::css::taffy_bridge::ConversionContext;
2802    use crate::css::units::{Length, LengthPercentage as CLP};
2803    use crate::engine::box_tree::{BoxKind, BoxNode};
2804    use crate::engine::layout_pass::run_layout;
2805
2806    fn test_frame(w: u32, h: u32) -> PaintFrame {
2807        PaintFrame {
2808            time: 0.0,
2809            scenario_time: 0.0,
2810            frame_index: 0,
2811            fps: 30,
2812            video_width: w,
2813            video_height: h,
2814            scene_duration: 1.0,
2815            camera: None,
2816        }
2817    }
2818
2819    fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
2820        root.assign_ids(0);
2821        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
2822        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
2823        paint_tree(
2824            surface.canvas(),
2825            root,
2826            &layout,
2827            &test_frame(w, h),
2828            &NoopDispatcher,
2829        );
2830        let info = skia_safe::ImageInfo::new(
2831            (w as i32, h as i32),
2832            skia_safe::ColorType::RGBA8888,
2833            skia_safe::AlphaType::Unpremul,
2834            None,
2835        );
2836        let mut buf = vec![0u8; (w * h * 4) as usize];
2837        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
2838        buf
2839    }
2840
2841    fn root_node(w: f32, h: f32, background: &str, children: Vec<BoxNode>) -> BoxNode {
2842        BoxNode {
2843            id: 0,
2844            kind: BoxKind::Container,
2845            css: CssStyle {
2846                display: Some(Display::Flex),
2847                flex_direction: Some(FlexDirection::Column),
2848                width: Some(CSize::Length(CLP::Px(w))),
2849                height: Some(CSize::Length(CLP::Px(h))),
2850                background: Some(Background::Color(CssColor::String(background.to_string()))),
2851                ..Default::default()
2852            },
2853            children,
2854            intrinsic: None,
2855            source_path: None,
2856            window: None,
2857        }
2858    }
2859
2860    /// Count of "probe" red pixels (spread-only, blur:0, so a hard-edged
2861    /// halo) in a rectangular region — used to compare before/after pixel
2862    /// counts for the paint-order fix.
2863    fn count_red_in(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
2864        let mut n = 0;
2865        for y in y0..y1 {
2866            for x in x0..x1 {
2867                let i = ((y * w + x) * 4) as usize;
2868                if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
2869                    n += 1;
2870                }
2871            }
2872        }
2873        n
2874    }
2875
2876    fn card_with_shadow(overflow_hidden: bool) -> BoxNode {
2877        let mut css = CssStyle {
2878            position: Some(Position::Absolute),
2879            left: Some(CLP::Px(50.0)),
2880            top: Some(CLP::Px(50.0)),
2881            width: Some(CSize::Length(CLP::Px(100.0))),
2882            height: Some(CSize::Length(CLP::Px(100.0))),
2883            background: Some(Background::Color(CssColor::String("#ffffff".into()))),
2884            box_shadow: Some(vec![BoxShadow {
2885                offset_x: Length::Px(0.0),
2886                offset_y: Length::Px(0.0),
2887                blur: None,
2888                spread: Some(Length::Px(20.0)),
2889                color: Some(CssColor::String("#ff0000".into())),
2890                inset: None,
2891            }]),
2892            ..Default::default()
2893        };
2894        if overflow_hidden {
2895            css.overflow = Some(Overflow::Hidden);
2896        }
2897        BoxNode {
2898            id: 0,
2899            kind: BoxKind::Container,
2900            css,
2901            children: vec![],
2902            intrinsic: None,
2903            source_path: None,
2904            window: None,
2905        }
2906    }
2907
2908    #[test]
2909    fn overflow_hidden_does_not_clip_own_outset_box_shadow() {
2910        // 100x100 white card at (50,50) on a 200x200 black canvas, outset
2911        // box-shadow (red, spread 20, blur 0 -> hard-edged halo rect from
2912        // (30,30) to (170,170)). Probe points (100,45) and (100,155) sit in
2913        // the halo band above/below the card, outside its own border-box.
2914        let without = {
2915            let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(false)]);
2916            render_pixels(&mut root, 200, 200)
2917        };
2918        let with_hidden = {
2919            let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(true)]);
2920            render_pixels(&mut root, 200, 200)
2921        };
2922
2923        let probe = |buf: &[u8], x: usize, y: usize| -> (u8, u8, u8) {
2924            let i = (y * 200 + x) * 4;
2925            (buf[i], buf[i + 1], buf[i + 2])
2926        };
2927
2928        let above_plain = probe(&without, 100, 45);
2929        let below_plain = probe(&without, 100, 155);
2930        assert!(
2931            above_plain.0 > 200 && above_plain.1 < 50,
2932            "sanity: shadow halo must be visible without overflow, got {above_plain:?}"
2933        );
2934        assert!(
2935            below_plain.0 > 200 && below_plain.1 < 50,
2936            "sanity: shadow halo must be visible without overflow, got {below_plain:?}"
2937        );
2938
2939        let above_hidden = probe(&with_hidden, 100, 45);
2940        let below_hidden = probe(&with_hidden, 100, 155);
2941        assert!(
2942            above_hidden.0 > 200 && above_hidden.1 < 50,
2943            "overflow:hidden must not erase the node's own outset shadow, got {above_hidden:?}"
2944        );
2945        assert!(
2946            below_hidden.0 > 200 && below_hidden.1 < 50,
2947            "overflow:hidden must not erase the node's own outset shadow, got {below_hidden:?}"
2948        );
2949
2950        // Probe-pixel count over the full halo band, before/after overflow.
2951        let halo_count_plain = count_red_in(&without, 200, 25, 25, 175, 175);
2952        let halo_count_hidden = count_red_in(&with_hidden, 200, 25, 25, 175, 175);
2953        assert_eq!(
2954            halo_count_plain, halo_count_hidden,
2955            "halo pixel count must be identical with/without overflow:hidden \
2956             (plain={halo_count_plain}, hidden={halo_count_hidden})"
2957        );
2958    }
2959
2960    #[test]
2961    fn filter_layer_bounds_do_not_clip_blur_bleed() {
2962        // A 60x60 opaque red square with `opacity: 0.999` (forces the
2963        // SaveLayerRec open) AND `filter: blur(24px)` on a 300x300 black
2964        // canvas. Bounding the layer to the node's box (issue #4 fix) must
2965        // still leave room for the blur to bleed outward — if the bounds
2966        // were the bare box rect, Skia would hard-clip the blurred fringe
2967        // at the box edge, and the region just outside the box would stay
2968        // pure black instead of picking up a soft red glow.
2969        let n = BoxNode {
2970            id: 0,
2971            kind: BoxKind::Container,
2972            css: CssStyle {
2973                position: Some(Position::Absolute),
2974                left: Some(CLP::Px(120.0)),
2975                top: Some(CLP::Px(120.0)),
2976                width: Some(CSize::Length(CLP::Px(60.0))),
2977                height: Some(CSize::Length(CLP::Px(60.0))),
2978                background: Some(Background::Color(CssColor::String("#ff0000".into()))),
2979                opacity: Some(0.999),
2980                filter: Some(vec![FilterFn::Blur {
2981                    radius: Length::Px(24.0),
2982                }]),
2983                ..Default::default()
2984            },
2985            children: vec![],
2986            intrinsic: None,
2987            source_path: None,
2988            window: None,
2989        };
2990        let mut root = root_node(300.0, 300.0, "#000000", vec![n]);
2991        let buf = render_pixels(&mut root, 300, 300);
2992
2993        let probe = |x: usize, y: usize| -> u8 {
2994            let i = (y * 300 + x) * 4;
2995            buf[i]
2996        };
2997        // 8px outside the left edge of the box (box left edge = x=120),
2998        // vertically centered (y=150): must show blur bleed (red > black).
2999        let bled = probe(112, 150);
3000        assert!(
3001            bled > 15,
3002            "blur must bleed past the box edge under bounded SaveLayerRec, got r={bled}"
3003        );
3004        // Far outside any plausible bleed radius: must stay black.
3005        let far = probe(20, 20);
3006        assert_eq!(far, 0, "far corner must stay untouched, got r={far}");
3007    }
3008}
3009
3010#[cfg(test)]
3011mod tests {
3012    use super::*;
3013
3014    #[test]
3015    fn parse_hex_3_and_6() {
3016        let c = parse_color_string("#fff").unwrap();
3017        assert_eq!(c, SColor::from_argb(255, 255, 255, 255));
3018        let c = parse_color_string("#102030").unwrap();
3019        assert_eq!(c, SColor::from_argb(255, 0x10, 0x20, 0x30));
3020    }
3021
3022    #[test]
3023    fn parse_hex_with_alpha() {
3024        let c = parse_color_string("#80ffffff").unwrap();
3025        assert_eq!(c.a(), 0xff);
3026    }
3027
3028    #[test]
3029    fn parse_rgb_string() {
3030        let c = parse_color_string("rgb(10, 20, 30)").unwrap();
3031        assert_eq!(c, SColor::from_argb(255, 10, 20, 30));
3032    }
3033
3034    #[test]
3035    fn parse_rgba_string() {
3036        let c = parse_color_string("rgba(10, 20, 30, 0.5)").unwrap();
3037        assert_eq!(c.r(), 10);
3038        assert_eq!(c.a(), 128);
3039    }
3040
3041    #[test]
3042    fn parse_named_colors() {
3043        assert_eq!(parse_color_string("red").unwrap(), SColor::RED);
3044        assert_eq!(
3045            parse_color_string("transparent").unwrap(),
3046            SColor::TRANSPARENT
3047        );
3048    }
3049
3050    #[test]
3051    fn parse_white_forms_all_agree() {
3052        // Regression test for the C2 audit finding: `#fff`, `#FFF`, `white`
3053        // and `rgb(255,255,255)` must all resolve to the same opaque white,
3054        // never to black.
3055        let white = SColor::from_argb(255, 255, 255, 255);
3056        assert_eq!(parse_color_string("#fff").unwrap(), white);
3057        assert_eq!(parse_color_string("#FFF").unwrap(), white);
3058        assert_eq!(parse_color_string("white").unwrap(), white);
3059        assert_eq!(parse_color_string("WHITE").unwrap(), white);
3060        assert_eq!(parse_color_string("rgb(255,255,255)").unwrap(), white);
3061        assert_eq!(parse_color_string("rgb(255, 255, 255)").unwrap(), white);
3062    }
3063
3064    #[test]
3065    fn parse_color_string_supports_extended_named_set() {
3066        // Only 11 names were hardcoded before; spot-check a few outside
3067        // that set to prove it now routes through the full CSS keyword
3068        // table in `renderer::colors`.
3069        assert!(parse_color_string("rebeccapurple").is_some());
3070        assert!(parse_color_string("cornflowerblue").is_some());
3071        assert!(parse_color_string("dodgerblue").is_some());
3072    }
3073
3074    #[test]
3075    fn parse_color_string_supports_hsl() {
3076        assert_eq!(
3077            parse_color_string("hsl(0, 100%, 50%)").unwrap(),
3078            SColor::from_argb(255, 255, 0, 0)
3079        );
3080    }
3081
3082    #[test]
3083    fn parse_color_unresolvable_string_is_not_black() {
3084        let c = parse_color(&Color::String("not-a-color".to_string()));
3085        assert_ne!(c, SColor::BLACK);
3086        assert_eq!(c, SColor::from_argb(255, 255, 0, 255));
3087    }
3088}