1pub 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;
16pub 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); assert_eq!(pixels[1], 0); assert_eq!(pixels[2], 0); assert_eq!(pixels[3], 255); }
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); }
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 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 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 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 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 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 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 assert_eq!(quads[0].clip, Some((30.0, 30.0, 30.0, 30.0)));
162 assert_eq!(quads[1].clip, None);
164 }
165
166 #[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 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 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 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)); 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 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 let px2: Arc<Vec<u8>> = Arc::new(vec![200u8; 8 * 8 * 4]);
282 assert_eq!(*key, crate::canvas::blit_key(&px2, 8, 8));
283 assert!(canvas.pixels().iter().all(|&b| b == 0));
285 }
286
287 #[test]
288 fn cpu_mode_is_unchanged_by_gpu_mode_existing() {
289 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 #[test]
317 fn take_frame_dirty_requires_an_explicit_mark_paint_alone_does_not_set_it() {
318 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 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 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 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}