| 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 | |