mirror of
https://github.com/emilk/egui.git
synced 2026-09-02 06:40:06 -04:00
This allows you to see how many more steps it would have taken for the harness to settle. Useful to see if you're in a infinite repaint loop or just need a `harness.run_steps(3)` to give it some more frames to settle. Should make it easier to make the default step count tighter, which should make tests faster.
966 lines
31 KiB
Rust
966 lines
31 KiB
Rust
#![cfg_attr(doc, doc = include_str!("../README.md"))]
|
|
//!
|
|
//! ## Feature flags
|
|
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
|
|
#![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps
|
|
|
|
mod builder;
|
|
#[cfg(feature = "snapshot")]
|
|
mod snapshot;
|
|
|
|
#[cfg(feature = "snapshot")]
|
|
pub use crate::snapshot::*;
|
|
|
|
mod app_kind;
|
|
mod config;
|
|
mod node;
|
|
mod renderer;
|
|
#[cfg(feature = "wgpu")]
|
|
mod texture_to_image;
|
|
#[cfg(feature = "wgpu")]
|
|
pub mod wgpu;
|
|
|
|
// re-exports:
|
|
pub use {
|
|
self::{builder::*, node::*, renderer::*},
|
|
kittest,
|
|
};
|
|
|
|
use core::{
|
|
fmt::{Debug, Display, Formatter},
|
|
time::Duration,
|
|
};
|
|
|
|
use egui::{
|
|
Color32, Key, Modifiers, PointerButton, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId,
|
|
epaint::{ClippedShape, RectShape},
|
|
style::ScrollAnimation,
|
|
};
|
|
use kittest::Queryable;
|
|
|
|
use crate::{app_kind::AppKind, config::config};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExceededMaxStepsError {
|
|
pub max_steps: u64,
|
|
|
|
/// How many steps the ui would have needed to settle.
|
|
///
|
|
/// `None` if it did not settle within `diagnostic_max_steps` (see `kittest.toml`) further
|
|
/// steps either, i.e. it just keeps repainting.
|
|
pub steps_to_settle: Option<u64>,
|
|
|
|
/// How far past [`Self::max_steps`] we kept stepping to find [`Self::steps_to_settle`].
|
|
pub diagnostic_max_steps: u64,
|
|
|
|
pub repaint_causes: Vec<RepaintCause>,
|
|
}
|
|
|
|
impl Display for ExceededMaxStepsError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
|
|
write!(f, "Harness::run exceeded max_steps ({}). ", self.max_steps)?;
|
|
|
|
match self.steps_to_settle {
|
|
Some(steps) => write!(f, "It would have settled after {steps} steps. ")?,
|
|
None => write!(
|
|
f,
|
|
"It did not settle within {} further steps either. ",
|
|
self.diagnostic_max_steps
|
|
)?,
|
|
}
|
|
|
|
write!(
|
|
f,
|
|
"If you expect your ui to keep repainting \
|
|
(e.g. when showing a spinner) call Harness::step or Harness::run_steps instead.\
|
|
\nRepaint causes: {:#?}",
|
|
self.repaint_causes,
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The test Harness. This contains everything needed to run the test.
|
|
///
|
|
/// Create a new Harness using [`Harness::new_ui`] or [`Harness::builder`].
|
|
///
|
|
/// The [Harness] has a optional generic state that can be used to pass data to the app / ui closure.
|
|
/// In _most cases_ it should be fine to just store the state in the closure itself.
|
|
/// The state functions are useful if you need to access the state after the harness has been created.
|
|
///
|
|
/// Some egui style options are changed from the defaults:
|
|
/// - The cursor blinking is disabled
|
|
/// - The scroll animation is disabled
|
|
pub struct Harness<'a, State = ()> {
|
|
pub ctx: egui::Context,
|
|
input: egui::RawInput,
|
|
kittest: kittest::State,
|
|
output: egui::FullOutput,
|
|
app: AppKind<'a, State>,
|
|
response: Option<egui::Response>,
|
|
state: State,
|
|
renderer: Box<dyn TestRenderer>,
|
|
max_steps: u64,
|
|
step_dt: f32,
|
|
wait_for_pending_images: bool,
|
|
queued_events: EventQueue,
|
|
|
|
#[cfg(feature = "snapshot")]
|
|
default_snapshot_options: SnapshotOptions,
|
|
#[cfg(feature = "snapshot")]
|
|
snapshot_results: SnapshotResults,
|
|
}
|
|
|
|
impl<State> Debug for Harness<'_, State> {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
|
|
self.kittest.fmt(f)
|
|
}
|
|
}
|
|
|
|
impl<'a, State> Harness<'a, State> {
|
|
#[track_caller]
|
|
pub(crate) fn from_builder(
|
|
builder: HarnessBuilder<State>,
|
|
mut app: AppKind<'a, State>,
|
|
mut state: State,
|
|
ctx: Option<egui::Context>,
|
|
) -> Self {
|
|
let HarnessBuilder {
|
|
screen_rect,
|
|
pixels_per_point,
|
|
theme,
|
|
os,
|
|
max_steps,
|
|
step_dt,
|
|
state: _,
|
|
mut renderer,
|
|
wait_for_pending_images,
|
|
|
|
#[cfg(feature = "snapshot")]
|
|
default_snapshot_options,
|
|
|
|
// rustfmt adds this weird indentation below.
|
|
// See: https://github.com/rust-lang/rustfmt/issues/5920
|
|
#[cfg(feature = "wgpu")]
|
|
render_options: _,
|
|
} = builder;
|
|
let ctx = ctx.unwrap_or_default();
|
|
ctx.set_theme(theme);
|
|
ctx.set_os(os);
|
|
ctx.enable_accesskit();
|
|
ctx.all_styles_mut(|style| {
|
|
// Disable cursor blinking so it doesn't interfere with snapshots
|
|
style.visuals.text_cursor.blink = false;
|
|
style.scroll_animation = ScrollAnimation::none();
|
|
style.animation_time = 0.0;
|
|
});
|
|
let mut input = egui::RawInput {
|
|
screen_rect: Some(screen_rect),
|
|
..Default::default()
|
|
};
|
|
let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap();
|
|
viewport.native_pixels_per_point = Some(pixels_per_point);
|
|
|
|
let mut response = None;
|
|
|
|
// 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_ui(input.clone(), |ui| {
|
|
response = app.run(ui, &mut state, false);
|
|
});
|
|
|
|
renderer.handle_delta(&mut output.textures_delta);
|
|
|
|
let mut harness = Self {
|
|
app,
|
|
ctx,
|
|
input,
|
|
kittest: kittest::State::new(
|
|
output
|
|
.platform_output
|
|
.accesskit_update
|
|
.take()
|
|
.expect("AccessKit was disabled"),
|
|
),
|
|
output,
|
|
response,
|
|
state,
|
|
renderer,
|
|
max_steps,
|
|
step_dt,
|
|
wait_for_pending_images,
|
|
queued_events: Default::default(),
|
|
|
|
#[cfg(feature = "snapshot")]
|
|
default_snapshot_options,
|
|
|
|
#[cfg(feature = "snapshot")]
|
|
snapshot_results: SnapshotResults::default(),
|
|
};
|
|
// Handle any viewport commands (e.g. a screenshot or resize) requested during the initial
|
|
// frame above (which didn't go through `_step`).
|
|
harness.handle_viewport_commands();
|
|
|
|
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
|
|
harness.run_ok();
|
|
harness
|
|
}
|
|
|
|
/// Create a [`Harness`] via a [`HarnessBuilder`].
|
|
pub fn builder() -> HarnessBuilder<State> {
|
|
HarnessBuilder::default()
|
|
}
|
|
|
|
/// Create a new Harness with the given ui closure and a state.
|
|
///
|
|
/// The ui closure will immediately be called once to create the initial ui.
|
|
///
|
|
/// If you e.g. want to customize the size of the ui, you can use [`Harness::builder`].
|
|
///
|
|
/// # Example
|
|
/// ```rust
|
|
/// # use egui_kittest::{Harness, kittest::Queryable};
|
|
/// let mut checked = false;
|
|
/// let mut harness = Harness::new_ui_state(|ui, checked| {
|
|
/// ui.checkbox(checked, "Check me!");
|
|
/// }, checked);
|
|
///
|
|
/// harness.get_by_label("Check me!").click();
|
|
/// harness.run();
|
|
///
|
|
/// assert_eq!(*harness.state(), true);
|
|
/// ```
|
|
#[track_caller]
|
|
pub fn new_ui_state(app: impl FnMut(&mut egui::Ui, &mut State) + 'a, state: State) -> Self {
|
|
Self::builder().build_ui_state(app, state)
|
|
}
|
|
|
|
/// Create a new [Harness] from the given eframe creation closure.
|
|
#[cfg(feature = "eframe")]
|
|
#[track_caller]
|
|
pub fn new_eframe(builder: impl FnOnce(&mut eframe::CreationContext<'a>) -> State) -> Self
|
|
where
|
|
State: eframe::App + 'static,
|
|
{
|
|
Self::builder().build_eframe(builder)
|
|
}
|
|
|
|
/// 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 `pixels_per_point` of the window.
|
|
/// Note: If you only want to set the `pixels_per_point` once at the beginning,
|
|
/// prefer using [`HarnessBuilder::with_pixels_per_point`].
|
|
#[inline]
|
|
pub fn set_pixels_per_point(&mut self, pixels_per_point: f32) -> &mut Self {
|
|
self.ctx.set_pixels_per_point(pixels_per_point);
|
|
self
|
|
}
|
|
|
|
/// Run a frame for each queued event (or a single frame if there are no events).
|
|
/// This will call the app closure with each queued event and
|
|
/// update the Harness.
|
|
pub fn step(&mut self) {
|
|
let events = core::mem::take(&mut *self.queued_events.lock());
|
|
if events.is_empty() {
|
|
self.step_impl(false);
|
|
}
|
|
for event in events {
|
|
self.input.events.push(event);
|
|
self.step_impl(false);
|
|
}
|
|
}
|
|
|
|
/// Run a single step. This will not process any events.
|
|
fn step_impl(&mut self, sizing_pass: bool) {
|
|
self.input.predicted_dt = self.step_dt;
|
|
|
|
let mut output = self.ctx.run_ui(self.input.take(), |ui| {
|
|
self.response = self.app.run(ui, &mut self.state, sizing_pass);
|
|
});
|
|
self.kittest.update(
|
|
output
|
|
.platform_output
|
|
.accesskit_update
|
|
.take()
|
|
.expect("AccessKit was disabled"),
|
|
);
|
|
self.renderer.handle_delta(&mut output.textures_delta);
|
|
self.output = output;
|
|
|
|
self.handle_viewport_commands();
|
|
}
|
|
|
|
/// Calculate the rect that includes all popups and tooltips.
|
|
fn compute_total_rect_with_popups(&self) -> Option<Rect> {
|
|
// Start with the standard response rect
|
|
let mut used = self.response.as_ref()?.rect;
|
|
|
|
// Add all visible areas from other orders (popups, tooltips, etc.)
|
|
self.ctx.memory(|mem| {
|
|
mem.areas()
|
|
.visible_layer_ids()
|
|
.into_iter()
|
|
.filter(|layer_id| layer_id.order != egui::Order::Background)
|
|
.filter_map(|layer_id| mem.area_rect(layer_id.id))
|
|
.for_each(|area_rect| used |= area_rect);
|
|
});
|
|
|
|
Some(used)
|
|
}
|
|
|
|
/// Resize the test harness to fit the contents. This only works when creating the Harness via
|
|
/// [`Harness::new_ui`] / [`Harness::new_ui_state`] or
|
|
/// [`HarnessBuilder::build_ui`] / [`HarnessBuilder::build_ui_state`].
|
|
pub fn fit_contents(&mut self) {
|
|
self.step_impl(true);
|
|
|
|
// Calculate size including all content (main UI + popups + tooltips)
|
|
if let Some(rect) = self.compute_total_rect_with_popups() {
|
|
self.set_size(rect.size());
|
|
}
|
|
|
|
self.run_ok();
|
|
}
|
|
|
|
/// Run until
|
|
/// - all animations are done
|
|
/// - no more repaints are requested
|
|
///
|
|
/// Returns the number of frames that were run.
|
|
///
|
|
/// # Panics
|
|
/// Panics if the number of steps exceeds the maximum number of steps set
|
|
/// in [`HarnessBuilder::with_max_steps`].
|
|
///
|
|
/// See also:
|
|
/// - [`Harness::try_run`].
|
|
/// - [`Harness::try_run_realtime`].
|
|
/// - [`Harness::run_ok`].
|
|
/// - [`Harness::step`].
|
|
/// - [`Harness::run_steps`].
|
|
#[track_caller]
|
|
pub fn run(&mut self) -> u64 {
|
|
match self.try_run() {
|
|
Ok(steps) => steps,
|
|
Err(err) => {
|
|
panic!("{err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// When `sleep` is true, each step sleeps for `self.step_dt`.
|
|
/// When `diagnostic` is true, we run extra steps to find [`ExceededMaxStepsError::steps_to_settle`].
|
|
fn try_run_impl(
|
|
&mut self,
|
|
sleep: bool,
|
|
diagnostic: bool,
|
|
) -> Result<u64, ExceededMaxStepsError> {
|
|
// Once the budget is blown we keep going for a while, purely to find out how many steps
|
|
// would have been needed. The repaint causes are the ones from the moment we blew it.
|
|
let diagnostic_max_steps = if diagnostic {
|
|
config().diagnostic_max_steps()
|
|
} else {
|
|
0
|
|
};
|
|
let last_diagnostic_step = self.max_steps.saturating_add(diagnostic_max_steps);
|
|
let mut repaint_causes_at_max_steps = None;
|
|
|
|
let mut steps = 0;
|
|
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 && !wait_for_images {
|
|
if let Some(repaint_causes) = repaint_causes_at_max_steps {
|
|
return Err(ExceededMaxStepsError {
|
|
max_steps: self.max_steps,
|
|
steps_to_settle: Some(steps),
|
|
diagnostic_max_steps,
|
|
repaint_causes,
|
|
});
|
|
}
|
|
break;
|
|
} else if sleep || wait_for_images {
|
|
std::thread::sleep(Duration::from_secs_f32(self.step_dt));
|
|
}
|
|
if steps > self.max_steps && repaint_causes_at_max_steps.is_none() {
|
|
repaint_causes_at_max_steps = Some(self.ctx.repaint_causes());
|
|
}
|
|
if steps > last_diagnostic_step {
|
|
return Err(ExceededMaxStepsError {
|
|
max_steps: self.max_steps,
|
|
steps_to_settle: None,
|
|
diagnostic_max_steps,
|
|
repaint_causes: repaint_causes_at_max_steps
|
|
.unwrap_or_else(|| self.ctx.repaint_causes()),
|
|
});
|
|
}
|
|
}
|
|
Ok(steps)
|
|
}
|
|
|
|
/// Run until
|
|
/// - all animations are done
|
|
/// - no more repaints are requested
|
|
/// - the maximum number of steps is reached (See [`HarnessBuilder::with_max_steps`])
|
|
///
|
|
/// Returns the number of steps that were run.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if the maximum number of steps is exceeded.
|
|
///
|
|
/// See also:
|
|
/// - [`Harness::run`].
|
|
/// - [`Harness::run_ok`].
|
|
/// - [`Harness::step`].
|
|
/// - [`Harness::run_steps`].
|
|
/// - [`Harness::try_run_realtime`].
|
|
pub fn try_run(&mut self) -> Result<u64, ExceededMaxStepsError> {
|
|
self.try_run_impl(false, true)
|
|
}
|
|
|
|
/// Run until
|
|
/// - all animations are done
|
|
/// - no more repaints are requested
|
|
/// - the maximum number of steps is reached (See [`HarnessBuilder::with_max_steps`])
|
|
///
|
|
/// Returns the number of steps that were run, or None if the maximum number of steps was exceeded.
|
|
///
|
|
/// Unlike [`Harness::run`], this never steps past `max_steps`.
|
|
///
|
|
/// See also:
|
|
/// - [`Harness::run`].
|
|
/// - [`Harness::try_run`].
|
|
/// - [`Harness::step`].
|
|
/// - [`Harness::run_steps`].
|
|
/// - [`Harness::try_run_realtime`].
|
|
pub fn run_ok(&mut self) -> Option<u64> {
|
|
self.try_run_impl(false, false).ok()
|
|
}
|
|
|
|
/// Run multiple frames, sleeping for [`HarnessBuilder::with_step_dt`] between frames.
|
|
///
|
|
/// This is useful to e.g. wait for an async operation to complete (e.g. loading of images).
|
|
/// Runs until
|
|
/// - all animations are done
|
|
/// - no more repaints are requested
|
|
/// - the maximum number of steps is reached (See [`HarnessBuilder::with_max_steps`])
|
|
///
|
|
/// Returns the number of steps that were run.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if the maximum number of steps is exceeded.
|
|
///
|
|
/// See also:
|
|
/// - [`Harness::run`].
|
|
/// - [`Harness::run_ok`].
|
|
/// - [`Harness::step`].
|
|
/// - [`Harness::run_steps`].
|
|
/// - [`Harness::try_run`].
|
|
pub fn try_run_realtime(&mut self) -> Result<u64, ExceededMaxStepsError> {
|
|
self.try_run_impl(true, true)
|
|
}
|
|
|
|
/// Run a number of steps.
|
|
/// Equivalent to calling [`Harness::step`] x times.
|
|
pub fn run_steps(&mut self, steps: usize) {
|
|
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
|
|
}
|
|
|
|
/// Access the state.
|
|
pub fn state(&self) -> &State {
|
|
&self.state
|
|
}
|
|
|
|
/// Access the state mutably.
|
|
pub fn state_mut(&mut self) -> &mut State {
|
|
&mut self.state
|
|
}
|
|
|
|
/// Consume the harness and return the state.
|
|
pub fn into_state(self) -> State {
|
|
self.state
|
|
}
|
|
|
|
/// Queue an event to be processed in the next frame.
|
|
pub fn event(&self, event: egui::Event) {
|
|
self.queued_events.lock().push(event);
|
|
}
|
|
|
|
/// Queue an event with modifiers.
|
|
///
|
|
/// Queues the modifiers to be pressed, then the event, then the modifiers to be released.
|
|
pub fn event_modifiers(&self, event: egui::Event, modifiers: Modifiers) {
|
|
let mut queue = self.queued_events.lock();
|
|
queue.push(egui::Event::ModifiersChanged(modifiers));
|
|
queue.push(event);
|
|
queue.push(egui::Event::ModifiersChanged(Modifiers::default()));
|
|
}
|
|
|
|
fn modifiers(&self, modifiers: Modifiers) {
|
|
self.queued_events
|
|
.lock()
|
|
.push(egui::Event::ModifiersChanged(modifiers));
|
|
}
|
|
|
|
pub fn key_down(&self, key: egui::Key) {
|
|
self.event(egui::Event::Key {
|
|
key,
|
|
pressed: true,
|
|
modifiers: Modifiers::default(),
|
|
repeat: false,
|
|
physical_key: None,
|
|
});
|
|
}
|
|
|
|
pub fn key_down_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
|
|
self.event_modifiers(
|
|
egui::Event::Key {
|
|
key,
|
|
pressed: true,
|
|
modifiers,
|
|
repeat: false,
|
|
physical_key: None,
|
|
},
|
|
modifiers,
|
|
);
|
|
}
|
|
|
|
pub fn key_up(&self, key: egui::Key) {
|
|
self.event(egui::Event::Key {
|
|
key,
|
|
pressed: false,
|
|
modifiers: Modifiers::default(),
|
|
repeat: false,
|
|
physical_key: None,
|
|
});
|
|
}
|
|
|
|
pub fn key_up_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
|
|
self.event_modifiers(
|
|
egui::Event::Key {
|
|
key,
|
|
pressed: false,
|
|
modifiers,
|
|
repeat: false,
|
|
physical_key: None,
|
|
},
|
|
modifiers,
|
|
);
|
|
}
|
|
|
|
/// Press the given keys in combination.
|
|
///
|
|
/// For e.g. [`Key::A`] + [`Key::B`] this would generate:
|
|
/// - Press [`Key::A`]
|
|
/// - Press [`Key::B`]
|
|
/// - Release [`Key::B`]
|
|
/// - Release [`Key::A`]
|
|
pub fn key_combination(&self, keys: &[Key]) {
|
|
for key in keys {
|
|
self.key_down(*key);
|
|
}
|
|
for key in keys.iter().rev() {
|
|
self.key_up(*key);
|
|
}
|
|
}
|
|
|
|
/// Press the given keys in combination, with modifiers.
|
|
///
|
|
/// For e.g. [`Modifiers::COMMAND`] + [`Key::A`] + [`Key::B`] this would generate:
|
|
/// - Press [`Modifiers::COMMAND`]
|
|
/// - Press [`Key::A`]
|
|
/// - Press [`Key::B`]
|
|
/// - Release [`Key::B`]
|
|
/// - Release [`Key::A`]
|
|
/// - Release [`Modifiers::COMMAND`]
|
|
pub fn key_combination_modifiers(&self, modifiers: Modifiers, keys: &[Key]) {
|
|
self.modifiers(modifiers);
|
|
|
|
for pressed in [true, false] {
|
|
for key in keys {
|
|
self.event(egui::Event::Key {
|
|
key: *key,
|
|
pressed,
|
|
modifiers,
|
|
repeat: false,
|
|
physical_key: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
self.modifiers(Modifiers::default());
|
|
}
|
|
|
|
/// Press a key.
|
|
///
|
|
/// This will create a key down event and a key up event.
|
|
pub fn key_press(&self, key: egui::Key) {
|
|
self.key_combination(&[key]);
|
|
}
|
|
|
|
/// Press a key with modifiers.
|
|
///
|
|
/// This will
|
|
/// - set the modifiers
|
|
/// - create a key down event
|
|
/// - create a key up event
|
|
/// - reset the modifiers
|
|
pub fn key_press_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
|
|
self.key_combination_modifiers(modifiers, &[key]);
|
|
}
|
|
|
|
/// Move mouse cursor to this position.
|
|
pub fn hover_at(&self, pos: egui::Pos2) {
|
|
self.event(egui::Event::PointerMoved(pos));
|
|
}
|
|
|
|
/// Start dragging from a position.
|
|
pub fn drag_at(&self, pos: egui::Pos2) {
|
|
self.event(egui::Event::PointerButton {
|
|
pos,
|
|
button: PointerButton::Primary,
|
|
pressed: true,
|
|
modifiers: Modifiers::NONE,
|
|
});
|
|
}
|
|
|
|
/// Stop dragging and remove cursor.
|
|
pub fn drop_at(&self, pos: egui::Pos2) {
|
|
self.event(egui::Event::PointerButton {
|
|
pos,
|
|
button: PointerButton::Primary,
|
|
pressed: false,
|
|
modifiers: Modifiers::NONE,
|
|
});
|
|
self.remove_cursor();
|
|
}
|
|
|
|
/// Remove the cursor from the screen.
|
|
///
|
|
/// Will fire a [`egui::Event::PointerGone`] event.
|
|
///
|
|
/// If you click a button and then take a snapshot, the button will be shown as hovered.
|
|
/// If you don't want that, you can call this method after clicking.
|
|
pub fn remove_cursor(&self) {
|
|
self.event(egui::Event::PointerGone);
|
|
}
|
|
|
|
/// Mask something. Useful for snapshot tests.
|
|
///
|
|
/// Call this _after_ [`Self::run`] and before [`Self::snapshot`].
|
|
/// This will add a [`RectShape`] to the output shapes, for the current frame.
|
|
/// Will be overwritten on the next call to [`Self::run`].
|
|
pub fn mask(&mut self, rect: Rect) {
|
|
self.output.shapes.push(ClippedShape {
|
|
clip_rect: Rect::EVERYTHING,
|
|
shape: Shape::Rect(RectShape::filled(rect, 0.0, Color32::MAGENTA)),
|
|
});
|
|
}
|
|
|
|
/// Render the last output to an image.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if the rendering fails.
|
|
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
|
pub fn render(&mut self) -> Result<image::RgbaImage, String> {
|
|
let mut output = self.output.clone();
|
|
|
|
if let Some(mouse_pos) = self.ctx.input(|i| i.pointer.hover_pos()) {
|
|
// Paint a mouse cursor:
|
|
let triangle = vec![
|
|
mouse_pos,
|
|
mouse_pos + egui::vec2(16.0, 8.0),
|
|
mouse_pos + egui::vec2(8.0, 16.0),
|
|
];
|
|
|
|
output.shapes.push(ClippedShape {
|
|
clip_rect: self.ctx.content_rect(),
|
|
shape: egui::epaint::PathShape::convex_polygon(
|
|
triangle,
|
|
Color32::WHITE,
|
|
egui::Stroke::new(1.0, Color32::BLACK),
|
|
)
|
|
.into(),
|
|
});
|
|
}
|
|
|
|
self.renderer.render(&self.ctx, &output)
|
|
}
|
|
|
|
/// Apply the [`egui::ViewportCommand`]s the app emitted during the last frame.
|
|
fn handle_viewport_commands(&mut self) {
|
|
self.handle_inner_size();
|
|
|
|
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
|
self.handle_screenshots();
|
|
}
|
|
|
|
/// Resize the harness to the last [`egui::ViewportCommand::InnerSize`] requested by the app
|
|
/// during the last frame, if any.
|
|
fn handle_inner_size(&mut self) {
|
|
let new_inner_size =
|
|
self.root_viewport_output()
|
|
.commands
|
|
.iter()
|
|
.rev()
|
|
.find_map(|command| {
|
|
if let egui::ViewportCommand::InnerSize(size) = command {
|
|
Some(*size)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
if let Some(size) = new_inner_size {
|
|
self.set_size(size);
|
|
self.ctx.request_repaint();
|
|
}
|
|
}
|
|
|
|
/// Fulfill any [`egui::ViewportCommand::Screenshot`] requests made by the app during the
|
|
/// last frame.
|
|
///
|
|
/// If a screenshot was requested and no renderer is available, an error will be logged.
|
|
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
|
fn handle_screenshots(&mut self) {
|
|
// Collect all screenshot requests from this frame's viewport output.
|
|
let requests: Vec<(ViewportId, egui::UserData)> = self
|
|
.output
|
|
.viewport_output
|
|
.iter()
|
|
.flat_map(|(id, viewport)| {
|
|
viewport.commands.iter().filter_map(move |command| {
|
|
if let egui::ViewportCommand::Screenshot(user_data) = command {
|
|
Some((*id, user_data.clone()))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
if requests.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Render the frame once and reuse it for every request. We render without the synthetic
|
|
// mouse cursor since a real screenshot wouldn't include the OS cursor either.
|
|
let image = match self.renderer.render(&self.ctx, &self.output) {
|
|
Ok(image) => image,
|
|
Err(err) => {
|
|
log::error!("Failed to render screenshot requested via ViewportCommand: {err}");
|
|
return;
|
|
}
|
|
};
|
|
let image = std::sync::Arc::new(rgba_image_to_color_image(&image));
|
|
|
|
for (viewport_id, user_data) in requests {
|
|
self.input.events.push(egui::Event::Screenshot {
|
|
viewport_id,
|
|
user_data,
|
|
image: std::sync::Arc::clone(&image),
|
|
});
|
|
}
|
|
|
|
// Make sure the run loop runs at least one more frame so the app actually receives the
|
|
// queued screenshot event.
|
|
self.ctx.request_repaint();
|
|
}
|
|
|
|
/// Get the root viewport output
|
|
fn root_viewport_output(&self) -> &egui::ViewportOutput {
|
|
self.output
|
|
.viewport_output
|
|
.get(&ViewportId::ROOT)
|
|
.expect("Missing root viewport")
|
|
}
|
|
|
|
/// The root node of the test harness.
|
|
pub fn root(&self) -> Node<'_> {
|
|
Node::new(
|
|
self.kittest.root(),
|
|
&self.queued_events,
|
|
self.ctx.pixels_per_point(),
|
|
)
|
|
}
|
|
|
|
/// Spawn a real native eframe window running this harness's app, reusing its [`egui::Context`].
|
|
///
|
|
/// Blocks until the window is closed.
|
|
///
|
|
/// Useful for interactively debugging a failing test: add a call to this before the failing
|
|
/// assertion to poke at the UI yourself.
|
|
///
|
|
/// # macOS: must be called on the main thread
|
|
/// `AppKit` requires UI work to happen on the main thread, but by default cargo's test harness
|
|
/// runs each test on a spawned worker thread, so this function will panic on macOS unless
|
|
/// you opt out of the default harness.
|
|
///
|
|
/// To fix this, disable the default libtest harness for your test target and run tests on
|
|
/// the main thread yourself. In `Cargo.toml`:
|
|
///
|
|
/// ```toml
|
|
/// [[test]]
|
|
/// name = "your_test"
|
|
/// harness = false
|
|
/// ```
|
|
///
|
|
/// Then write a `fn main()` in the test file that invokes your test directly.
|
|
///
|
|
/// See also: <https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-harness-field>
|
|
#[cfg(all(feature = "eframe", not(target_arch = "wasm32")))]
|
|
#[deprecated = "Only for debugging, don't commit this."]
|
|
pub fn spawn_eframe_app(self)
|
|
where
|
|
'a: 'static,
|
|
State: 'static,
|
|
{
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
// AppKit requires UI work to happen on the main thread, but by default cargo's
|
|
// test harness runs each test on a spawned worker thread.
|
|
#[expect(unsafe_code)]
|
|
// SAFETY: `pthread_main_np` is a thread-safe libc query with no arguments.
|
|
let is_main_thread = unsafe {
|
|
unsafe extern "C" {
|
|
fn pthread_main_np() -> core::ffi::c_int;
|
|
}
|
|
pthread_main_np() != 0
|
|
};
|
|
assert!(
|
|
is_main_thread,
|
|
"spawn_eframe_app must be called on the main thread on macOS, \
|
|
but the default `cargo test` harness runs each test on a worker thread.\n\
|
|
\n\
|
|
To fix this, disable the default libtest harness for your test target and run \
|
|
tests on the main thread yourself. In Cargo.toml:\n\
|
|
\n\
|
|
[[test]]\n\
|
|
name = \"your_test\"\n\
|
|
harness = false\n\
|
|
\n\
|
|
Then write a `fn main()` in the test file that invokes your test directly.\n\
|
|
\n\
|
|
See: https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-harness-field"
|
|
);
|
|
}
|
|
|
|
struct UiApp {
|
|
f: Box<dyn FnMut(&mut egui::Ui)>,
|
|
}
|
|
|
|
impl eframe::App for UiApp {
|
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
(self.f)(ui);
|
|
}
|
|
}
|
|
|
|
struct UiStateApp<State> {
|
|
f: Box<dyn FnMut(&mut egui::Ui, &mut State)>,
|
|
state: State,
|
|
}
|
|
|
|
impl<State: 'static> eframe::App for UiStateApp<State> {
|
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
let Self { f, state } = self;
|
|
f(ui, state);
|
|
}
|
|
}
|
|
|
|
use crate::app_kind::AppKindEframe;
|
|
|
|
let Self {
|
|
ctx, state, app, ..
|
|
} = self;
|
|
|
|
let eframe_app: Box<dyn eframe::App> = match app {
|
|
AppKind::Ui(f) => Box::new(UiApp { f }),
|
|
AppKind::UiState(f) => Box::new(UiStateApp { f, state }),
|
|
AppKind::Eframe(AppKindEframe { take_app, .. }) => take_app(state),
|
|
};
|
|
|
|
eframe::run_native_ext(
|
|
"egui_kittest",
|
|
eframe::NativeOptions::default(),
|
|
Some(ctx),
|
|
Box::new(|_cc| Ok(eframe_app)),
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
/// Utilities for stateless harnesses.
|
|
impl<'a> Harness<'a> {
|
|
/// Create a new Harness with the given ui closure.
|
|
/// Use the [`Harness::run`], [`Harness::step`], etc... methods to run the app.
|
|
///
|
|
/// The ui closure will immediately be called once to create the initial ui.
|
|
///
|
|
/// If you e.g. want to customize the size of the ui, you can use [`Harness::builder`].
|
|
///
|
|
/// # Example
|
|
/// ```rust
|
|
/// # use egui_kittest::Harness;
|
|
/// let mut harness = Harness::new_ui(|ui| {
|
|
/// ui.label("Hello, world!");
|
|
/// });
|
|
/// ```
|
|
#[track_caller]
|
|
pub fn new_ui(app: impl FnMut(&mut egui::Ui) + 'a) -> Self {
|
|
Self::builder().build_ui(app)
|
|
}
|
|
}
|
|
|
|
/// Convert a rendered [`image::RgbaImage`] (premultiplied alpha, as produced by the renderer)
|
|
/// into an [`egui::ColorImage`] suitable for [`egui::Event::Screenshot`].
|
|
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
|
fn rgba_image_to_color_image(image: &image::RgbaImage) -> egui::ColorImage {
|
|
let size = [image.width() as usize, image.height() as usize];
|
|
let pixels = image
|
|
.pixels()
|
|
.map(|p| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]))
|
|
.collect();
|
|
egui::ColorImage::new(size, pixels)
|
|
}
|
|
|
|
impl<'tree, 'node, State> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State>
|
|
where
|
|
'node: 'tree,
|
|
{
|
|
fn queryable_node(&'node self) -> Node<'tree> {
|
|
self.root()
|
|
}
|
|
}
|