1use 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#[derive(Debug, Clone, Copy)]
36pub struct PaintFrame {
37 pub time: f64,
38 pub scenario_time: f64,
47 pub frame_index: u32,
48 pub fps: u32,
49 pub video_width: u32,
50 pub video_height: u32,
51 pub scene_duration: f64,
54 pub camera: Option<PlaneCamera>,
60}
61
62#[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 pub origin_x: f32,
71 pub origin_y: f32,
72}
73
74fn 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#[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#[derive(Debug, Clone, Copy, PartialEq)]
112pub struct HitNode {
113 pub node_id: NodeId,
114 pub rect: HitRect,
115}
116
117pub type HitMap = Vec<HitNode>;
120
121#[derive(Debug, Clone, PartialEq)]
124pub struct EnrichedHit {
125 pub node_id: NodeId,
126 pub kind: String,
127 pub rect: HitRect,
128 pub pointer: Option<String>,
130}
131
132pub trait PaintDispatcher {
135 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
149pub 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
164pub 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
182pub 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
212fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: usize) {
215 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 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 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 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 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 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 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 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 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 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 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 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 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 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
515fn 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
542fn 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 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 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 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
601fn 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
635fn 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 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
697fn 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 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
731fn 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 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
824fn resolve_origin(
842 origin: Option<&TransformOrigin>,
843 layout: &BoxLayout,
844 ctx: &LengthContext,
845) -> (f32, f32, f32) {
846 let Some(o) = origin else {
847 return (
849 layout.x + layout.width / 2.0,
850 layout.y + layout.height / 2.0,
851 0.0,
852 );
853 };
854
855 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 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 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#[derive(Clone, Copy)]
918struct TransformAxes {
919 x: LengthContext,
920 y: LengthContext,
921 general: LengthContext,
922}
923
924fn 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 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 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 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 let tp = transform_pivot;
1016 let pp = perspective_pivot;
1017 let mut m = M44::new_identity();
1018
1019 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 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
1037fn 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
1150fn 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 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 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 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 }
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 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
1270fn paint_border(
1273 canvas: &Canvas,
1274 layout: &BoxLayout,
1275 css: &CssStyle,
1276 border: &BorderEdges,
1277 ctx: &LengthContext,
1278) {
1279 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 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 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 canvas.draw_drrect(outer, inner, &paint);
1318}
1319
1320fn 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 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 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 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
1445fn 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 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 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
1529pub 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
1560fn 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#[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 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 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 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 assert!(red(10, 97) < 10, "outside/above must stay black");
1739 assert!(red(10, 103) > 245, "outside/below must stay white");
1740 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 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 assert!(red(10, 97) < 10, "outside/above must stay black");
1845 assert!(red(10, 103) > 245, "outside/below must stay white");
1846 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 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 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 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 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 fn r(buf: &[u8], w: u32, x: u32, y: u32) -> u8 {
1976 buf[((y * w + x) * 4) as usize]
1977 }
1978
1979 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 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 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]
2071 fn no_transform_origin_unchanged_non_regression() {
2072 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 n
2082 }],
2083 );
2084 let buf = render_pixels(&mut root, 300, 300);
2085
2086 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]
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]
2122 fn rotate_90_left_top_vs_center_occupy_different_quadrants() {
2123 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 let buf_center = make_root_with_origin(None, None);
2142 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 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 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 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]
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]
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]
2229 fn rotate_y_left_origin_left_edge_is_stable() {
2230 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 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 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 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]
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 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 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]
2316 fn translate_percent_resolves_against_own_axis_not_max_dimension() {
2317 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 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 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 #[test]
2481 fn gradient_border_paints_both_colors_on_perimeter_center_intact() {
2482 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 let left = px(&buf, 400, 106, 200);
2504 let right = px(&buf, 400, 294, 200);
2505
2506 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 assert_ne!(
2519 left, right,
2520 "border edges must show different gradient stops"
2521 );
2522
2523 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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}