Add `ImageLoader::has_pending` and `wait_for_pending_images` (#7030)

With kittest it was difficult to wait for images to be loaded before
taking a snapshot test.
This PR adds `Harness::with_wait_for_pending_images` (true by default)
which will cause `Harness::run` to sleep until all images are loaded (or
`HarnessBuilder::with_max_steps` is exceeded).

It also adds a new ImageLoader::has_pending and
BytesLoader::has_pending, which should be implemented if things are
loaded / decoded asynchronously.

It reverts https://github.com/emilk/egui/pull/6901 which was my previous
attempt to fix this (but this didn't work since only the tested crate is
compiled with cfg(test) and not it's dependencies)
This commit is contained in:
Lucas Meurer 2025-05-08 09:27:52 +02:00 committed by GitHub
parent 0fd6a805a4
commit 120d736cfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 93 additions and 8 deletions

View File

@ -3546,6 +3546,14 @@ impl Context {
pub fn loaders(&self) -> Arc<Loaders> {
self.read(|this| this.loaders.clone())
}
/// Returns `true` if any image is currently being loaded.
pub fn has_pending_images(&self) -> bool {
self.read(|this| {
this.loaders.image.lock().iter().any(|i| i.has_pending())
|| this.loaders.bytes.lock().iter().any(|i| i.has_pending())
})
}
}
/// ## Viewports

View File

@ -328,6 +328,11 @@ pub trait BytesLoader {
/// If the loader caches any data, this should return the size of that cache.
fn byte_size(&self) -> usize;
/// Returns `true` if some data is currently being loaded.
fn has_pending(&self) -> bool {
false
}
}
/// Represents an image which is currently being loaded.
@ -395,6 +400,13 @@ pub trait ImageLoader {
/// If the loader caches any data, this should return the size of that cache.
fn byte_size(&self) -> usize;
/// Returns `true` if some image is currently being loaded.
///
/// NOTE: You probably also want to check [`BytesLoader::has_pending`].
fn has_pending(&self) -> bool {
false
}
}
/// A texture with a known size.

View File

@ -125,4 +125,8 @@ impl BytesLoader for EhttpLoader {
})
.sum()
}
fn has_pending(&self) -> bool {
self.cache.lock().values().any(|entry| entry.is_pending())
}
}

View File

@ -128,4 +128,8 @@ impl BytesLoader for FileLoader {
})
.sum()
}
fn has_pending(&self) -> bool {
self.cache.lock().values().any(|entry| entry.is_pending())
}
}

View File

@ -8,6 +8,9 @@ use egui::{
use image::ImageFormat;
use std::{mem::size_of, path::Path, sync::Arc, task::Poll};
#[cfg(not(target_arch = "wasm32"))]
use std::thread;
type Entry = Poll<Result<Arc<ColorImage>, String>>;
#[derive(Default)]
@ -73,7 +76,7 @@ impl ImageLoader for ImageCrateLoader {
return Err(LoadError::NotSupported);
}
#[cfg(not(any(target_arch = "wasm32", test)))]
#[cfg(not(target_arch = "wasm32"))]
#[expect(clippy::unnecessary_wraps)] // needed here to match other return types
fn load_image(
ctx: &egui::Context,
@ -85,7 +88,7 @@ impl ImageLoader for ImageCrateLoader {
cache.lock().insert(uri.clone(), Poll::Pending);
// Do the image parsing on a bg thread
std::thread::Builder::new()
thread::Builder::new()
.name(format!("egui_extras::ImageLoader::load({uri:?})"))
.spawn({
let ctx = ctx.clone();
@ -113,8 +116,7 @@ impl ImageLoader for ImageCrateLoader {
Ok(ImagePoll::Pending { size: None })
}
// Load images on the current thread for tests, so they are less flaky
#[cfg(any(target_arch = "wasm32", test))]
#[cfg(target_arch = "wasm32")]
fn load_image(
_ctx: &egui::Context,
uri: &str,
@ -179,6 +181,10 @@ impl ImageLoader for ImageCrateLoader {
})
.sum()
}
fn has_pending(&self) -> bool {
self.cache.lock().values().any(|result| result.is_pending())
}
}
#[cfg(test)]

View File

@ -63,7 +63,7 @@ document-features = { workspace = true, optional = true }
[dev-dependencies]
egui = { workspace = true, features = ["default_fonts"] }
image = { workspace = true, features = ["png"] }
egui_extras = { workspace = true, features = ["image"] }
egui_extras = { workspace = true, features = ["image", "http"] }
[lints]
workspace = true

View File

@ -11,6 +11,7 @@ pub struct HarnessBuilder<State = ()> {
pub(crate) step_dt: f32,
pub(crate) state: PhantomData<State>,
pub(crate) renderer: Box<dyn TestRenderer>,
pub(crate) wait_for_pending_images: bool,
}
impl<State> Default for HarnessBuilder<State> {
@ -22,6 +23,7 @@ impl<State> Default for HarnessBuilder<State> {
renderer: Box::new(LazyRenderer::default()),
max_steps: 4,
step_dt: 1.0 / 4.0,
wait_for_pending_images: true,
}
}
}
@ -63,6 +65,19 @@ impl<State> HarnessBuilder<State> {
self
}
/// Should we wait for pending images?
///
/// If `true`, [`Harness::run`] and related methods will check if there are pending images
/// (via [`egui::Context::has_pending_images`]) and sleep for [`Self::with_step_dt`] up to
/// [`Self::with_max_steps`] times.
///
/// Default: `true`
#[inline]
pub fn with_wait_for_pending_images(mut self, wait_for_pending_images: bool) -> Self {
self.wait_for_pending_images = wait_for_pending_images;
self
}
/// Set the [`TestRenderer`] to use for rendering.
///
/// By default, a [`LazyRenderer`] is used.

View File

@ -31,6 +31,7 @@ pub use renderer::*;
use egui::{Modifiers, Pos2, Rect, RepaintCause, Vec2, ViewportId};
use kittest::{Node, Queryable};
#[derive(Debug, Clone)]
pub struct ExceededMaxStepsError {
pub max_steps: u64,
pub repaint_causes: Vec<RepaintCause>,
@ -66,6 +67,7 @@ pub struct Harness<'a, State = ()> {
renderer: Box<dyn TestRenderer>,
max_steps: u64,
step_dt: f32,
wait_for_pending_images: bool,
}
impl<State> Debug for Harness<'_, State> {
@ -88,6 +90,7 @@ impl<'a, State> Harness<'a, State> {
step_dt,
state: _,
mut renderer,
wait_for_pending_images,
} = builder;
let ctx = ctx.unwrap_or_default();
ctx.enable_accesskit();
@ -128,6 +131,7 @@ impl<'a, State> Harness<'a, State> {
renderer,
max_steps,
step_dt,
wait_for_pending_images,
};
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
harness.run_ok();
@ -293,10 +297,13 @@ impl<'a, State> Harness<'a, State> {
loop {
steps += 1;
self.step();
let wait_for_images = self.wait_for_pending_images && self.ctx.has_pending_images();
// We only care about immediate repaints
if self.root_viewport_output().repaint_delay != Duration::ZERO {
if self.root_viewport_output().repaint_delay != Duration::ZERO && !wait_for_images {
break;
} else if sleep {
} else if sleep || wait_for_images {
std::thread::sleep(Duration::from_secs_f32(self.step_dt));
}
if steps > self.max_steps {

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6298e67d099002808d51e7494e4174adee66d7ef2880728126c1d761d1432372
size 2145

View File

@ -1,4 +1,4 @@
use egui::Modifiers;
use egui::{include_image, Modifiers, Vec2};
use egui_kittest::Harness;
use kittest::{Key, Queryable as _};
@ -57,3 +57,29 @@ fn test_modifiers() {
assert!(state.cmd_z_pressed, "Cmd+Z wasn't pressed");
assert!(state.cmd_y_pressed, "Cmd+Y wasn't pressed");
}
#[test]
fn should_wait_for_images() {
let mut harness = Harness::builder()
.with_size(Vec2::new(60.0, 120.0))
.build_ui(|ui| {
egui_extras::install_image_loaders(ui.ctx());
let size = Vec2::splat(30.0);
ui.label("Url:");
ui.add_sized(
size,
egui::Image::new(
"https://raw.githubusercontent.com\
/emilk/egui/refs/heads/main/crates/eframe/data/icon.png",
),
);
ui.label("Include:");
ui.add_sized(
size,
egui::Image::new(include_image!("../../eframe/data/icon.png")),
);
});
harness.snapshot("should_wait_for_images");
}