1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 23:00:04 -04:00

Add egui testing library (#5166)

- closes #3491 
- closes #3926

This adds a testing library to egui based on
[kittest](https://github.com/rerun-io/kittest). Kittest is a new
[AccessKit](https://github.com/AccessKit/accesskit/)-based testing
library. The api is inspired by the js
[testing-library](https://testing-library.com/) where the idea is also
to query the dom based on accessibility attributes.
We made kittest with egui in mind but it should work with any rust gui
framework with AccessKit support.

It currently has support for:
- running the egui app, frame by frame
- building the AccessKit tree
- ergonomic queries via kittest
  - via e.g. get_by_name, get_by_role
- simulating events based on the accesskit node id
- creating arbitrary events based on Harness::input_mut
- rendering screenshots via wgpu
- snapshot tests with these screenshots

A simple test looks like this: 
```rust
fn main() {
    let mut checked = false;
    let app = |ctx: &Context| {
        CentralPanel::default().show(ctx, |ui| {
            ui.checkbox(&mut checked, "Check me!");
        });
    };

    let mut harness = Harness::builder().with_size(egui::Vec2::new(200.0, 100.0)).build(app);
    
    let checkbox = harness.get_by_name("Check me!");
    assert_eq!(checkbox.toggled(), Some(Toggled::False));
    checkbox.click();
    
    harness.run();

    let checkbox = harness.get_by_name("Check me!");
    assert_eq!(checkbox.toggled(), Some(Toggled::True));

    // You can even render the ui and do image snapshot tests
    #[cfg(all(feature = "wgpu", feature = "snapshot"))]
    egui_kittest::image_snapshot(&egui_kittest::wgpu::TestRenderer::new().render(&harness), "readme_example");
}
```

~Since getting wgpu to run in ci is a hassle, I'm taking another shot at
creating a software renderer for egui (ideally without a huge dependency
like skia)~ (this didn't work as well as I hoped and it turns out in CI
you can just run tests on a mac runner which comes with a real GPU)
 
Here is a example of a failed snapshot test in ci, it will say which
snapshot failed and upload an artifact with the before / after and diff
images:

https://github.com/emilk/egui/actions/runs/11183049487/job/31090724606?pr=5166
This commit is contained in:
lucasmerlin
2024-10-22 12:39:00 +02:00
committed by GitHub
parent 707cd03357
commit 70a01138b7
45 changed files with 1262 additions and 11 deletions

View File

@@ -0,0 +1,47 @@
[package]
name = "egui_kittest"
version.workspace = true
authors = ["Lucas Meurer <lucasmeurer96@gmail.com>", "Emil Ernerfeldt <emil.ernerfeldt@gmail.com>"]
description = "Testing library for egui based on kittest and AccessKit"
edition.workspace = true
rust-version.workspace = true
homepage = "https://github.com/emilk/egui"
license.workspace = true
readme = "./README.md"
repository = "https://github.com/emilk/egui"
categories = ["gui", "development-tools::testing", "accessibility"]
keywords = ["gui", "immediate", "egui", "testing", "accesskit"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
# Adds a wgpu-based test renderer.
wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image"]
# Adds a dify-based image snapshot utility.
snapshot = ["dep:dify", "dep:image", "image/png"]
[dependencies]
kittest.workspace = true
egui = { workspace = true, features = ["accesskit"] }
# wgpu dependencies
egui-wgpu = { workspace = true, optional = true }
pollster = { workspace = true, optional = true }
image = { workspace = true, optional = true }
# snapshot dependencies
dify = { workspace = true, optional = true }
## Enable this when generating docs.
document-features = { workspace = true, optional = true }
[dev-dependencies]
wgpu = { workspace = true, features = ["metal"] }
image = { workspace = true, features = ["png"] }
egui = { workspace = true, features = ["default_fonts"] }
[lints]
workspace = true

View File

@@ -0,0 +1,35 @@
# egui_kittest
Ui testing library for egui, based on [kittest](https://github.com/rerun-io/kittest) (an [AccessKit](https://github.com/AccessKit/accesskit) based testing library).
```rust
use egui::accesskit::{Role, Toggled};
use egui::{CentralPanel, Context, TextEdit, Vec2};
use egui_kittest::Harness;
use kittest::Queryable;
use std::cell::RefCell;
fn main() {
let mut checked = false;
let app = |ctx: &Context| {
CentralPanel::default().show(ctx, |ui| {
ui.checkbox(&mut checked, "Check me!");
});
};
let mut harness = Harness::builder().with_size(egui::Vec2::new(200.0, 100.0)).build(app);
let checkbox = harness.get_by_name("Check me!");
assert_eq!(checkbox.toggled(), Some(Toggled::False));
checkbox.click();
harness.run();
let checkbox = harness.get_by_name("Check me!");
assert_eq!(checkbox.toggled(), Some(Toggled::True));
// You can even render the ui and do image snapshot tests
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
harness.wgpu_snapshot("readme_example");
}
```

View File

@@ -0,0 +1,55 @@
use crate::Harness;
use egui::{Pos2, Rect, Vec2};
/// Builder for [`Harness`].
pub struct HarnessBuilder {
pub(crate) screen_rect: Rect,
pub(crate) dpi: f32,
}
impl Default for HarnessBuilder {
fn default() -> Self {
Self {
screen_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)),
dpi: 1.0,
}
}
}
impl HarnessBuilder {
/// Set the size of the window.
#[inline]
pub fn with_size(mut self, size: impl Into<Vec2>) -> Self {
let size = size.into();
self.screen_rect.set_width(size.x);
self.screen_rect.set_height(size.y);
self
}
/// Set the DPI of the window.
#[inline]
pub fn with_dpi(mut self, dpi: f32) -> Self {
self.dpi = dpi;
self
}
/// Create a new Harness with the given app closure.
///
/// The ui closure will immediately be called once to create the initial ui.
///
/// # Example
/// ```rust
/// # use egui::CentralPanel;
/// # use egui_kittest::Harness;
/// let mut harness = Harness::builder()
/// .with_size(egui::Vec2::new(300.0, 200.0))
/// .build(|ctx| {
/// CentralPanel::default().show(ctx, |ui| {
/// ui.label("Hello, world!");
/// });
/// });
/// ```
pub fn build<'a>(self, app: impl FnMut(&egui::Context) + 'a) -> Harness<'a> {
Harness::from_builder(&self, app)
}
}

View File

@@ -0,0 +1,182 @@
use egui::Event::PointerButton;
use egui::{Event, Modifiers, Pos2};
use kittest::{ElementState, MouseButton, SimulatedEvent};
#[derive(Default)]
pub(crate) struct EventState {
modifiers: Modifiers,
last_mouse_pos: Pos2,
}
impl EventState {
pub fn kittest_event_to_egui(&mut self, event: kittest::Event) -> Option<egui::Event> {
match event {
kittest::Event::ActionRequest(e) => Some(Event::AccessKitActionRequest(e)),
kittest::Event::Simulated(e) => match e {
SimulatedEvent::CursorMoved { position } => {
self.last_mouse_pos = Pos2::new(position.x as f32, position.y as f32);
Some(Event::PointerMoved(Pos2::new(
position.x as f32,
position.y as f32,
)))
}
SimulatedEvent::MouseInput { state, button } => {
pointer_button_to_egui(button).map(|button| PointerButton {
button,
modifiers: self.modifiers,
pos: self.last_mouse_pos,
pressed: matches!(state, ElementState::Pressed),
})
}
SimulatedEvent::Ime(text) => Some(Event::Text(text)),
SimulatedEvent::KeyInput { state, key } => {
match key {
kittest::Key::Alt => {
self.modifiers.alt = matches!(state, ElementState::Pressed);
}
kittest::Key::Command => {
self.modifiers.command = matches!(state, ElementState::Pressed);
}
kittest::Key::Control => {
self.modifiers.ctrl = matches!(state, ElementState::Pressed);
}
kittest::Key::Shift => {
self.modifiers.shift = matches!(state, ElementState::Pressed);
}
_ => {}
}
kittest_key_to_egui(key).map(|key| Event::Key {
key,
modifiers: self.modifiers,
pressed: matches!(state, ElementState::Pressed),
repeat: false,
physical_key: None,
})
}
},
}
}
}
pub fn kittest_key_to_egui(value: kittest::Key) -> Option<egui::Key> {
use egui::Key as EKey;
use kittest::Key;
match value {
Key::ArrowDown => Some(EKey::ArrowDown),
Key::ArrowLeft => Some(EKey::ArrowLeft),
Key::ArrowRight => Some(EKey::ArrowRight),
Key::ArrowUp => Some(EKey::ArrowUp),
Key::Escape => Some(EKey::Escape),
Key::Tab => Some(EKey::Tab),
Key::Backspace => Some(EKey::Backspace),
Key::Enter => Some(EKey::Enter),
Key::Space => Some(EKey::Space),
Key::Insert => Some(EKey::Insert),
Key::Delete => Some(EKey::Delete),
Key::Home => Some(EKey::Home),
Key::End => Some(EKey::End),
Key::PageUp => Some(EKey::PageUp),
Key::PageDown => Some(EKey::PageDown),
Key::Copy => Some(EKey::Copy),
Key::Cut => Some(EKey::Cut),
Key::Paste => Some(EKey::Paste),
Key::Colon => Some(EKey::Colon),
Key::Comma => Some(EKey::Comma),
Key::Backslash => Some(EKey::Backslash),
Key::Slash => Some(EKey::Slash),
Key::Pipe => Some(EKey::Pipe),
Key::Questionmark => Some(EKey::Questionmark),
Key::OpenBracket => Some(EKey::OpenBracket),
Key::CloseBracket => Some(EKey::CloseBracket),
Key::Backtick => Some(EKey::Backtick),
Key::Minus => Some(EKey::Minus),
Key::Period => Some(EKey::Period),
Key::Plus => Some(EKey::Plus),
Key::Equals => Some(EKey::Equals),
Key::Semicolon => Some(EKey::Semicolon),
Key::Quote => Some(EKey::Quote),
Key::Num0 => Some(EKey::Num0),
Key::Num1 => Some(EKey::Num1),
Key::Num2 => Some(EKey::Num2),
Key::Num3 => Some(EKey::Num3),
Key::Num4 => Some(EKey::Num4),
Key::Num5 => Some(EKey::Num5),
Key::Num6 => Some(EKey::Num6),
Key::Num7 => Some(EKey::Num7),
Key::Num8 => Some(EKey::Num8),
Key::Num9 => Some(EKey::Num9),
Key::A => Some(EKey::A),
Key::B => Some(EKey::B),
Key::C => Some(EKey::C),
Key::D => Some(EKey::D),
Key::E => Some(EKey::E),
Key::F => Some(EKey::F),
Key::G => Some(EKey::G),
Key::H => Some(EKey::H),
Key::I => Some(EKey::I),
Key::J => Some(EKey::J),
Key::K => Some(EKey::K),
Key::L => Some(EKey::L),
Key::M => Some(EKey::M),
Key::N => Some(EKey::N),
Key::O => Some(EKey::O),
Key::P => Some(EKey::P),
Key::Q => Some(EKey::Q),
Key::R => Some(EKey::R),
Key::S => Some(EKey::S),
Key::T => Some(EKey::T),
Key::U => Some(EKey::U),
Key::V => Some(EKey::V),
Key::W => Some(EKey::W),
Key::X => Some(EKey::X),
Key::Y => Some(EKey::Y),
Key::Z => Some(EKey::Z),
Key::F1 => Some(EKey::F1),
Key::F2 => Some(EKey::F2),
Key::F3 => Some(EKey::F3),
Key::F4 => Some(EKey::F4),
Key::F5 => Some(EKey::F5),
Key::F6 => Some(EKey::F6),
Key::F7 => Some(EKey::F7),
Key::F8 => Some(EKey::F8),
Key::F9 => Some(EKey::F9),
Key::F10 => Some(EKey::F10),
Key::F11 => Some(EKey::F11),
Key::F12 => Some(EKey::F12),
Key::F13 => Some(EKey::F13),
Key::F14 => Some(EKey::F14),
Key::F15 => Some(EKey::F15),
Key::F16 => Some(EKey::F16),
Key::F17 => Some(EKey::F17),
Key::F18 => Some(EKey::F18),
Key::F19 => Some(EKey::F19),
Key::F20 => Some(EKey::F20),
Key::F21 => Some(EKey::F21),
Key::F22 => Some(EKey::F22),
Key::F23 => Some(EKey::F23),
Key::F24 => Some(EKey::F24),
Key::F25 => Some(EKey::F25),
Key::F26 => Some(EKey::F26),
Key::F27 => Some(EKey::F27),
Key::F28 => Some(EKey::F28),
Key::F29 => Some(EKey::F29),
Key::F30 => Some(EKey::F30),
Key::F31 => Some(EKey::F31),
Key::F32 => Some(EKey::F32),
Key::F33 => Some(EKey::F33),
Key::F34 => Some(EKey::F34),
Key::F35 => Some(EKey::F35),
_ => None,
}
}
pub fn pointer_button_to_egui(value: MouseButton) -> Option<egui::PointerButton> {
match value {
MouseButton::Left => Some(egui::PointerButton::Primary),
MouseButton::Right => Some(egui::PointerButton::Secondary),
MouseButton::Middle => Some(egui::PointerButton::Middle),
MouseButton::Back => Some(egui::PointerButton::Extra1),
MouseButton::Forward => Some(egui::PointerButton::Extra2),
MouseButton::Other(_) => None,
}
}

View File

@@ -0,0 +1,186 @@
#![doc = include_str!("../README.md")]
//!
//! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
mod builder;
mod event;
#[cfg(feature = "snapshot")]
mod snapshot;
#[cfg(feature = "snapshot")]
pub use snapshot::*;
use std::fmt::{Debug, Formatter};
#[cfg(feature = "wgpu")]
mod texture_to_image;
#[cfg(feature = "wgpu")]
pub mod wgpu;
pub use kittest;
use std::mem;
use crate::event::EventState;
pub use builder::*;
use egui::{Pos2, Rect, TexturesDelta, Vec2, ViewportId};
use kittest::{Node, Queryable};
/// The test Harness. This contains everything needed to run the test.
/// Create a new Harness using [`Harness::new`] or [`Harness::builder`].
pub struct Harness<'a> {
pub ctx: egui::Context,
input: egui::RawInput,
kittest: kittest::State,
output: egui::FullOutput,
texture_deltas: Vec<TexturesDelta>,
update_fn: Box<dyn FnMut(&egui::Context) + 'a>,
event_state: EventState,
}
impl<'a> Debug for Harness<'a> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.kittest.fmt(f)
}
}
impl<'a> Harness<'a> {
pub(crate) fn from_builder(
builder: &HarnessBuilder,
mut app: impl FnMut(&egui::Context) + 'a,
) -> Self {
let ctx = egui::Context::default();
ctx.enable_accesskit();
let mut input = egui::RawInput {
screen_rect: Some(builder.screen_rect),
..Default::default()
};
let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap();
viewport.native_pixels_per_point = Some(builder.dpi);
// We need to run egui for a single frame so that the AccessKit state can be initialized
// and users can immediately start querying for widgets.
let mut output = ctx.run(input.clone(), &mut app);
let mut harness = Self {
update_fn: Box::new(app),
ctx,
input,
kittest: kittest::State::new(
output
.platform_output
.accesskit_update
.take()
.expect("AccessKit was disabled"),
),
texture_deltas: vec![mem::take(&mut output.textures_delta)],
output,
event_state: EventState::default(),
};
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
harness.run();
harness
}
pub fn builder() -> HarnessBuilder {
HarnessBuilder::default()
}
/// Create a new Harness with the given app closure.
///
/// The ui closure will immediately be called once to create the initial ui.
///
/// If you e.g. want to customize the size of the window, you can use [`Harness::builder`].
///
/// # Example
/// ```rust
/// # use egui::CentralPanel;
/// # use egui_kittest::Harness;
/// let mut harness = Harness::new(|ctx| {
/// CentralPanel::default().show(ctx, |ui| {
/// ui.label("Hello, world!");
/// });
/// });
/// ```
pub fn new(app: impl FnMut(&egui::Context) + 'a) -> Self {
Self::builder().build(app)
}
/// Set the size of the window.
/// Note: If you only want to set the size once at the beginning,
/// prefer using [`HarnessBuilder::with_size`].
#[inline]
pub fn set_size(&mut self, size: Vec2) -> &mut Self {
self.input.screen_rect = Some(Rect::from_min_size(Pos2::ZERO, size));
self
}
/// Set the DPI of the window.
/// Note: If you only want to set the DPI once at the beginning,
/// prefer using [`HarnessBuilder::with_dpi`].
#[inline]
pub fn set_dpi(&mut self, dpi: f32) -> &mut Self {
self.ctx.set_pixels_per_point(dpi);
self
}
/// Run a frame.
/// This will call the app closure with the current context and update the Harness.
pub fn step(&mut self) {
for event in self.kittest.take_events() {
if let Some(event) = self.event_state.kittest_event_to_egui(event) {
self.input.events.push(event);
}
}
let mut output = self.ctx.run(self.input.take(), self.update_fn.as_mut());
self.kittest.update(
output
.platform_output
.accesskit_update
.take()
.expect("AccessKit was disabled"),
);
self.texture_deltas
.push(mem::take(&mut output.textures_delta));
self.output = output;
}
/// Run a few frames.
/// This will soon be changed to run the app until it is "stable", meaning
/// - all animations are done
/// - no more repaints are requested
pub fn run(&mut self) {
const STEPS: usize = 2;
for _ in 0..STEPS {
self.step();
}
}
/// Access the [`egui::RawInput`] for the next frame.
pub fn input(&self) -> &egui::RawInput {
&self.input
}
/// Access the [`egui::RawInput`] for the next frame mutably.
pub fn input_mut(&mut self) -> &mut egui::RawInput {
&mut self.input
}
/// Access the [`egui::FullOutput`] for the last frame.
pub fn output(&self) -> &egui::FullOutput {
&self.output
}
/// Access the [`kittest::State`].
pub fn kittest_state(&self) -> &kittest::State {
&self.kittest
}
}
impl<'t, 'n, 'h> Queryable<'t, 'n> for Harness<'h>
where
'n: 't,
{
fn node(&'n self) -> Node<'t> {
self.kittest_state().node()
}
}

View File

@@ -0,0 +1,214 @@
use crate::Harness;
use image::ImageError;
use std::fmt::Display;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum SnapshotError {
/// Image did not match snapshot
Diff {
/// Count of pixels that were different
diff: i32,
/// Path where the diff image was saved
diff_path: PathBuf,
},
/// Error opening the existing snapshot (it probably doesn't exist, check the
/// [`ImageError`] for more information)
OpenSnapshot {
/// Path where the snapshot was expected to be
path: PathBuf,
/// The error that occurred
err: ImageError,
},
/// The size of the image did not match the snapshot
SizeMismatch {
/// Expected size
expected: (u32, u32),
/// Actual size
actual: (u32, u32),
},
/// Error writing the snapshot output
WriteSnapshot {
/// Path where a file was expected to be written
path: PathBuf,
/// The error that occurred
err: ImageError,
},
}
impl Display for SnapshotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Diff { diff, diff_path } => {
write!(
f,
"Image did not match snapshot. Diff: {diff}, {diff_path:?}"
)
}
Self::OpenSnapshot { path, err } => match err {
ImageError::IoError(io) => match io.kind() {
ErrorKind::NotFound => {
write!(f, "Missing snapshot: {path:?}")
}
err => {
write!(f, "Error reading snapshot: {err:?}\nAt: {path:?}")
}
},
err => {
write!(f, "Error decoding snapshot: {err:?}\nAt: {path:?}")
}
},
Self::SizeMismatch { expected, actual } => {
write!(
f,
"Image size did not match snapshot. Expected: {expected:?}, Actual: {actual:?}"
)
}
Self::WriteSnapshot { path, err } => {
write!(f, "Error writing snapshot: {err:?}\nAt: {path:?}")
}
}
}
}
/// Image snapshot test.
///
/// # Errors
/// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error
/// reading or writing the snapshot.
pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> Result<(), SnapshotError> {
let snapshots_path = Path::new("tests/snapshots");
let path = snapshots_path.join(format!("{name}.png"));
std::fs::create_dir_all(path.parent().expect("Could not get snapshot folder")).ok();
let diff_path = snapshots_path.join(format!("{name}.diff.png"));
let current_path = snapshots_path.join(format!("{name}.new.png"));
current
.save(&current_path)
.map_err(|err| SnapshotError::WriteSnapshot {
err,
path: current_path,
})?;
let previous = match image::open(&path) {
Ok(image) => image.to_rgba8(),
Err(err) => {
maybe_update_snapshot(&path, current)?;
return Err(SnapshotError::OpenSnapshot { path, err });
}
};
if previous.dimensions() != current.dimensions() {
maybe_update_snapshot(&path, current)?;
return Err(SnapshotError::SizeMismatch {
expected: previous.dimensions(),
actual: current.dimensions(),
});
}
// Looking at dify's source code, the threshold is based on the distance between two colors in
// YIQ color space.
// The default is 0.1, but we'll try 0.0 because ideally the output should not change at all.
// We might have to increase the threshold if there are minor differences when running tests
// on different gpus or different backends.
let threshold = 0.0;
let result = dify::diff::get_results(
previous,
current.clone(),
threshold,
true,
None,
&None,
&None,
);
if let Some((diff, result_image)) = result {
result_image
.save(diff_path.clone())
.map_err(|err| SnapshotError::WriteSnapshot {
path: diff_path.clone(),
err,
})?;
maybe_update_snapshot(&path, current)?;
return Err(SnapshotError::Diff { diff, diff_path });
} else {
// Delete old diff if it exists
std::fs::remove_file(diff_path).ok();
}
Ok(())
}
fn should_update_snapshots() -> bool {
std::env::var("UPDATE_SNAPSHOTS").is_ok()
}
fn maybe_update_snapshot(
snapshot_path: &Path,
current: &image::RgbaImage,
) -> Result<(), SnapshotError> {
if should_update_snapshots() {
current
.save(snapshot_path)
.map_err(|err| SnapshotError::WriteSnapshot {
err,
path: snapshot_path.into(),
})?;
println!("Updated snapshot: {snapshot_path:?}");
}
Ok(())
}
/// Image snapshot test.
///
/// # Panics
/// Panics if the image does not match the snapshot or if there was an error reading or writing the
/// snapshot.
#[track_caller]
pub fn image_snapshot(current: &image::RgbaImage, name: &str) {
match try_image_snapshot(current, name) {
Ok(_) => {}
Err(err) => {
panic!("{}", err);
}
}
}
#[cfg(feature = "wgpu")]
impl Harness<'_> {
/// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot.
///
/// # Errors
/// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error
/// reading or writing the snapshot.
#[track_caller]
pub fn try_wgpu_snapshot(&self, name: &str) -> Result<(), SnapshotError> {
let image = crate::wgpu::TestRenderer::new().render(self);
try_image_snapshot(&image, name)
}
/// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot.
///
/// # Panics
/// Panics if the image does not match the snapshot or if there was an error reading or writing the
/// snapshot.
#[track_caller]
pub fn wgpu_snapshot(&self, name: &str) {
match self.try_wgpu_snapshot(name) {
Ok(_) => {}
Err(err) => {
panic!("{}", err);
}
}
}
}

View File

@@ -0,0 +1,83 @@
use egui_wgpu::wgpu;
use egui_wgpu::wgpu::{Device, Extent3d, Queue, Texture};
use image::RgbaImage;
use std::iter;
use std::mem::size_of;
use std::sync::mpsc::channel;
pub(crate) fn texture_to_image(device: &Device, queue: &Queue, texture: &Texture) -> RgbaImage {
let buffer_dimensions =
BufferDimensions::new(texture.width() as usize, texture.height() as usize);
let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Texture to bytes output buffer"),
size: (buffer_dimensions.padded_bytes_per_row * buffer_dimensions.height) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Texture to bytes encoder"),
});
// Copy the data from the texture to the buffer
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::ImageCopyBuffer {
buffer: &output_buffer,
layout: wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(buffer_dimensions.padded_bytes_per_row as u32),
rows_per_image: None,
},
},
Extent3d {
width: texture.width(),
height: texture.height(),
depth_or_array_layers: 1,
},
);
let submission_index = queue.submit(iter::once(encoder.finish()));
// Note that we're not calling `.await` here.
let buffer_slice = output_buffer.slice(..);
// Sets the buffer up for mapping, sending over the result of the mapping back to us when it is finished.
let (sender, receiver) = channel();
buffer_slice.map_async(wgpu::MapMode::Read, move |v| drop(sender.send(v)));
// Poll the device in a blocking manner so that our future resolves.
device.poll(wgpu::Maintain::WaitForSubmissionIndex(submission_index));
receiver.recv().unwrap().unwrap();
let buffer_slice = output_buffer.slice(..);
let data = buffer_slice.get_mapped_range();
let data = data
.chunks_exact(buffer_dimensions.padded_bytes_per_row)
.flat_map(|row| row.iter().take(buffer_dimensions.unpadded_bytes_per_row))
.copied()
.collect::<Vec<_>>();
RgbaImage::from_raw(texture.width(), texture.height(), data).expect("Failed to create image")
}
struct BufferDimensions {
height: usize,
unpadded_bytes_per_row: usize,
padded_bytes_per_row: usize,
}
impl BufferDimensions {
fn new(width: usize, height: usize) -> Self {
let bytes_per_pixel = size_of::<u32>();
let unpadded_bytes_per_row = width * bytes_per_pixel;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded_bytes_per_row_padding = (align - unpadded_bytes_per_row % align) % align;
let padded_bytes_per_row = unpadded_bytes_per_row + padded_bytes_per_row_padding;
Self {
height,
unpadded_bytes_per_row,
padded_bytes_per_row,
}
}
}

View File

@@ -0,0 +1,141 @@
use crate::texture_to_image::texture_to_image;
use crate::Harness;
use egui_wgpu::wgpu::{Backends, InstanceDescriptor, StoreOp, TextureFormat};
use egui_wgpu::{wgpu, ScreenDescriptor};
use image::RgbaImage;
use std::iter::once;
use wgpu::Maintain;
pub struct TestRenderer {
device: wgpu::Device,
queue: wgpu::Queue,
dithering: bool,
}
impl Default for TestRenderer {
fn default() -> Self {
Self::new()
}
}
impl TestRenderer {
pub fn new() -> Self {
let instance = wgpu::Instance::new(InstanceDescriptor::default());
let adapters = instance.enumerate_adapters(Backends::all());
let adapter = adapters.first().expect("No adapter found");
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("Egui Device"),
memory_hints: Default::default(),
required_limits: Default::default(),
required_features: Default::default(),
},
None,
))
.expect("Failed to create device");
Self::create(device, queue)
}
pub fn create(device: wgpu::Device, queue: wgpu::Queue) -> Self {
Self {
device,
queue,
dithering: false,
}
}
#[inline]
pub fn with_dithering(mut self, dithering: bool) -> Self {
self.dithering = dithering;
self
}
pub fn render(&mut self, harness: &Harness<'_>) -> RgbaImage {
let mut renderer = egui_wgpu::Renderer::new(
&self.device,
TextureFormat::Rgba8Unorm,
None,
1,
self.dithering,
);
for delta in &harness.texture_deltas {
for (id, image_delta) in &delta.set {
renderer.update_texture(&self.device, &self.queue, *id, image_delta);
}
}
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Egui Command Encoder"),
});
let size = harness.ctx.screen_rect().size() * harness.ctx.pixels_per_point();
let screen = ScreenDescriptor {
pixels_per_point: harness.ctx.pixels_per_point(),
size_in_pixels: [size.x.round() as u32, size.y.round() as u32],
};
let tessellated = harness.ctx.tessellate(
harness.output().shapes.clone(),
harness.ctx.pixels_per_point(),
);
let user_buffers = renderer.update_buffers(
&self.device,
&self.queue,
&mut encoder,
&tessellated,
&screen,
);
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("Egui Texture"),
size: wgpu::Extent3d {
width: screen.size_in_pixels[0],
height: screen.size_in_pixels[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
{
let mut pass = encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Egui Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &texture_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
})
.forget_lifetime();
renderer.render(&mut pass, &tessellated, &screen);
}
self.queue
.submit(user_buffers.into_iter().chain(once(encoder.finish())));
self.device.poll(Maintain::Wait);
texture_to_image(&self.device, &self.queue, &texture)
}
}

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36c1b432140456ea5cbb687076b1c910aea8b31affd33a0ece22218f60af2d6e
size 2296