Skip to main content

rosace_render/
lib.rs

1//! `rosace-render` — software rasterizer and display-list recording for ROSACE.
2//!
3//! Provides [`SkiaCanvas`] (backed by `tiny-skia`), [`PictureRecorder`] for
4//! recording draw commands during the paint pass, and [`Picture`] for replaying
5//! them. The [`FontCache`] handles glyph rasterization.
6
7pub mod canvas;
8pub mod draw_command;
9pub mod font;
10pub mod gpu_shapes;
11pub mod image;
12pub mod picture;
13
14pub use canvas::{Color, ShaderQuadCmd, SkiaCanvas};
15pub use draw_command::DrawCommand;
16/// `OwnedFace` is exported so higher layers can parse fonts for
17/// [`font::FontCache::set_icon_face`] without their own swash dependency —
18/// same reasoning the old `pub use fontdue;` re-export existed for.
19pub use font::{FontCache, FontWeight, OwnedFace};
20pub use image::{CachePolicy, ImageFit, ImageHandle};
21pub use picture::{Picture, PictureRecorder};
22
23#[cfg(test)]
24mod tests {
25    use rosace_core::types::{Point, Rect, Size};
26
27    use crate::canvas::{Color, SkiaCanvas};
28    use crate::image::ImageHandle;
29
30    #[test]
31    fn canvas_clear_fills_with_color() {
32        let mut canvas = SkiaCanvas::new(10, 10);
33        canvas.clear(Color::RED);
34        let pixels = canvas.pixels();
35        assert_eq!(pixels[0], 255); // R
36        assert_eq!(pixels[1], 0);   // G
37        assert_eq!(pixels[2], 0);   // B
38        assert_eq!(pixels[3], 255); // A
39    }
40
41    #[test]
42    fn canvas_fill_rect_changes_pixels() {
43        let mut canvas = SkiaCanvas::new(100, 100);
44        canvas.clear(Color::WHITE);
45        canvas.fill_rect(
46            Rect {
47                origin: Point { x: 0.0, y: 0.0 },
48                size: Size { width: 10.0, height: 10.0 },
49            },
50            Color::BLUE,
51        );
52        let pixels = canvas.pixels();
53        assert_eq!(pixels[2], 255); // B channel
54    }
55
56    #[test]
57    fn blit_rgba_at_full_opacity_replaces_the_background() {
58        let mut canvas = SkiaCanvas::new(4, 4);
59        canvas.clear(Color::WHITE);
60        // A single fully-opaque red pixel, blitted 1:1.
61        let red_pixel = vec![255u8, 0, 0, 255];
62        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);
63        let px = canvas.pixels();
64        assert_eq!(&px[0..4], &[255, 0, 0, 255], "opacity=1.0 must fully replace the background");
65    }
66
67    #[test]
68    fn blit_rgba_at_zero_opacity_leaves_the_background_untouched() {
69        let mut canvas = SkiaCanvas::new(4, 4);
70        canvas.clear(Color::WHITE);
71        let red_pixel = vec![255u8, 0, 0, 255];
72        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);
73        let px = canvas.pixels();
74        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");
75    }
76
77    #[test]
78    fn blit_rgba_at_half_opacity_blends_partway_between_background_and_source() {
79        let mut canvas = SkiaCanvas::new(4, 4);
80        canvas.clear(Color::WHITE);
81        let red_pixel = vec![255u8, 0, 0, 255];
82        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);
83        let px = canvas.pixels();
84        // Halfway from white (255,255,255) toward red (255,0,0): R stays
85        // 255, G/B roughly halve. Allow rounding slack.
86        assert_eq!(px[0], 255, "R channel");
87        assert!((100..156).contains(&px[1]), "G channel should be roughly halved, got {}", px[1]);
88        assert!((100..156).contains(&px[2]), "B channel should be roughly halved, got {}", px[2]);
89    }
90
91    // ── ShaderFill collection (D109/Phase 27 Step 2) ─────────────────────
92
93    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
94        Rect { origin: Point { x, y }, size: Size { width: w, height: h } }
95    }
96
97    #[test]
98    fn shader_fill_is_collected_scaled_to_physical_px_not_rasterized() {
99        use crate::draw_command::DrawCommand;
100        use crate::font::FontCache;
101        use crate::picture::PictureRecorder;
102
103        // HiDPI canvas: logical coords must scale ×2 into the quad.
104        let mut canvas = SkiaCanvas::new_hidpi(200, 200, 2.0);
105        canvas.clear(Color::WHITE);
106        let before = canvas.pixels().to_vec();
107
108        let mut rec = PictureRecorder::new();
109        rec.push(DrawCommand::ShaderFill {
110            animate_time: false,
111            pipeline_id: 0x200,
112            rect: rect(10.0, 20.0, 30.0, 40.0),
113            uniforms: vec![1, 2, 3, 4],
114        });
115        canvas.play_picture(&rec.finish(), &FontCache::embedded());
116
117        let quads = canvas.take_shader_quads();
118        assert_eq!(quads.len(), 1);
119        assert_eq!(quads[0].pipeline_id, 0x200);
120        assert_eq!(quads[0].rect, (20.0, 40.0, 60.0, 80.0), "must be physical px (×2)");
121        assert_eq!(quads[0].uniforms, vec![1, 2, 3, 4]);
122        assert_eq!(quads[0].clip, None);
123        // No CPU pixel was touched — ShaderFill has no raster path.
124        assert_eq!(canvas.pixels(), &before[..], "ShaderFill must not rasterize");
125        assert!(canvas.take_shader_quads().is_empty(), "take must drain");
126    }
127
128    #[test]
129    fn shader_fill_captures_widget_clip_but_not_damage_clip() {
130        use crate::draw_command::DrawCommand;
131        use crate::font::FontCache;
132        use crate::picture::PictureRecorder;
133
134        let mut canvas = SkiaCanvas::new(100, 100);
135        // Damage clip active (partial repaint) — must NOT leak into quads.
136        canvas.set_logical_clip(Some(rect(0.0, 0.0, 5.0, 5.0)));
137
138        let mut rec = PictureRecorder::new();
139        rec.push(DrawCommand::PushClip { rect: rect(10.0, 10.0, 50.0, 50.0) });
140        rec.push(DrawCommand::PushClip { rect: rect(30.0, 30.0, 50.0, 50.0) });
141        rec.push(DrawCommand::ShaderFill {
142            animate_time: false,
143            pipeline_id: 0x300,
144            rect: rect(0.0, 0.0, 100.0, 100.0),
145            uniforms: vec![],
146        });
147        rec.push(DrawCommand::PopClip);
148        rec.push(DrawCommand::PopClip);
149        rec.push(DrawCommand::ShaderFill {
150            animate_time: false,
151            pipeline_id: 0x301,
152            rect: rect(0.0, 0.0, 10.0, 10.0),
153            uniforms: vec![],
154        });
155        canvas.play_picture(&rec.finish(), &FontCache::embedded());
156        canvas.set_logical_clip(None);
157
158        let quads = canvas.take_shader_quads();
159        assert_eq!(quads.len(), 2);
160        // Nested clips intersect: (10..60) ∩ (30..80) = (30, 30, 30, 30).
161        assert_eq!(quads[0].clip, Some((30.0, 30.0, 30.0, 30.0)));
162        // Outside all PushClips: no widget clip, damage clip ignored.
163        assert_eq!(quads[1].clip, None);
164    }
165
166    // ── GPU-shapes mode: C1 segment executor (D109/Phase 27 Step 3b) ────
167
168    #[test]
169    fn gpu_mode_partitions_commands_into_ordered_quads_and_segments() {
170        use crate::canvas::CanvasFrameItem;
171        use crate::draw_command::DrawCommand;
172        use crate::font::FontCache;
173        use crate::picture::PictureRecorder;
174
175        let mut canvas = SkiaCanvas::new(200, 200);
176        canvas.set_gpu_shapes(true);
177        canvas.clear(Color::rgb(30, 31, 34));
178
179        let mut rec = PictureRecorder::new();
180        // shape → text → shape: the text must land in a Segment BETWEEN the
181        // two shape quads (the Stack z-order case, correct by construction).
182        rec.push(DrawCommand::FillRect { rect: rect(10.0, 10.0, 50.0, 50.0), color: Color::RED });
183        rec.push(DrawCommand::DrawText {
184            text: "hi".into(), origin: Point { x: 20.0, y: 30.0 },
185            color: Color::WHITE, px: 14.0, weight: crate::FontWeight::Regular,
186        });
187        rec.push(DrawCommand::FillCircle { center: Point { x: 100.0, y: 100.0 }, radius: 20.0, color: Color::BLUE });
188        canvas.play_picture(&rec.finish(), &FontCache::embedded());
189
190        let items = canvas.take_frame_items();
191        assert_eq!(items.len(), 4, "bg quad + rect quad + glyph batch + circle quad: {items:?}");
192        assert!(matches!(&items[0], CanvasFrameItem::Shader(q) if q.pipeline_id == crate::gpu_shapes::FILL_RRECT_ID),
193            "item 0 must be the background quad");
194        assert!(matches!(&items[1], CanvasFrameItem::Shader(q) if q.pipeline_id == crate::gpu_shapes::FILL_RRECT_ID));
195        // Text is an atlas glyph batch BETWEEN the two shape quads (Step 4)
196        // — same z-order guarantee the segment path had.
197        let CanvasFrameItem::Glyphs { glyphs, clip } = &items[2] else {
198            panic!("item 2 must be the glyph batch, got {:?}", items[2]);
199        };
200        assert_eq!(glyphs.len(), 2, "'hi' = two placed glyphs");
201        assert_eq!(*clip, None);
202        assert!(glyphs[0].x >= 18.0 && glyphs[0].y >= 30.0 && glyphs[0].y <= 46.0,
203            "first glyph must sit near the text origin (baseline convention): {:?}", glyphs[0]);
204        assert!(glyphs[1].x > glyphs[0].x, "second glyph advances rightward");
205        assert!(glyphs.iter().all(|g| g.w > 0 && g.h > 0 && !g.bitmap.1.is_empty()));
206        assert!(matches!(&items[3], CanvasFrameItem::Shader(q) if q.pipeline_id == crate::gpu_shapes::FILL_RRECT_ID),
207            "circle renders via the fill-rrect pipeline");
208
209        // Nothing touched the CPU buffer: shapes are quads, text is atlas
210        // glyphs — the scratch pixmap only ever holds Blit segments now.
211        assert!(canvas.pixels().iter().all(|&b| b == 0), "scratch pixmap must stay empty");
212    }
213
214    #[test]
215    fn gpu_mode_consecutive_text_coalesces_into_one_glyph_batch() {
216        use crate::canvas::CanvasFrameItem;
217        use crate::draw_command::DrawCommand;
218        use crate::font::FontCache;
219        use crate::picture::PictureRecorder;
220
221        let mut canvas = SkiaCanvas::new(200, 200);
222        canvas.set_gpu_shapes(true);
223        canvas.clear(Color::rgb(0, 0, 0));
224        let mut rec = PictureRecorder::new();
225        for (i, s) in ["ab", "cd"].iter().enumerate() {
226            rec.push(DrawCommand::DrawText {
227                text: (*s).into(), origin: Point { x: 10.0, y: 20.0 + i as f32 * 20.0 },
228                color: Color::WHITE, px: 12.0, weight: crate::FontWeight::Regular,
229            });
230        }
231        canvas.play_picture(&rec.finish(), &FontCache::embedded());
232        let items = canvas.take_frame_items();
233        assert_eq!(items.len(), 2, "bg + ONE coalesced glyph batch: {items:?}");
234        let CanvasFrameItem::Glyphs { glyphs, .. } = &items[1] else { panic!() };
235        assert_eq!(glyphs.len(), 4, "both runs batch together");
236    }
237
238    #[test]
239    fn gpu_mode_clear_records_background_quad_and_resets_items() {
240        use crate::canvas::CanvasFrameItem;
241
242        let mut canvas = SkiaCanvas::new(50, 40);
243        canvas.set_gpu_shapes(true);
244        canvas.clear(Color::rgb(10, 20, 30));
245        canvas.clear(Color::rgb(10, 20, 30)); // second frame: items reset, not appended
246        let items = canvas.take_frame_items();
247        assert_eq!(items.len(), 1, "clear must reset the item list each frame");
248        let CanvasFrameItem::Shader(q) = &items[0] else { panic!("bg must be a quad") };
249        // Full-frame + 1px AA inflation on each side.
250        assert_eq!(q.rect, (-1.0, -1.0, 52.0, 42.0));
251    }
252
253    #[test]
254    fn gpu_mode_blit_becomes_image_item_with_stable_content_key() {
255        use crate::canvas::CanvasFrameItem;
256        use crate::draw_command::DrawCommand;
257        use crate::font::FontCache;
258        use crate::picture::PictureRecorder;
259        use std::sync::Arc;
260
261        let mut canvas = SkiaCanvas::new(100, 100);
262        canvas.set_gpu_shapes(true);
263        canvas.clear(Color::rgb(0, 0, 0));
264        let px: Arc<Vec<u8>> = Arc::new(vec![200u8; 8 * 8 * 4]);
265        let mut rec = PictureRecorder::new();
266        rec.push(DrawCommand::BlitRgba {
267            pixels: px.clone(), src_width: 8, src_height: 8,
268            dest_rect: rect(10.0, 20.0, 16.0, 16.0), opacity: 0.5,
269        });
270        canvas.play_picture(&rec.finish(), &FontCache::embedded());
271        let items = canvas.take_frame_items();
272        let CanvasFrameItem::Image { key, dest, opacity, src_w, .. } = &items[1] else {
273            panic!("blit must become an Image item, got {:?}", items[1]);
274        };
275        assert_eq!(*dest, (10.0, 20.0, 16.0, 16.0));
276        assert_eq!(*opacity, 0.5);
277        assert_eq!(*src_w, 8);
278        // Key is content-derived and stable: a SEPARATE allocation with the
279        // same bytes produces the same key (decode-cache misses can't
280        // invalidate GPU textures).
281        let px2: Arc<Vec<u8>> = Arc::new(vec![200u8; 8 * 8 * 4]);
282        assert_eq!(*key, crate::canvas::blit_key(&px2, 8, 8));
283        // No CPU pixel was touched.
284        assert!(canvas.pixels().iter().all(|&b| b == 0));
285    }
286
287    #[test]
288    fn cpu_mode_is_unchanged_by_gpu_mode_existing() {
289        // Default canvases (engine tests, scroll content, overlay, web)
290        // must behave exactly as before: shapes rasterize, no items.
291        use crate::draw_command::DrawCommand;
292        use crate::font::FontCache;
293        use crate::picture::PictureRecorder;
294
295        let mut canvas = SkiaCanvas::new(20, 20);
296        canvas.clear(Color::WHITE);
297        let mut rec = PictureRecorder::new();
298        rec.push(DrawCommand::FillRect { rect: rect(0.0, 0.0, 10.0, 10.0), color: Color::BLUE });
299        canvas.play_picture(&rec.finish(), &FontCache::embedded());
300        assert_eq!(canvas.pixels()[2], 255, "CPU mode must still rasterize");
301        assert!(canvas.take_frame_items().is_empty());
302    }
303
304    // ── `take_frame_dirty` gates `take_frame_items` (D109 overlay-GPU
305    // support, 2026-08-04): `rosace-platform` only refreshes its retained
306    // `overlay_frame_items` when `take_frame_dirty()` reports true —
307    // otherwise it keeps whatever was captured on the last frame the
308    // overlay actually repainted (the overlay is cleared+replayed only
309    // when something opens/closes/changes, not every present, same
310    // retention contract the base canvas's `frame_items` already relies
311    // on). This locks in the exact contract that gating depends on, since
312    // neither `take_frame_dirty` nor this retention pattern had a test
313    // before this pass despite already being load-bearing for the base
314    // canvas's own frame_items. ─────────────────────────────────────────
315
316    #[test]
317    fn take_frame_dirty_requires_an_explicit_mark_paint_alone_does_not_set_it() {
318        // `frame_dirty` is NOT a side effect of `clear`/`play_picture` — it
319        // is only ever set by an explicit `mark_frame_dirty()` call from
320        // the frame loop (found the hard way: a first draft of the
321        // overlay-GPU-support test above assumed painting alone dirtied
322        // the flag, and failed — `rosace/src/engine.rs` only called
323        // `mark_frame_dirty()` for the BASE canvas at its own paint site;
324        // nothing called it for `overlay_canvas`, so a gated caller like
325        // `rosace-platform`'s retained-items pattern would have populated
326        // its retained overlay items ONCE ever, then silently frozen —
327        // every dialog/menu/drawer after the first would render stale
328        // content forever, with no crash or warning. Fixed by adding the
329        // missing `overlay_canvas.mark_frame_dirty()` call at the engine's
330        // own overlay-clear site, same altitude as the base canvas's.)
331        use crate::draw_command::DrawCommand;
332        use crate::font::FontCache;
333        use crate::picture::PictureRecorder;
334
335        let mut canvas = SkiaCanvas::new(20, 20);
336        canvas.set_gpu_shapes(true);
337        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");
338
339        canvas.clear(Color::WHITE);
340        let mut rec = PictureRecorder::new();
341        rec.push(DrawCommand::FillRect { rect: rect(0.0, 0.0, 10.0, 10.0), color: Color::BLUE });
342        canvas.play_picture(&rec.finish(), &FontCache::embedded());
343        assert!(!canvas.take_frame_dirty(), "painting alone must NOT set frame_dirty — only mark_frame_dirty() does");
344
345        canvas.mark_frame_dirty();
346        assert!(canvas.take_frame_dirty(), "an explicit mark must report dirty exactly once");
347        assert!(!canvas.take_frame_dirty(), "consuming the flag must reset it — a second call with no new mark must be false");
348    }
349
350    #[test]
351    fn frame_items_are_retrievable_exactly_once_per_dirty_paint_gate() {
352        // Models `rosace-platform`'s exact usage: `if take_frame_dirty() {
353        // retained = take_frame_items() }` — a caller that (correctly)
354        // skips calling `take_frame_items` on a non-dirty frame keeps
355        // whatever it captured last time, at the CALLER level (this canvas
356        // API doesn't retain on its own — the retention is the caller's
357        // responsibility, which is exactly the bug class a caller that
358        // unconditionally calls `take_frame_items` every frame would hit:
359        // it would silently wipe its own retained set to empty).
360        use crate::draw_command::DrawCommand;
361        use crate::font::FontCache;
362        use crate::picture::PictureRecorder;
363
364        let mut canvas = SkiaCanvas::new(20, 20);
365        canvas.set_gpu_shapes(true);
366        canvas.clear(Color::WHITE);
367        let mut rec = PictureRecorder::new();
368        rec.push(DrawCommand::FillRect { rect: rect(0.0, 0.0, 10.0, 10.0), color: Color::BLUE });
369        canvas.play_picture(&rec.finish(), &FontCache::embedded());
370
371        // Simulate the app.rs retention pattern directly.
372        let mut retained = Vec::new();
373        if canvas.take_frame_dirty() {
374            retained = canvas.take_frame_items();
375        }
376        assert!(!retained.is_empty(), "the dirty frame must populate the retained set");
377        let captured_len = retained.len();
378
379        // A later frame where the overlay did NOT repaint: dirty is false,
380        // so the gated caller must NOT call take_frame_items — and if it
381        // correctly skips that call, `retained` must be untouched.
382        if canvas.take_frame_dirty() {
383            retained = canvas.take_frame_items();
384        }
385        assert_eq!(retained.len(), captured_len, "a non-dirty frame must leave the caller's retained set untouched");
386    }
387
388    #[test]
389    fn image_handle_from_valid_png() {
390        let png_bytes: &[u8] = &[
391            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
392            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
393            0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
394            0xD7, 0x63, 0xF8, 0xFF, 0xFF, 0x3F, 0x00, 0x05, 0xFE, 0x02, 0xFE, 0xDC, 0xCC, 0x59,
395            0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
396        ];
397        let handle = ImageHandle::from_png_bytes(png_bytes);
398        assert!(handle.is_some());
399        let h = handle.unwrap();
400        assert_eq!(h.width, 1);
401        assert_eq!(h.height, 1);
402    }
403}