1 | // Copyright © SixtyFPS GmbH <info@slint.dev> |
2 | // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 |
3 | |
4 | // cSpell: ignore powf |
5 | |
6 | use std::rc::Rc; |
7 | |
8 | use crossterm::style::Stylize; |
9 | |
10 | use i_slint_core::graphics::{euclid, IntRect, Rgb8Pixel, SharedPixelBuffer}; |
11 | use i_slint_core::lengths::LogicalRect; |
12 | use i_slint_core::platform::PlatformError; |
13 | use i_slint_core::renderer::RendererSealed; |
14 | use i_slint_core::software_renderer::{ |
15 | LineBufferProvider, MinimalSoftwareWindow, RenderingRotation, |
16 | }; |
17 | |
18 | pub struct SwrTestingBackend { |
19 | window: Rc<MinimalSoftwareWindow>, |
20 | } |
21 | |
22 | impl i_slint_core::platform::Platform for SwrTestingBackend { |
23 | fn create_window_adapter( |
24 | &self, |
25 | ) -> Result<Rc<dyn i_slint_core::platform::WindowAdapter>, PlatformError> { |
26 | Ok(self.window.clone()) |
27 | } |
28 | |
29 | fn duration_since_start(&self) -> core::time::Duration { |
30 | core::time::Duration::from_millis(i_slint_core::animations::current_tick().0) |
31 | } |
32 | } |
33 | |
34 | pub fn init_swr() -> Rc<MinimalSoftwareWindow> { |
35 | let window: Rc = MinimalSoftwareWindow::new( |
36 | i_slint_core::software_renderer::RepaintBufferType::ReusedBuffer, |
37 | ); |
38 | |
39 | i_slint_coreResult<(), SetPlatformError>::platform::set_platform(Box::new(SwrTestingBackend { window: window.clone() })) |
40 | .unwrap(); |
41 | |
42 | window |
43 | } |
44 | |
45 | pub fn image_buffer(path: &str) -> Result<SharedPixelBuffer<Rgb8Pixel>, image::ImageError> { |
46 | image::open(path).map(|image: DynamicImage| { |
47 | let image: ImageBuffer, Vec<…>> = image.into_rgb8(); |
48 | SharedPixelBuffer::<Rgb8Pixel>::clone_from_slice( |
49 | pixel_slice:image.as_raw(), |
50 | image.width(), |
51 | image.height(), |
52 | ) |
53 | }) |
54 | } |
55 | |
56 | pub fn screenshot( |
57 | window: Rc<MinimalSoftwareWindow>, |
58 | rotated: RenderingRotation, |
59 | ) -> SharedPixelBuffer<Rgb8Pixel> { |
60 | let size = window.size(); |
61 | let width = size.width; |
62 | let height = size.height; |
63 | |
64 | let mut buffer = match rotated { |
65 | RenderingRotation::Rotate90 | RenderingRotation::Rotate270 => { |
66 | SharedPixelBuffer::<Rgb8Pixel>::new(height, width) |
67 | } |
68 | _ => SharedPixelBuffer::<Rgb8Pixel>::new(width, height), |
69 | }; |
70 | |
71 | // render to buffer |
72 | window.request_redraw(); |
73 | window.draw_if_needed(|renderer| { |
74 | renderer.mark_dirty_region( |
75 | LogicalRect::from_size(euclid::size2(width as f32, height as f32)).into(), |
76 | ); |
77 | renderer.set_rendering_rotation(rotated); |
78 | let stride = buffer.width() as usize; |
79 | renderer.render(buffer.make_mut_slice(), stride); |
80 | renderer.set_rendering_rotation(RenderingRotation::NoRotation); |
81 | }); |
82 | |
83 | buffer |
84 | } |
85 | |
86 | struct TestingLineBuffer<'a> { |
87 | buffer: &'a mut [Rgb8Pixel], |
88 | stride: usize, |
89 | region: Option<IntRect>, |
90 | } |
91 | |
92 | impl LineBufferProvider for TestingLineBuffer<'_> { |
93 | type TargetPixel = Rgb8Pixel; |
94 | |
95 | fn process_line( |
96 | &mut self, |
97 | line: usize, |
98 | range: core::ops::Range<usize>, |
99 | render_fn: impl FnOnce(&mut [Self::TargetPixel]), |
100 | ) { |
101 | if let Some(r: Rect) = self.region.map(|r: Rect| r.cast::<usize>()) { |
102 | assert!(r.y_range().contains(&line), "line {line} out of range {r:?}" ); |
103 | assert_eq!(r.cast().x_range(), range); |
104 | } |
105 | let start: usize = line * self.stride + range.start; |
106 | let end: usize = line * self.stride + range.end; |
107 | render_fn(&mut self.buffer[start..end]); |
108 | } |
109 | } |
110 | |
111 | fn color_difference(lhs: &Rgb8Pixel, rhs: &Rgb8Pixel) -> f32 { |
112 | ((rhs.r as f32 - lhs.r as f32).powf(2.) |
113 | + (rhs.g as f32 - lhs.g as f32).powf(2.) |
114 | + (rhs.b as f32 - lhs.b as f32).powf(2.)) |
115 | .sqrt() |
116 | } |
117 | |
118 | #[derive (Default, Clone)] |
119 | pub struct TestCaseOptions { |
120 | /// How much we allow the maximum pixel difference to be when operating a screen rotation |
121 | pub rotation_threshold: f32, |
122 | |
123 | /// When true, we don't compare screenshots rendered with clipping |
124 | pub skip_clipping: bool, |
125 | } |
126 | |
127 | fn compare_images( |
128 | reference_path: &str, |
129 | screenshot: &SharedPixelBuffer<Rgb8Pixel>, |
130 | rotated: RenderingRotation, |
131 | options: &TestCaseOptions, |
132 | ) -> Result<(), String> { |
133 | let compare = || { |
134 | let reference = image_buffer(reference_path) |
135 | .map_err(|image_err| format!("error loading reference image: {image_err:#}" ))?; |
136 | |
137 | let mut ref_size = reference.size(); |
138 | if matches!(rotated, RenderingRotation::Rotate90 | RenderingRotation::Rotate270) { |
139 | std::mem::swap(&mut ref_size.width, &mut ref_size.height); |
140 | } |
141 | if ref_size != screenshot.size() { |
142 | return Err(format!( |
143 | "image sizes don't match. reference size {:#?} rendered size {:#?}" , |
144 | reference.size(), |
145 | screenshot.size() |
146 | )); |
147 | } |
148 | if reference.as_bytes() == screenshot.as_bytes() && rotated != RenderingRotation::NoRotation |
149 | { |
150 | return Ok(()); |
151 | } |
152 | |
153 | let idx = |x: u32, y: u32| -> u32 { |
154 | match rotated { |
155 | RenderingRotation::Rotate90 => (reference.height() - x - 1) * reference.width() + y, |
156 | RenderingRotation::Rotate180 => { |
157 | (reference.height() - y - 1) * reference.width() + reference.width() - x - 1 |
158 | } |
159 | RenderingRotation::Rotate270 => x * reference.width() + reference.width() - y - 1, |
160 | _ => y * reference.width() + x, |
161 | } |
162 | }; |
163 | |
164 | let fold_pixel = |(failure_count, max_color_difference): (usize, f32), |
165 | (reference_pixel, screenshot_pixel)| { |
166 | ( |
167 | failure_count + (reference_pixel != screenshot_pixel) as usize, |
168 | max_color_difference.max(color_difference(reference_pixel, screenshot_pixel)), |
169 | ) |
170 | }; |
171 | |
172 | let (failed_pixel_count, max_color_difference) = if rotated != RenderingRotation::NoRotation |
173 | { |
174 | let mut failure_count = 0usize; |
175 | let mut max_color_difference = 0.0f32; |
176 | for y in 0..screenshot.height() { |
177 | for x in 0..screenshot.width() { |
178 | let pa = &reference.as_slice()[idx(x, y) as usize]; |
179 | let pb = &screenshot.as_slice()[(y * screenshot.width() + x) as usize]; |
180 | (failure_count, max_color_difference) = |
181 | fold_pixel((failure_count, max_color_difference), (pa, pb)); |
182 | } |
183 | } |
184 | (failure_count, max_color_difference) |
185 | } else { |
186 | reference |
187 | .as_slice() |
188 | .iter() |
189 | .zip(screenshot.as_slice().iter()) |
190 | .fold((0usize, 0.0f32), fold_pixel) |
191 | }; |
192 | if max_color_difference < 0.1 { |
193 | return Ok(()); |
194 | } |
195 | let percentage_different = failed_pixel_count * 100 / reference.as_slice().len(); |
196 | if rotated != RenderingRotation::NoRotation |
197 | && (percentage_different < 1 || max_color_difference < options.rotation_threshold) |
198 | { |
199 | return Ok(()); |
200 | } |
201 | |
202 | for y in 0..screenshot.height() { |
203 | for x in 0..screenshot.width() { |
204 | let pa = reference.as_slice()[idx(x, y) as usize]; |
205 | let pb = screenshot.as_slice()[(y * screenshot.width() + x) as usize]; |
206 | let ca = crossterm::style::Color::Rgb { r: pa.r, g: pa.g, b: pa.b }; |
207 | let cb = crossterm::style::Color::Rgb { r: pb.r, g: pb.g, b: pb.b }; |
208 | if pa == pb { |
209 | eprint!(" {}" , crossterm::style::style("██" ).on(ca).with(cb)); |
210 | } else if color_difference(&pa, &pb) >= 1.75 { |
211 | eprint!( |
212 | " {}{}" , |
213 | crossterm::style::style("•" ).on(ca).slow_blink().red(), |
214 | crossterm::style::style("•" ).on(cb).slow_blink().green() |
215 | ); |
216 | } else { |
217 | eprint!( |
218 | " {}{}" , |
219 | crossterm::style::style("." ).on(ca).slow_blink().red(), |
220 | crossterm::style::style("." ).on(cb).slow_blink().green() |
221 | ); |
222 | } |
223 | } |
224 | eprintln!(); |
225 | } |
226 | |
227 | Err(format!("images are not equal. Percentage of pixels that are different: {}. Maximum color difference: {}" , failed_pixel_count * 100 / reference.as_slice().len(), max_color_difference)) |
228 | }; |
229 | |
230 | let result = compare(); |
231 | |
232 | if result.is_err() |
233 | && rotated == RenderingRotation::NoRotation |
234 | && std::env::var("SLINT_CREATE_SCREENSHOTS" ).is_ok_and(|var| var == "1" ) |
235 | { |
236 | eprintln!("saving rendered image as comparison to reference failed" ); |
237 | image::save_buffer( |
238 | reference_path, |
239 | screenshot.as_bytes(), |
240 | screenshot.width(), |
241 | screenshot.height(), |
242 | image::ColorType::Rgb8, |
243 | ) |
244 | .unwrap(); |
245 | } |
246 | |
247 | result |
248 | } |
249 | |
250 | pub fn assert_with_render( |
251 | path: &str, |
252 | window: Rc<MinimalSoftwareWindow>, |
253 | options: &TestCaseOptions, |
254 | ) { |
255 | for rotation: RenderingRotation in [ |
256 | RenderingRotation::NoRotation, |
257 | RenderingRotation::Rotate180, |
258 | RenderingRotation::Rotate90, |
259 | RenderingRotation::Rotate270, |
260 | ] { |
261 | let rendering: SharedPixelBuffer> = screenshot(window.clone(), rotated:rotation); |
262 | if let Err(reason: String) = compare_images(path, &rendering, rotated:rotation, options) { |
263 | panic!("Image comparison failure for {path} ( {rotation:?}): {reason}" ); |
264 | } |
265 | } |
266 | } |
267 | |
268 | pub fn assert_with_render_by_line( |
269 | path: &str, |
270 | window: Rc<MinimalSoftwareWindow>, |
271 | options: &TestCaseOptions, |
272 | ) { |
273 | let s = window.size(); |
274 | let mut rendering = SharedPixelBuffer::<Rgb8Pixel>::new(s.width, s.height); |
275 | |
276 | screenshot_render_by_line(window.clone(), None, &mut rendering); |
277 | if let Err(reason) = compare_images(path, &rendering, RenderingRotation::NoRotation, options) { |
278 | panic!("Image comparison failure for line-by-line rendering for {path}: {reason}" ); |
279 | } |
280 | |
281 | // Try to render a clipped version (to simulate partial rendering) and it should be exactly the same |
282 | let region = euclid::rect(s.width / 4, s.height / 4, s.width / 2, s.height / 2).cast::<usize>(); |
283 | for y in region.y_range() { |
284 | let stride = rendering.width() as usize; |
285 | // fill with garbage |
286 | rendering.make_mut_slice()[y * stride..][region.x_range()].fill(Rgb8Pixel::new( |
287 | ((y << 3) & 0xff) as u8, |
288 | 0, |
289 | 255, |
290 | )); |
291 | } |
292 | screenshot_render_by_line(window, Some(region.cast()), &mut rendering); |
293 | if !options.skip_clipping { |
294 | if let Err(reason) = |
295 | compare_images(path, &rendering, RenderingRotation::NoRotation, options) |
296 | { |
297 | panic!("Partial rendering image comparison failure for line-by-line rendering for {path}: {reason}" ); |
298 | } |
299 | } |
300 | } |
301 | |
302 | pub fn screenshot_render_by_line( |
303 | window: Rc<MinimalSoftwareWindow>, |
304 | region: Option<IntRect>, |
305 | buffer: &mut SharedPixelBuffer<Rgb8Pixel>, |
306 | ) { |
307 | // render to buffer |
308 | window.request_redraw(); |
309 | |
310 | window.draw_if_needed(|renderer: &SoftwareRenderer| { |
311 | match region { |
312 | None => renderer.mark_dirty_region( |
313 | LogicalRectRect::from_size(euclid::size2( |
314 | w:buffer.width() as f32, |
315 | h:buffer.height() as f32, |
316 | )) |
317 | .into(), |
318 | ), |
319 | Some(r: Rect) => renderer.mark_dirty_region( |
320 | (euclid::Rect::from_untyped(&r.cast()) / window.scale_factor()).into(), |
321 | ), |
322 | } |
323 | renderer.render_by_line(line_buffer:TestingLineBuffer { |
324 | stride: buffer.width() as usize, |
325 | buffer: buffer.make_mut_slice(), |
326 | region, |
327 | }); |
328 | }); |
329 | } |
330 | |
331 | pub fn save_screenshot(path: &str, window: Rc<MinimalSoftwareWindow>) { |
332 | let buffer: SharedPixelBuffer> = screenshot(window.clone(), rotated:RenderingRotation::NoRotation); |
333 | imageResult<(), ImageError>::save_buffer( |
334 | path, |
335 | buf:buffer.as_bytes(), |
336 | window.size().width, |
337 | window.size().height, |
338 | color:image::ColorType::Rgb8, |
339 | ) |
340 | .unwrap(); |
341 | } |
342 | |