rosace-render 0.1.0

GPU/CPU hybrid renderer for ROSACE with dirty-region tracking
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! `rosace-render` — software rasterizer and display-list recording for ROSACE.
//!
//! Provides [`SkiaCanvas`] (backed by `tiny-skia`), [`PictureRecorder`] for
//! recording draw commands during the paint pass, and [`Picture`] for replaying
//! them. The [`FontCache`] handles glyph rasterization.

pub mod canvas;
pub mod draw_command;
pub mod font;
pub mod gpu_shapes;
pub mod image;
pub mod picture;

pub use canvas::{Color, ShaderQuadCmd, SkiaCanvas};
pub use draw_command::DrawCommand;
/// `OwnedFace` is exported so higher layers can parse fonts for
/// [`font::FontCache::set_icon_face`] without their own swash dependency —
/// same reasoning the old `pub use fontdue;` re-export existed for.
pub use font::{FontCache, FontWeight, OwnedFace};
pub use image::{CachePolicy, ImageFit, ImageHandle};
pub use picture::{Picture, PictureRecorder};

#[cfg(test)]
mod tests {
    use rosace_core::types::{Point, Rect, Size};

    use crate::canvas::{Color, SkiaCanvas};
    use crate::image::ImageHandle;

    #[test]
    fn canvas_clear_fills_with_color() {
        let mut canvas = SkiaCanvas::new(10, 10);
        canvas.clear(Color::RED);
        let pixels = canvas.pixels();
        assert_eq!(pixels[0], 255); // R
        assert_eq!(pixels[1], 0);   // G
        assert_eq!(pixels[2], 0);   // B
        assert_eq!(pixels[3], 255); // A
    }

    #[test]
    fn canvas_fill_rect_changes_pixels() {
        let mut canvas = SkiaCanvas::new(100, 100);
        canvas.clear(Color::WHITE);
        canvas.fill_rect(
            Rect {
                origin: Point { x: 0.0, y: 0.0 },
                size: Size { width: 10.0, height: 10.0 },
            },
            Color::BLUE,
        );
        let pixels = canvas.pixels();
        assert_eq!(pixels[2], 255); // B channel
    }

    #[test]
    fn blit_rgba_at_full_opacity_replaces_the_background() {
        let mut canvas = SkiaCanvas::new(4, 4);
        canvas.clear(Color::WHITE);
        // A single fully-opaque red pixel, blitted 1:1.
        let red_pixel = vec![255u8, 0, 0, 255];
        canvas.blit_rgba(&red_pixel, 1, 1, Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 1.0, height: 1.0 } }, 1.0);
        let px = canvas.pixels();
        assert_eq!(&px[0..4], &[255, 0, 0, 255], "opacity=1.0 must fully replace the background");
    }

    #[test]
    fn blit_rgba_at_zero_opacity_leaves_the_background_untouched() {
        let mut canvas = SkiaCanvas::new(4, 4);
        canvas.clear(Color::WHITE);
        let red_pixel = vec![255u8, 0, 0, 255];
        canvas.blit_rgba(&red_pixel, 1, 1, Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 1.0, height: 1.0 } }, 0.0);
        let px = canvas.pixels();
        assert_eq!(&px[0..4], &[255, 255, 255, 255], "opacity=0.0 must leave the white background untouched — this is the D108/Phase 26 Step 4 image fade-in's very first frame");
    }

    #[test]
    fn blit_rgba_at_half_opacity_blends_partway_between_background_and_source() {
        let mut canvas = SkiaCanvas::new(4, 4);
        canvas.clear(Color::WHITE);
        let red_pixel = vec![255u8, 0, 0, 255];
        canvas.blit_rgba(&red_pixel, 1, 1, Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 1.0, height: 1.0 } }, 0.5);
        let px = canvas.pixels();
        // Halfway from white (255,255,255) toward red (255,0,0): R stays
        // 255, G/B roughly halve. Allow rounding slack.
        assert_eq!(px[0], 255, "R channel");
        assert!((100..156).contains(&px[1]), "G channel should be roughly halved, got {}", px[1]);
        assert!((100..156).contains(&px[2]), "B channel should be roughly halved, got {}", px[2]);
    }

    // ── ShaderFill collection (D109/Phase 27 Step 2) ─────────────────────

    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
        Rect { origin: Point { x, y }, size: Size { width: w, height: h } }
    }

    #[test]
    fn shader_fill_is_collected_scaled_to_physical_px_not_rasterized() {
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;

        // HiDPI canvas: logical coords must scale ×2 into the quad.
        let mut canvas = SkiaCanvas::new_hidpi(200, 200, 2.0);
        canvas.clear(Color::WHITE);
        let before = canvas.pixels().to_vec();

        let mut rec = PictureRecorder::new();
        rec.push(DrawCommand::ShaderFill {
            animate_time: false,
            pipeline_id: 0x200,
            rect: rect(10.0, 20.0, 30.0, 40.0),
            uniforms: vec![1, 2, 3, 4],
        });
        canvas.play_picture(&rec.finish(), &FontCache::embedded());

        let quads = canvas.take_shader_quads();
        assert_eq!(quads.len(), 1);
        assert_eq!(quads[0].pipeline_id, 0x200);
        assert_eq!(quads[0].rect, (20.0, 40.0, 60.0, 80.0), "must be physical px (×2)");
        assert_eq!(quads[0].uniforms, vec![1, 2, 3, 4]);
        assert_eq!(quads[0].clip, None);
        // No CPU pixel was touched — ShaderFill has no raster path.
        assert_eq!(canvas.pixels(), &before[..], "ShaderFill must not rasterize");
        assert!(canvas.take_shader_quads().is_empty(), "take must drain");
    }

    #[test]
    fn shader_fill_captures_widget_clip_but_not_damage_clip() {
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;

        let mut canvas = SkiaCanvas::new(100, 100);
        // Damage clip active (partial repaint) — must NOT leak into quads.
        canvas.set_logical_clip(Some(rect(0.0, 0.0, 5.0, 5.0)));

        let mut rec = PictureRecorder::new();
        rec.push(DrawCommand::PushClip { rect: rect(10.0, 10.0, 50.0, 50.0) });
        rec.push(DrawCommand::PushClip { rect: rect(30.0, 30.0, 50.0, 50.0) });
        rec.push(DrawCommand::ShaderFill {
            animate_time: false,
            pipeline_id: 0x300,
            rect: rect(0.0, 0.0, 100.0, 100.0),
            uniforms: vec![],
        });
        rec.push(DrawCommand::PopClip);
        rec.push(DrawCommand::PopClip);
        rec.push(DrawCommand::ShaderFill {
            animate_time: false,
            pipeline_id: 0x301,
            rect: rect(0.0, 0.0, 10.0, 10.0),
            uniforms: vec![],
        });
        canvas.play_picture(&rec.finish(), &FontCache::embedded());
        canvas.set_logical_clip(None);

        let quads = canvas.take_shader_quads();
        assert_eq!(quads.len(), 2);
        // Nested clips intersect: (10..60) ∩ (30..80) = (30, 30, 30, 30).
        assert_eq!(quads[0].clip, Some((30.0, 30.0, 30.0, 30.0)));
        // Outside all PushClips: no widget clip, damage clip ignored.
        assert_eq!(quads[1].clip, None);
    }

    // ── GPU-shapes mode: C1 segment executor (D109/Phase 27 Step 3b) ────

    #[test]
    fn gpu_mode_partitions_commands_into_ordered_quads_and_segments() {
        use crate::canvas::CanvasFrameItem;
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;

        let mut canvas = SkiaCanvas::new(200, 200);
        canvas.set_gpu_shapes(true);
        canvas.clear(Color::rgb(30, 31, 34));

        let mut rec = PictureRecorder::new();
        // shape → text → shape: the text must land in a Segment BETWEEN the
        // two shape quads (the Stack z-order case, correct by construction).
        rec.push(DrawCommand::FillRect { rect: rect(10.0, 10.0, 50.0, 50.0), color: Color::RED });
        rec.push(DrawCommand::DrawText {
            text: "hi".into(), origin: Point { x: 20.0, y: 30.0 },
            color: Color::WHITE, px: 14.0, weight: crate::FontWeight::Regular,
        });
        rec.push(DrawCommand::FillCircle { center: Point { x: 100.0, y: 100.0 }, radius: 20.0, color: Color::BLUE });
        canvas.play_picture(&rec.finish(), &FontCache::embedded());

        let items = canvas.take_frame_items();
        assert_eq!(items.len(), 4, "bg quad + rect quad + glyph batch + circle quad: {items:?}");
        assert!(matches!(&items[0], CanvasFrameItem::Shader(q) if q.pipeline_id == crate::gpu_shapes::FILL_RRECT_ID),
            "item 0 must be the background quad");
        assert!(matches!(&items[1], CanvasFrameItem::Shader(q) if q.pipeline_id == crate::gpu_shapes::FILL_RRECT_ID));
        // Text is an atlas glyph batch BETWEEN the two shape quads (Step 4)
        // — same z-order guarantee the segment path had.
        let CanvasFrameItem::Glyphs { glyphs, clip } = &items[2] else {
            panic!("item 2 must be the glyph batch, got {:?}", items[2]);
        };
        assert_eq!(glyphs.len(), 2, "'hi' = two placed glyphs");
        assert_eq!(*clip, None);
        assert!(glyphs[0].x >= 18.0 && glyphs[0].y >= 30.0 && glyphs[0].y <= 46.0,
            "first glyph must sit near the text origin (baseline convention): {:?}", glyphs[0]);
        assert!(glyphs[1].x > glyphs[0].x, "second glyph advances rightward");
        assert!(glyphs.iter().all(|g| g.w > 0 && g.h > 0 && !g.bitmap.1.is_empty()));
        assert!(matches!(&items[3], CanvasFrameItem::Shader(q) if q.pipeline_id == crate::gpu_shapes::FILL_RRECT_ID),
            "circle renders via the fill-rrect pipeline");

        // Nothing touched the CPU buffer: shapes are quads, text is atlas
        // glyphs — the scratch pixmap only ever holds Blit segments now.
        assert!(canvas.pixels().iter().all(|&b| b == 0), "scratch pixmap must stay empty");
    }

    #[test]
    fn gpu_mode_consecutive_text_coalesces_into_one_glyph_batch() {
        use crate::canvas::CanvasFrameItem;
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;

        let mut canvas = SkiaCanvas::new(200, 200);
        canvas.set_gpu_shapes(true);
        canvas.clear(Color::rgb(0, 0, 0));
        let mut rec = PictureRecorder::new();
        for (i, s) in ["ab", "cd"].iter().enumerate() {
            rec.push(DrawCommand::DrawText {
                text: (*s).into(), origin: Point { x: 10.0, y: 20.0 + i as f32 * 20.0 },
                color: Color::WHITE, px: 12.0, weight: crate::FontWeight::Regular,
            });
        }
        canvas.play_picture(&rec.finish(), &FontCache::embedded());
        let items = canvas.take_frame_items();
        assert_eq!(items.len(), 2, "bg + ONE coalesced glyph batch: {items:?}");
        let CanvasFrameItem::Glyphs { glyphs, .. } = &items[1] else { panic!() };
        assert_eq!(glyphs.len(), 4, "both runs batch together");
    }

    #[test]
    fn gpu_mode_clear_records_background_quad_and_resets_items() {
        use crate::canvas::CanvasFrameItem;

        let mut canvas = SkiaCanvas::new(50, 40);
        canvas.set_gpu_shapes(true);
        canvas.clear(Color::rgb(10, 20, 30));
        canvas.clear(Color::rgb(10, 20, 30)); // second frame: items reset, not appended
        let items = canvas.take_frame_items();
        assert_eq!(items.len(), 1, "clear must reset the item list each frame");
        let CanvasFrameItem::Shader(q) = &items[0] else { panic!("bg must be a quad") };
        // Full-frame + 1px AA inflation on each side.
        assert_eq!(q.rect, (-1.0, -1.0, 52.0, 42.0));
    }

    #[test]
    fn gpu_mode_blit_becomes_image_item_with_stable_content_key() {
        use crate::canvas::CanvasFrameItem;
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;
        use std::sync::Arc;

        let mut canvas = SkiaCanvas::new(100, 100);
        canvas.set_gpu_shapes(true);
        canvas.clear(Color::rgb(0, 0, 0));
        let px: Arc<Vec<u8>> = Arc::new(vec![200u8; 8 * 8 * 4]);
        let mut rec = PictureRecorder::new();
        rec.push(DrawCommand::BlitRgba {
            pixels: px.clone(), src_width: 8, src_height: 8,
            dest_rect: rect(10.0, 20.0, 16.0, 16.0), opacity: 0.5,
        });
        canvas.play_picture(&rec.finish(), &FontCache::embedded());
        let items = canvas.take_frame_items();
        let CanvasFrameItem::Image { key, dest, opacity, src_w, .. } = &items[1] else {
            panic!("blit must become an Image item, got {:?}", items[1]);
        };
        assert_eq!(*dest, (10.0, 20.0, 16.0, 16.0));
        assert_eq!(*opacity, 0.5);
        assert_eq!(*src_w, 8);
        // Key is content-derived and stable: a SEPARATE allocation with the
        // same bytes produces the same key (decode-cache misses can't
        // invalidate GPU textures).
        let px2: Arc<Vec<u8>> = Arc::new(vec![200u8; 8 * 8 * 4]);
        assert_eq!(*key, crate::canvas::blit_key(&px2, 8, 8));
        // No CPU pixel was touched.
        assert!(canvas.pixels().iter().all(|&b| b == 0));
    }

    #[test]
    fn cpu_mode_is_unchanged_by_gpu_mode_existing() {
        // Default canvases (engine tests, scroll content, overlay, web)
        // must behave exactly as before: shapes rasterize, no items.
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;

        let mut canvas = SkiaCanvas::new(20, 20);
        canvas.clear(Color::WHITE);
        let mut rec = PictureRecorder::new();
        rec.push(DrawCommand::FillRect { rect: rect(0.0, 0.0, 10.0, 10.0), color: Color::BLUE });
        canvas.play_picture(&rec.finish(), &FontCache::embedded());
        assert_eq!(canvas.pixels()[2], 255, "CPU mode must still rasterize");
        assert!(canvas.take_frame_items().is_empty());
    }

    // ── `take_frame_dirty` gates `take_frame_items` (D109 overlay-GPU
    // support, 2026-08-04): `rosace-platform` only refreshes its retained
    // `overlay_frame_items` when `take_frame_dirty()` reports true —
    // otherwise it keeps whatever was captured on the last frame the
    // overlay actually repainted (the overlay is cleared+replayed only
    // when something opens/closes/changes, not every present, same
    // retention contract the base canvas's `frame_items` already relies
    // on). This locks in the exact contract that gating depends on, since
    // neither `take_frame_dirty` nor this retention pattern had a test
    // before this pass despite already being load-bearing for the base
    // canvas's own frame_items. ─────────────────────────────────────────

    #[test]
    fn take_frame_dirty_requires_an_explicit_mark_paint_alone_does_not_set_it() {
        // `frame_dirty` is NOT a side effect of `clear`/`play_picture` — it
        // is only ever set by an explicit `mark_frame_dirty()` call from
        // the frame loop (found the hard way: a first draft of the
        // overlay-GPU-support test above assumed painting alone dirtied
        // the flag, and failed — `rosace/src/engine.rs` only called
        // `mark_frame_dirty()` for the BASE canvas at its own paint site;
        // nothing called it for `overlay_canvas`, so a gated caller like
        // `rosace-platform`'s retained-items pattern would have populated
        // its retained overlay items ONCE ever, then silently frozen —
        // every dialog/menu/drawer after the first would render stale
        // content forever, with no crash or warning. Fixed by adding the
        // missing `overlay_canvas.mark_frame_dirty()` call at the engine's
        // own overlay-clear site, same altitude as the base canvas's.)
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;

        let mut canvas = SkiaCanvas::new(20, 20);
        canvas.set_gpu_shapes(true);
        assert!(canvas.take_frame_dirty(), "a freshly constructed canvas starts dirty (so the first frame always uploads) — consume that before testing paint's own effect");

        canvas.clear(Color::WHITE);
        let mut rec = PictureRecorder::new();
        rec.push(DrawCommand::FillRect { rect: rect(0.0, 0.0, 10.0, 10.0), color: Color::BLUE });
        canvas.play_picture(&rec.finish(), &FontCache::embedded());
        assert!(!canvas.take_frame_dirty(), "painting alone must NOT set frame_dirty — only mark_frame_dirty() does");

        canvas.mark_frame_dirty();
        assert!(canvas.take_frame_dirty(), "an explicit mark must report dirty exactly once");
        assert!(!canvas.take_frame_dirty(), "consuming the flag must reset it — a second call with no new mark must be false");
    }

    #[test]
    fn frame_items_are_retrievable_exactly_once_per_dirty_paint_gate() {
        // Models `rosace-platform`'s exact usage: `if take_frame_dirty() {
        // retained = take_frame_items() }` — a caller that (correctly)
        // skips calling `take_frame_items` on a non-dirty frame keeps
        // whatever it captured last time, at the CALLER level (this canvas
        // API doesn't retain on its own — the retention is the caller's
        // responsibility, which is exactly the bug class a caller that
        // unconditionally calls `take_frame_items` every frame would hit:
        // it would silently wipe its own retained set to empty).
        use crate::draw_command::DrawCommand;
        use crate::font::FontCache;
        use crate::picture::PictureRecorder;

        let mut canvas = SkiaCanvas::new(20, 20);
        canvas.set_gpu_shapes(true);
        canvas.clear(Color::WHITE);
        let mut rec = PictureRecorder::new();
        rec.push(DrawCommand::FillRect { rect: rect(0.0, 0.0, 10.0, 10.0), color: Color::BLUE });
        canvas.play_picture(&rec.finish(), &FontCache::embedded());

        // Simulate the app.rs retention pattern directly.
        let mut retained = Vec::new();
        if canvas.take_frame_dirty() {
            retained = canvas.take_frame_items();
        }
        assert!(!retained.is_empty(), "the dirty frame must populate the retained set");
        let captured_len = retained.len();

        // A later frame where the overlay did NOT repaint: dirty is false,
        // so the gated caller must NOT call take_frame_items — and if it
        // correctly skips that call, `retained` must be untouched.
        if canvas.take_frame_dirty() {
            retained = canvas.take_frame_items();
        }
        assert_eq!(retained.len(), captured_len, "a non-dirty frame must leave the caller's retained set untouched");
    }

    #[test]
    fn image_handle_from_valid_png() {
        let png_bytes: &[u8] = &[
            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
            0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
            0xD7, 0x63, 0xF8, 0xFF, 0xFF, 0x3F, 0x00, 0x05, 0xFE, 0x02, 0xFE, 0xDC, 0xCC, 0x59,
            0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
        ];
        let handle = ImageHandle::from_png_bytes(png_bytes);
        assert!(handle.is_some());
        let h = handle.unwrap();
        assert_eq!(h.width, 1);
        assert_eq!(h.height, 1);
    }
}