//! Exact offscreen rendering comparisons against independent reference painters. //! //! These are paired same-renderer comparisons, not portable GPU golden files. The reference //! paths deliberately do not call the shared components. Literal geometry and colors specify //! the baseline; deliberate mutations prove color, spacing and stroke regressions are caught. //! Set CRAFT_UI_VISUAL_DIR to retain bounded before/after/difference PNGs and comparison tables. #[path = "visual/fixtures.rs"] mod fixtures; use std::path::PathBuf; use egui::{Color32, Context, Event, FullOutput, Modifiers, PointerButton, Pos2, RawInput, Rect, Response, TextureId, Ui}; use egui_kittest::{TestRenderer, wgpu::WgpuTestRenderer}; use fixtures::{CANVAS, Case, Component, Mutation, State, Theme}; use image::{Rgba, RgbaImage}; #[derive(Debug, PartialEq)] struct Mesh { clip: Rect, texture: TextureId, indices: Vec, vertices: Vec<(Pos2, Pos2, Color32)>, } struct Capture { image: RgbaImage, meshes: Option>, } fn context(theme: Theme, scale: f32) -> Context { let ctx = Context::default(); ctx.set_theme(theme.egui()); ctx.set_pixels_per_point(scale); ctx.set_fonts(egui::FontDefinitions::empty()); ctx.all_styles_mut(|style| { style.animation_time = 0.0; style.visuals.text_cursor.blink = false; style.visuals.disabled_alpha = 0.4; }); ctx } fn raw_input(frame: usize, events: Vec) -> RawInput { RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, CANVAS)), time: Some(frame as f64 / 60.0), events, max_texture_side: Some(2048), ..Default::default() } } fn background(ui: &mut Ui, theme: Theme) { let screen = Rect::from_min_size(Pos2::ZERO, CANVAS); ui.set_clip_rect(screen); ui.painter().rect_filled(screen, 0.0, theme.surface()); } fn finish_capture(renderer: &mut WgpuTestRenderer, ctx: &Context, output: &FullOutput) -> Capture { let meshes = ctx .tessellate(output.shapes.clone(), output.pixels_per_point) .into_iter() .map(|clipped| { let egui::epaint::Primitive::Mesh(mesh) = clipped.primitive else { panic!("visual fixtures must not contain paint callbacks") }; Mesh { clip: clipped.clip_rect, texture: mesh.texture_id, indices: mesh.indices, vertices: mesh.vertices.into_iter().map(|vertex| (vertex.pos, vertex.uv, vertex.color)).collect(), } }) .collect(); let image = renderer.render(ctx, output).expect("offscreen renderer is required for the visual suite"); assert!(image.width() > 0 && image.height() > 0 && image.width() <= 256 && image.height() <= 144); Capture { image, meshes: Some(meshes) } } fn capture(renderer: &mut WgpuTestRenderer, case: Case, reference: bool, mutation: Mutation) -> Capture { let ctx = context(case.theme, case.scale); let mut output = FullOutput::default(); let mut responses: Vec = Vec::new(); for frame in 0..4 { let mut events = Vec::new(); if !reference && frame == 2 { if case.state == State::Focus { ctx.memory_mut(|memory| memory.request_focus(responses[0].id)); } if case.state.pointer() { let center = case.control_rect().center(); events.push(Event::PointerMoved(center)); if case.state.pressed() { events.push(Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); } } } output = ctx.run_ui(raw_input(frame, events), |ui| { background(ui, case.theme); if reference { fixtures::reference(ui, case); } else { responses = fixtures::actual(ui, case, mutation); } }); renderer.handle_delta(&mut output.textures_delta); } assert_eq!(output.pixels_per_point, case.scale); if !reference { let response = &responses[0]; assert_eq!(response.enabled(), case.state.enabled(), "{case:?}"); if case.state == State::Hover { assert!(response.hovered(), "hover was not exercised: {case:?}"); } if case.state.pressed() { assert!(response.is_pointer_button_down_on(), "press was not exercised: {case:?}"); if case.component.is_split() { assert!(response.dragged(), "divider drag was not exercised: {case:?}"); } } if case.state == State::Focus { assert!(response.has_focus(), "focus was not exercised: {case:?}"); } if case.state == State::Disabled { assert!(!response.hovered() && !response.has_focus(), "disabled pointer input must remain inert: {case:?}"); } } finish_capture(renderer, &ctx, &output) } fn static_capture(renderer: &mut WgpuTestRenderer, theme: Theme, scale: f32, mut paint: impl FnMut(&mut Ui)) -> Capture { let ctx = context(theme, scale); let mut output = FullOutput::default(); for frame in 0..2 { output = ctx.run_ui(raw_input(frame, vec![]), |ui| { background(ui, theme); paint(ui); }); renderer.handle_delta(&mut output.textures_delta); } assert_eq!(output.pixels_per_point, scale); finish_capture(renderer, &ctx, &output) } struct Artifacts { directory: Option, rows: String, } impl Artifacts { fn new(suite: &str) -> Self { let directory = std::env::var_os("CRAFT_UI_VISUAL_DIR").map(|root| PathBuf::from(root).join(suite)); if let Some(directory) = &directory { std::fs::create_dir_all(directory).unwrap(); } Self { directory, rows: "case\twidth\theight\tchanged_pixels\tmesh_equal\texpected_change\n".into() } } fn record(&mut self, name: &str, before: &Capture, after: &Capture, expected_change: bool) -> usize { assert_eq!(before.image.dimensions(), after.image.dimensions(), "{name}"); let mut changed = 0; let mut difference = RgbaImage::new(before.image.width(), before.image.height()); for ((before, after), pixel) in before.image.pixels().zip(after.image.pixels()).zip(difference.pixels_mut()) { if before != after { changed += 1; *pixel = Rgba([255, 0, 128, 255]); } } let mesh_equal = match (&before.meshes, &after.meshes) { (Some(before), Some(after)) => (before == after).to_string(), (None, None) => "not_applicable".into(), _ => panic!("both captures must have the same comparison scope"), }; self.rows.push_str(&format!("{name}\t{}\t{}\t{changed}\t{mesh_equal}\t{expected_change}\n", before.image.width(), before.image.height())); if let Some(directory) = &self.directory { before.image.save(directory.join(format!("{name}-before.png"))).unwrap(); after.image.save(directory.join(format!("{name}-after.png"))).unwrap(); difference.save(directory.join(format!("{name}-difference.png"))).unwrap(); // Preserve completed rows even if a later assertion fails. std::fs::write(directory.join("comparison.tsv"), &self.rows).unwrap(); } changed } } #[test] fn rendered_controls_match_independent_reference_pixels_and_meshes() { let mut renderer = WgpuTestRenderer::new(); let mut artifacts = Artifacts::new("controls"); let mut count = 0; for component in Component::ALL { for state in State::ALL { if component.is_split() && state.selected() { continue; // A resize handle has no selected state in its public contract. } for theme in [Theme::Light, Theme::Dark] { for scale in [1.0, 1.5, 2.0] { for compact in [false, true] { let case = Case { component, state, theme, scale, compact }; let before = capture(&mut renderer, case, true, Mutation::None); let after = capture(&mut renderer, case, false, Mutation::None); let changed = artifacts.record(&case.name(), &before, &after, false); assert_eq!(before.meshes, after.meshes, "reference mesh mismatch: {case:?}"); assert_eq!(changed, 0, "reference pixels changed: {case:?}"); count += 1; } } } } } assert_eq!(count, 372); eprintln!("{count} control before/after pairs matched exact RGBA pixels and tessellated meshes"); } #[test] fn visual_comparisons_detect_color_spacing_and_stroke_mutations() { let mut renderer = WgpuTestRenderer::new(); let mut artifacts = Artifacts::new("negative-controls"); let mut count = 0; for theme in [Theme::Light, Theme::Dark] { for scale in [1.0, 1.5, 2.0] { for compact in [false, true] { let case = Case { component: Component::Button, state: State::Normal, theme, scale, compact }; let before = capture(&mut renderer, case, true, Mutation::None); for mutation in [Mutation::Color, Mutation::Spacing, Mutation::Stroke] { let after = capture(&mut renderer, case, false, mutation); let changed = artifacts.record(&format!("{}-{mutation:?}", case.name()), &before, &after, true); assert_ne!(before.meshes, after.meshes, "mesh comparison missed {mutation:?}: {case:?}"); assert!(changed > 0, "pixel comparison missed {mutation:?}: {case:?}"); count += 1; } } } } assert_eq!(count, 36); eprintln!("{count} deliberate color/spacing/stroke changes were detected in both pixels and meshes"); } #[test] fn toolbar_fitting_matches_literal_group_and_overflow_positions() { use craft_ui::buttons::IconButton; use craft_ui::layout::fit_toolbar; use egui::{Align, Layout, Stroke, StrokeKind, UiBuilder, pos2, vec2}; let mut renderer = WgpuTestRenderer::new(); let mut artifacts = Artifacts::new("toolbar"); let mut count = 0; // Independently specified rectangles: three natural groups, one group + More, More only. for (available, expected) in [(112.0, vec![(12.0, 24.0), (40.0, 28.0), (72.0, 36.0)]), (64.0, vec![(12.0, 24.0), (40.0, 20.0)]), (20.0, vec![(12.0, 20.0)])] { for theme in [Theme::Light, Theme::Dark] { for scale in [1.0, 1.5, 2.0] { let p = theme.palette(); let before = static_capture(&mut renderer, theme, scale, |ui| { for &(x, width) in &expected { let rect = Rect::from_min_size(pos2(x, 12.0), vec2(width, 24.0)); ui.painter().rect_filled(rect, 3.0, p.normal); ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, p.border), StrokeKind::Inside); fixtures::icon(ui.painter(), rect, p.icon, true); } }); let after = static_capture(&mut renderer, theme, scale, |ui| { let fit = fit_toolbar(&[24.0, 28.0, 36.0], available, 4.0, 20.0).unwrap(); let style = fixtures::button_style(theme); ui.scope_builder( UiBuilder::new() .max_rect(Rect::from_min_size(pos2(12.0, 12.0), vec2(available, 24.0))) .layout(Layout::left_to_right(Align::Min)), |ui| { ui.spacing_mut().item_spacing.x = 4.0; for &(_, width) in &fit.shown { IconButton::new("Group", vec2(width, 24.0), &style) .show(ui, |ui, rect, tint| fixtures::icon(ui.painter(), rect, tint, true)); } if !fit.overflow.is_empty() { IconButton::new("More", vec2(fit.overflow_width, 24.0), &style) .show(ui, |ui, rect, tint| fixtures::icon(ui.painter(), rect, tint, true)); } }, ); }); let name = format!("toolbar-{available}-{}-{scale}x", theme.name()); let changed = artifacts.record(&name, &before, &after, false); assert_eq!(before.meshes, after.meshes, "{name}"); assert_eq!(changed, 0, "{name}"); count += 1; } } } assert_eq!(count, 18); } #[test] fn widget_palette_renders_all_five_states_with_existing_borders_and_expansion() { use craft_ui::theme::WidgetPalette; use egui::{CornerRadius, Stroke, StrokeKind, pos2, vec2}; let mut renderer = WgpuTestRenderer::new(); let mut artifacts = Artifacts::new("widget-palette"); for theme in [Theme::Light, Theme::Dark] { for scale in [1.0, 1.5, 2.0] { for compact in [false, true] { let p = theme.palette(); let fills = [p.normal, p.hovered, p.pressed, p.selected, theme.surface()]; let rect = |index| { Rect::from_min_size( pos2(12.0 + (index % 3) as f32 * 34.0, 12.0 + (index / 3) as f32 * 26.0), if compact { vec2(20.0, 12.0) } else { vec2(26.0, 18.0) }, ) }; let borders = [Color32::RED, Color32::GREEN, Color32::BLUE, p.border, p.focus]; let before = static_capture(&mut renderer, theme, scale, |ui| { for index in 0..5 { let rect = rect(index).expand(if index % 2 == 0 { 0.0 } else { 0.5 }); ui.painter().rect_filled(rect, 2.0, fills[index]); // A separate inset uses the weak fill, so omission of either fill is visible. ui.painter().rect_filled(rect.shrink(3.0), 0.0, fills[index]); ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, borders[index]), StrokeKind::Inside); ui.painter() .line_segment([rect.left_center() + vec2(4.0, 0.0), rect.right_center() - vec2(4.0, 0.0)], Stroke::new(1.5, p.icon)); } }); let after = static_capture(&mut renderer, theme, scale, |ui| { let mut widgets = ui.visuals().widgets.clone(); for (index, widget) in [&mut widgets.noninteractive, &mut widgets.inactive, &mut widgets.hovered, &mut widgets.active, &mut widgets.open] .into_iter() .enumerate() { widget.bg_stroke = Stroke::new(1.0, borders[index]); widget.expansion = if index % 2 == 0 { 0.0 } else { 0.5 }; } WidgetPalette { noninteractive: p.normal, inactive: p.hovered, hovered: p.pressed, active: p.selected, open: theme.surface() } .apply_to(&mut widgets, CornerRadius::same(2), Stroke::new(1.5, p.icon)); for (index, widget) in [widgets.noninteractive, widgets.inactive, widgets.hovered, widgets.active, widgets.open].iter().enumerate() { let rect = rect(index).expand(widget.expansion); ui.painter().rect_filled(rect, widget.corner_radius, widget.bg_fill); ui.painter().rect_filled(rect.shrink(3.0), 0.0, widget.weak_bg_fill); ui.painter().rect_stroke(rect, widget.corner_radius, widget.bg_stroke, StrokeKind::Inside); ui.painter().line_segment([rect.left_center() + vec2(4.0, 0.0), rect.right_center() - vec2(4.0, 0.0)], widget.fg_stroke); } }); let name = format!("palette-{}-{scale}x-{}", theme.name(), if compact { "compact" } else { "normal" }); let changed = artifacts.record(&name, &before, &after, false); assert_eq!(before.meshes, after.meshes, "{name}"); assert_eq!(changed, 0, "{name}"); } } } } #[test] fn svg_preparation_matches_literal_raster_references_at_all_scales() { use craft_ui::icons::{SvgIconSet, SvgStyle}; use egui::{ ImageSource, load::{Bytes, ImagePoll, SizeHint}, }; const SOURCE: &[u8] = br#""#; const LIGHT: &[u8] = br##""##; const DARK: &[u8] = br##""##; fn raster(ctx: &Context, uri: &str, size: u32) -> Capture { let ImagePoll::Ready { image } = ctx.try_load_image(uri, SizeHint::Size { width: size, height: size, maintain_aspect_ratio: true }).unwrap() else { panic!("compiled-in SVG must rasterize synchronously") }; assert_eq!(image.size, [size as usize; 2]); let image = RgbaImage::from_raw(size, size, image.pixels.iter().flat_map(|pixel| pixel.to_srgba_unmultiplied()).collect()).unwrap(); assert!(image.pixels().any(|pixel| pixel[3] != 0), "blank SVG is not a valid matching baseline"); Capture { image, meshes: None } } let mut artifacts = Artifacts::new("svg"); for (theme, color, literal) in [(Theme::Light, "#0f172a", LIGHT), (Theme::Dark, "#e2e8f0", DARK)] { for (scale, numerator) in [(1.0, 2), (1.5, 3), (2.0, 4)] { for logical_size in [12, 24] { let ctx = Context::default(); egui_extras::install_image_loaders(&ctx); ctx.include_bytes("bytes://literal-reference.svg", Bytes::Static(literal)); let icons = SvgIconSet::new(&[("box", SOURCE)], SvgStyle { current_color: color, stroke_width: Some("1.75") }, "bytes://actual/", "box"); let ImageSource::Bytes { uri, bytes } = icons.source("box") else { panic!("catalog must return embedded bytes") }; ctx.include_bytes(uri.clone(), bytes); let size = logical_size * numerator / 2; let before = raster(&ctx, "bytes://literal-reference.svg", size); let after = raster(&ctx, &uri, size); let name = format!("svg-{}-{scale}x-{logical_size}pt", theme.name()); assert_eq!(artifacts.record(&name, &before, &after, false), 0, "{name}"); } } } }