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
6use std::rc::Rc;
7
8use crossterm::style::Stylize;
9
10use i_slint_core::graphics::{euclid, IntRect, Rgb8Pixel, SharedPixelBuffer};
11use i_slint_core::lengths::LogicalRect;
12use i_slint_core::platform::PlatformError;
13use i_slint_core::renderer::RendererSealed;
14use i_slint_core::software_renderer::{
15 LineBufferProvider, MinimalSoftwareWindow, RenderingRotation,
16};
17
18pub struct SwrTestingBackend {
19 window: Rc<MinimalSoftwareWindow>,
20}
21
22impl 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
34pub 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
45pub 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
56pub 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
86struct TestingLineBuffer<'a> {
87 buffer: &'a mut [Rgb8Pixel],
88 stride: usize,
89 region: Option<IntRect>,
90}
91
92impl 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
111fn 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)]
119pub 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
127fn 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
250pub 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
268pub 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
302pub 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
331pub 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