Skip to main content

rosace_render/
draw_command.rs

1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4
5use crate::canvas::Color;
6use crate::font::FontWeight;
7
8/// A single drawing instruction recorded during the paint pass.
9///
10/// Widgets push these into a [`PictureRecorder`] instead of writing pixels
11/// directly. The compositor later replays them onto whatever backend is active
12/// (currently [`SkiaCanvas`], eventually wgpu).
13#[derive(Debug, Clone)]
14pub enum DrawCommand {
15    FillRect   { rect: Rect, color: Color },
16    StrokeRect { rect: Rect, color: Color, width: f32 },
17    /// Filled rounded rectangle — a single anti-aliased path.
18    FillRRect  { rect: Rect, radius: f32, color: Color },
19    /// Rounded rectangle outline — matches the FillRRect geometry so borders
20    /// hug rounded fills instead of framing them with square corners.
21    StrokeRRect { rect: Rect, radius: f32, color: Color, width: f32 },
22    FillCircle { center: Point, radius: f32, color: Color },
23    /// Two-stop linear gradient fill of a (rounded) rect. `vertical` picks
24    /// the axis; `radius` rounds corners (0 = square).
25    FillGradient { rect: Rect, radius: f32, from: Color, to: Color, vertical: bool },
26    /// A ring segment: stroke of thickness `thickness` along the circle of
27    /// `radius` centered at `center`, from `start_deg` sweeping `sweep_deg`
28    /// clockwise (0° = 3 o'clock). Powers progress rings and spinners.
29    FillArc { center: Point, radius: f32, thickness: f32, start_deg: f32, sweep_deg: f32, color: Color },
30    DrawText   { text: String, origin: Point, color: Color, px: f32, weight: FontWeight },
31    /// Gaussian-approximate drop shadow. `radius` rounds the shadow's source
32    /// shape to match rounded widgets — a square shadow behind a rounded fill
33    /// leaks dark corner triangles.
34    DrawShadow { rect: Rect, radius: f32, color: Color, blur: f32 },
35    /// Raw pre-decoded RGBA pixel blit. `pixels` must be `width × height × 4` bytes.
36    /// `opacity` (0.0-1.0) scales the blit's alpha — D108/Phase 26 Step 4's
37    /// image load-in fade; `1.0` is the previous, fully-opaque behavior.
38    BlitRgba   { pixels: Arc<Vec<u8>>, src_width: u32, src_height: u32, dest_rect: Rect, opacity: f32 },
39    /// Push a clip rect — subsequent commands are confined to `rect` (intersected
40    /// with any already-active clip). Must be paired with [`DrawCommand::PopClip`].
41    PushClip   { rect: Rect },
42    /// Restore the clip rect that was active before the matching [`DrawCommand::PushClip`].
43    PopClip,
44    /// Frosted-glass panel (D-DEF-012 backdrop blur): everything already
45    /// drawn beneath `rect` is blurred and tinted behind a rounded panel.
46    /// GPU-composited targets only; the CPU fallback renders a translucent
47    /// tint (no blur) — honest degradation, not silence. `blur` is the
48    /// Gaussian strength in logical px; `tint`'s alpha is the tint mix.
49    BackdropBlur { rect: Rect, radius: f32, blur: f32, tint: Color },
50    /// Fill `rect` with a registered GPU shader pipeline (D109/Phase 27).
51    ///
52    /// `pipeline_id` is the raw value of a `rosace-shader` `PipelineId`
53    /// (this Layer-4 crate cannot import the Layer-5 typed id). `uniforms`
54    /// are WGSL-uniform-layout bytes produced by `#[derive(ShaderUniforms)]`
55    /// — opaque here. This command has NO CPU rasterization path by design:
56    /// `SkiaCanvas::play_picture` collects it (see `take_shader_quads`) for
57    /// the compositor to execute on the GPU at present time.
58    ///
59    /// `animate_time` (D109 maturity, 2026-07-18): when true, the platform
60    /// patches the first 4 uniform bytes with a live clock at every present
61    /// (the standard `time`-first uniform convention) — continuous shader
62    /// animation then costs one 16-byte GPU buffer write per frame instead
63    /// of a full CPU tree repaint. The widget records this command ONCE;
64    /// no per-frame repaint, no `request_animation` loop.
65    ShaderFill { pipeline_id: u64, rect: Rect, uniforms: Vec<u8>, animate_time: bool },
66}
67
68impl DrawCommand {
69    /// Return a copy of this command translated by (dx, dy) in logical pixels (D088).
70    pub fn offset(&self, dx: f32, dy: f32) -> Self {
71        fn shift(r: Rect, dx: f32, dy: f32) -> Rect {
72            Rect {
73                origin: Point { x: r.origin.x + dx, y: r.origin.y + dy },
74                size: r.size,
75            }
76        }
77        match self.clone() {
78            Self::FillRect   { rect, color }           => Self::FillRect   { rect: shift(rect, dx, dy), color },
79            Self::StrokeRect { rect, color, width }    => Self::StrokeRect { rect: shift(rect, dx, dy), color, width },
80            Self::FillRRect  { rect, radius, color }   => Self::FillRRect  { rect: shift(rect, dx, dy), radius, color },
81            Self::StrokeRRect { rect, radius, color, width } => Self::StrokeRRect { rect: shift(rect, dx, dy), radius, color, width },
82            Self::FillCircle { center, radius, color } => Self::FillCircle { center: Point { x: center.x + dx, y: center.y + dy }, radius, color },
83            Self::FillGradient { rect, radius, from, to, vertical } => Self::FillGradient { rect: shift(rect, dx, dy), radius, from, to, vertical },
84            Self::FillArc { center, radius, thickness, start_deg, sweep_deg, color } => Self::FillArc { center: Point { x: center.x + dx, y: center.y + dy }, radius, thickness, start_deg, sweep_deg, color },
85            Self::DrawText   { text, origin, color, px, weight } => Self::DrawText { text, origin: Point { x: origin.x + dx, y: origin.y + dy }, color, px, weight },
86            Self::DrawShadow { rect, radius, color, blur } => Self::DrawShadow { rect: shift(rect, dx, dy), radius, color, blur },
87            Self::BlitRgba   { pixels, src_width, src_height, dest_rect, opacity } =>
88                Self::BlitRgba { pixels, src_width, src_height, dest_rect: shift(dest_rect, dx, dy), opacity },
89            Self::PushClip   { rect }                  => Self::PushClip   { rect: shift(rect, dx, dy) },
90            Self::PopClip                              => Self::PopClip,
91            Self::ShaderFill { pipeline_id, rect, uniforms, animate_time } =>
92                Self::ShaderFill { pipeline_id, rect: shift(rect, dx, dy), uniforms, animate_time },
93            Self::BackdropBlur { rect, radius, blur, tint } =>
94                Self::BackdropBlur { rect: shift(rect, dx, dy), radius, blur, tint },
95        }
96    }
97
98    /// Return a copy of this command remapped from a `src`-rect-relative
99    /// coordinate space to a `dst` one — translating AND scaling, unlike
100    /// [`Self::offset`] which only translates. Backs Hero/shared-element
101    /// transitions (D108/Phase 26 Step 5): a captured [`crate::Picture`]
102    /// recorded at a widget's rect on one screen is replayed at a
103    /// different-sized rect on the other screen's tag match, morphing
104    /// between the two. Non-rect geometry (circle/arc radius, stroke/blur
105    /// width, font size) has no independent x/y scale of its own, so it
106    /// scales uniformly by the average of `sx`/`sy`.
107    pub fn morph(&self, src_origin: Point, dst_origin: Point, sx: f32, sy: f32) -> Self {
108        let s = (sx + sy) * 0.5;
109        fn pt(p: Point, so: Point, do_: Point, sx: f32, sy: f32) -> Point {
110            Point { x: do_.x + (p.x - so.x) * sx, y: do_.y + (p.y - so.y) * sy }
111        }
112        fn rc(r: Rect, so: Point, do_: Point, sx: f32, sy: f32) -> Rect {
113            Rect {
114                origin: pt(r.origin, so, do_, sx, sy),
115                size: Size { width: r.size.width * sx, height: r.size.height * sy },
116            }
117        }
118        match self.clone() {
119            Self::FillRect   { rect, color }           => Self::FillRect   { rect: rc(rect, src_origin, dst_origin, sx, sy), color },
120            Self::StrokeRect { rect, color, width }    => Self::StrokeRect { rect: rc(rect, src_origin, dst_origin, sx, sy), color, width: width * s },
121            Self::FillRRect  { rect, radius, color }   => Self::FillRRect  { rect: rc(rect, src_origin, dst_origin, sx, sy), radius: radius * s, color },
122            Self::StrokeRRect { rect, radius, color, width } => Self::StrokeRRect { rect: rc(rect, src_origin, dst_origin, sx, sy), radius: radius * s, color, width: width * s },
123            Self::FillCircle { center, radius, color } => Self::FillCircle { center: pt(center, src_origin, dst_origin, sx, sy), radius: radius * s, color },
124            Self::FillGradient { rect, radius, from, to, vertical } => Self::FillGradient { rect: rc(rect, src_origin, dst_origin, sx, sy), radius: radius * s, from, to, vertical },
125            Self::FillArc { center, radius, thickness, start_deg, sweep_deg, color } => Self::FillArc { center: pt(center, src_origin, dst_origin, sx, sy), radius: radius * s, thickness: thickness * s, start_deg, sweep_deg, color },
126            Self::DrawText   { text, origin, color, px, weight } => Self::DrawText { text, origin: pt(origin, src_origin, dst_origin, sx, sy), color, px: px * s, weight },
127            Self::DrawShadow { rect, radius, color, blur } => Self::DrawShadow { rect: rc(rect, src_origin, dst_origin, sx, sy), radius: radius * s, color, blur: blur * s },
128            Self::BlitRgba   { pixels, src_width, src_height, dest_rect, opacity } =>
129                Self::BlitRgba { pixels, src_width, src_height, dest_rect: rc(dest_rect, src_origin, dst_origin, sx, sy), opacity },
130            Self::PushClip   { rect }                  => Self::PushClip   { rect: rc(rect, src_origin, dst_origin, sx, sy) },
131            Self::PopClip                              => Self::PopClip,
132            // Uniform bytes are pipeline-private and cannot be remapped
133            // generically — only the fill rect morphs. A shader whose
134            // uniforms encode absolute positions won't track a Hero morph;
135            // that's the shader author's contract, documented on ShaderFill.
136            Self::ShaderFill { pipeline_id, rect, uniforms, animate_time } =>
137                Self::ShaderFill { pipeline_id, rect: rc(rect, src_origin, dst_origin, sx, sy), uniforms, animate_time },
138            Self::BackdropBlur { rect, radius, blur, tint } =>
139                Self::BackdropBlur { rect: rc(rect, src_origin, dst_origin, sx, sy), radius: radius * s, blur: blur * s, tint },
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
149        Rect { origin: Point { x, y }, size: Size { width: w, height: h } }
150    }
151
152    #[test]
153    fn morph_maps_a_rect_from_its_source_origin_to_the_destination_origin_and_size() {
154        // A widget captured at (0, 0, 100, 100) morphs to (200, 50, 40, 40) —
155        // its own rect (same as src) must land exactly on dst.
156        let cmd = DrawCommand::FillRect { rect: rect(0.0, 0.0, 100.0, 100.0), color: Color::RED };
157        let morphed = cmd.morph(Point { x: 0.0, y: 0.0 }, Point { x: 200.0, y: 50.0 }, 0.4, 0.4);
158        match morphed {
159            DrawCommand::FillRect { rect: r, .. } => {
160                assert!((r.origin.x - 200.0).abs() < 0.001);
161                assert!((r.origin.y - 50.0).abs() < 0.001);
162                assert!((r.size.width - 40.0).abs() < 0.001, "100 * 0.4 = 40, got {}", r.size.width);
163                assert!((r.size.height - 40.0).abs() < 0.001);
164            }
165            other => panic!("expected FillRect, got {other:?}"),
166        }
167    }
168
169    #[test]
170    fn morph_scales_geometry_nested_inside_the_captured_rect_proportionally() {
171        // A circle centered at the MIDDLE of a 100x100 capture (50,50) must
172        // land at the middle of the 40x40 destination (220,70), not at the
173        // destination's origin.
174        let cmd = DrawCommand::FillCircle { center: Point { x: 50.0, y: 50.0 }, radius: 10.0, color: Color::RED };
175        let morphed = cmd.morph(Point { x: 0.0, y: 0.0 }, Point { x: 200.0, y: 50.0 }, 0.4, 0.4);
176        match morphed {
177            DrawCommand::FillCircle { center, radius, .. } => {
178                assert!((center.x - 220.0).abs() < 0.001, "expected 200 + 50*0.4 = 220, got {}", center.x);
179                assert!((center.y - 70.0).abs() < 0.001, "expected 50 + 50*0.4 = 70, got {}", center.y);
180                assert!((radius - 4.0).abs() < 0.001, "10 * 0.4 = 4, got {}", radius);
181            }
182            other => panic!("expected FillCircle, got {other:?}"),
183        }
184    }
185
186    #[test]
187    fn morph_at_identity_scale_and_matching_origins_is_a_no_op() {
188        let cmd = DrawCommand::FillRect { rect: rect(10.0, 20.0, 30.0, 40.0), color: Color::RED };
189        let morphed = cmd.morph(Point { x: 10.0, y: 20.0 }, Point { x: 10.0, y: 20.0 }, 1.0, 1.0);
190        match morphed {
191            DrawCommand::FillRect { rect: r, .. } => {
192                assert_eq!(r.origin.x, 10.0);
193                assert_eq!(r.origin.y, 20.0);
194                assert_eq!(r.size.width, 30.0);
195                assert_eq!(r.size.height, 40.0);
196            }
197            other => panic!("expected FillRect, got {other:?}"),
198        }
199    }
200}