mirror of
https://github.com/emilk/egui.git
synced 2026-09-02 23:00:04 -04:00
Add basic inspector
This commit is contained in:
@@ -29,6 +29,9 @@ snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"]
|
||||
## Record a test session as an animated GIF or PNG sequence.
|
||||
recording = ["dep:image", "image/gif", "image/png"]
|
||||
|
||||
## Stream frames + accesskit tree to a `kittest_inspector` window for live debugging.
|
||||
inspector = ["dep:image", "dep:kittest_inspector"]
|
||||
|
||||
## Allows testing eframe::App
|
||||
eframe = ["dep:eframe", "eframe/accesskit"]
|
||||
|
||||
@@ -53,6 +56,9 @@ wgpu = { workspace = true, features = ["metal", "dx12", "vulkan", "gles"], optio
|
||||
# snapshot dependencies
|
||||
dify = { workspace = true, optional = true }
|
||||
|
||||
# inspector dependencies
|
||||
kittest_inspector = { workspace = true, default-features = false, optional = true }
|
||||
|
||||
# Enable this when generating docs.
|
||||
document-features = { workspace = true, optional = true }
|
||||
|
||||
|
||||
160
crates/egui_kittest/src/inspector.rs
Normal file
160
crates/egui_kittest/src/inspector.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! Connect a [`crate::Harness`] to a `kittest_inspector` process for live debugging.
|
||||
//!
|
||||
//! The harness spawns the inspector as a child process with piped stdin/stdout. After every
|
||||
//! step the harness writes a frame + accesskit tree update to the child's stdin and reads a
|
||||
//! reply from its stdout, blocking until the user resumes (when paused).
|
||||
|
||||
use std::io::{BufReader, BufWriter, Write as _};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
use egui::accesskit;
|
||||
use kittest_inspector::{
|
||||
Frame, HarnessMessage, InspectorReply, read_message, write_message,
|
||||
};
|
||||
|
||||
/// Environment variable: when set to a truthy value, every harness auto-launches an inspector.
|
||||
pub const INSPECTOR_ENV_VAR: &str = "KITTEST_INSPECTOR";
|
||||
|
||||
/// Environment variable: explicit path to the `kittest_inspector` binary.
|
||||
pub const INSPECTOR_PATH_ENV_VAR: &str = "KITTEST_INSPECTOR_PATH";
|
||||
|
||||
/// Errors that can occur attaching or talking to the inspector.
|
||||
#[derive(Debug)]
|
||||
pub enum InspectorError {
|
||||
/// Failed to launch the `kittest_inspector` binary.
|
||||
Launch(std::io::Error),
|
||||
/// Failed to set up the child's stdio pipes.
|
||||
Pipe(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InspectorError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Launch(err) => write!(
|
||||
f,
|
||||
"failed to launch kittest_inspector (set {INSPECTOR_PATH_ENV_VAR} or put it on PATH): {err}"
|
||||
),
|
||||
Self::Pipe(msg) => write!(f, "inspector pipe setup failed: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InspectorError {}
|
||||
|
||||
/// An attached inspector. Owned by the [`crate::Harness`].
|
||||
pub(crate) struct Inspector {
|
||||
writer: BufWriter<ChildStdin>,
|
||||
reader: BufReader<ChildStdout>,
|
||||
/// Keep the child alive until the harness drops.
|
||||
_child: Child,
|
||||
step: u64,
|
||||
label: Option<String>,
|
||||
/// True once the connection has failed; we stop trying to send.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Inspector {
|
||||
/// Launch a new `kittest_inspector` child process.
|
||||
///
|
||||
/// Search order for the binary:
|
||||
/// 1. The path in `KITTEST_INSPECTOR_PATH` if set.
|
||||
/// 2. `kittest_inspector` from `PATH`.
|
||||
pub fn launch(label: Option<String>) -> Result<Self, InspectorError> {
|
||||
let bin = std::env::var(INSPECTOR_PATH_ENV_VAR)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("kittest_inspector"));
|
||||
|
||||
let mut child = Command::new(&bin)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(InspectorError::Launch)?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| InspectorError::Pipe("missing child stdin".into()))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| InspectorError::Pipe("missing child stdout".into()))?;
|
||||
|
||||
Ok(Self {
|
||||
writer: BufWriter::new(stdin),
|
||||
reader: BufReader::new(stdout),
|
||||
_child: child,
|
||||
step: 0,
|
||||
label,
|
||||
broken: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send the current frame + accesskit tree and block until the inspector replies.
|
||||
/// Returns silently on send/receive failure (e.g. the inspector window was closed).
|
||||
pub fn send_step(
|
||||
&mut self,
|
||||
image: &image::RgbaImage,
|
||||
pixels_per_point: f32,
|
||||
accesskit: Option<accesskit::TreeUpdate>,
|
||||
) {
|
||||
if self.broken {
|
||||
return;
|
||||
}
|
||||
self.step = self.step.saturating_add(1);
|
||||
let frame = Frame {
|
||||
step: self.step,
|
||||
width: image.width(),
|
||||
height: image.height(),
|
||||
pixels_per_point,
|
||||
rgba: image.as_raw().clone(),
|
||||
accesskit,
|
||||
label: self.label.clone(),
|
||||
};
|
||||
if let Err(err) = write_message(&mut self.writer, &HarnessMessage::Frame(frame)) {
|
||||
#[expect(clippy::print_stderr)]
|
||||
{
|
||||
eprintln!("egui_kittest inspector: send failed: {err}");
|
||||
}
|
||||
self.broken = true;
|
||||
return;
|
||||
}
|
||||
match read_message::<_, InspectorReply>(&mut self.reader) {
|
||||
Ok(InspectorReply::Continue) => {}
|
||||
Err(err) => {
|
||||
#[expect(clippy::print_stderr)]
|
||||
{
|
||||
eprintln!("egui_kittest inspector: read failed: {err}");
|
||||
}
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn say_goodbye(&mut self) {
|
||||
if self.broken {
|
||||
return;
|
||||
}
|
||||
let _ = write_message(&mut self.writer, &HarnessMessage::Goodbye);
|
||||
let _ = self.writer.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Inspector {
|
||||
fn drop(&mut self) {
|
||||
self.say_goodbye();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read [`INSPECTOR_ENV_VAR`] once and cache.
|
||||
pub(crate) fn env_enabled() -> bool {
|
||||
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ENABLED.get_or_init(|| match std::env::var(INSPECTOR_ENV_VAR) {
|
||||
Ok(value) => matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
),
|
||||
Err(_) => false,
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub use crate::snapshot::*;
|
||||
|
||||
mod app_kind;
|
||||
mod config;
|
||||
#[cfg(feature = "inspector")]
|
||||
mod inspector;
|
||||
mod node;
|
||||
#[cfg(feature = "recording")]
|
||||
mod recording;
|
||||
@@ -25,6 +27,9 @@ pub mod wgpu;
|
||||
#[cfg(feature = "recording")]
|
||||
pub use crate::recording::{RecordKind, RecordingError, RecordingOptions, RecordingTrigger};
|
||||
|
||||
#[cfg(feature = "inspector")]
|
||||
pub use crate::inspector::{INSPECTOR_ENV_VAR, INSPECTOR_PATH_ENV_VAR, InspectorError};
|
||||
|
||||
// re-exports:
|
||||
pub use {
|
||||
self::{builder::*, node::*, renderer::*},
|
||||
@@ -95,6 +100,11 @@ pub struct Harness<'a, State = ()> {
|
||||
|
||||
#[cfg(feature = "recording")]
|
||||
recording: Option<recording::RecordingState>,
|
||||
|
||||
#[cfg(feature = "inspector")]
|
||||
inspector: Option<inspector::Inspector>,
|
||||
#[cfg(feature = "inspector")]
|
||||
last_accesskit_update: Option<egui::accesskit::TreeUpdate>,
|
||||
}
|
||||
|
||||
impl<State> Debug for Harness<'_, State> {
|
||||
@@ -185,10 +195,28 @@ impl<'a, State> Harness<'a, State> {
|
||||
|
||||
#[cfg(feature = "recording")]
|
||||
recording: None,
|
||||
|
||||
#[cfg(feature = "inspector")]
|
||||
inspector: None,
|
||||
#[cfg(feature = "inspector")]
|
||||
last_accesskit_update: None,
|
||||
};
|
||||
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
|
||||
harness.run_ok();
|
||||
|
||||
#[cfg(feature = "inspector")]
|
||||
if inspector::env_enabled() {
|
||||
match inspector::Inspector::launch(std::thread::current().name().map(String::from)) {
|
||||
Ok(insp) => harness.inspector = Some(insp),
|
||||
Err(err) => {
|
||||
#[expect(clippy::print_stderr)]
|
||||
{
|
||||
eprintln!("egui_kittest: failed to launch inspector: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "recording", feature = "snapshot"))]
|
||||
{
|
||||
// Env var takes precedence (always saves), then config (only saves on failure).
|
||||
@@ -293,18 +321,24 @@ impl<'a, State> Harness<'a, State> {
|
||||
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"),
|
||||
);
|
||||
let accesskit_update = output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.take()
|
||||
.expect("AccessKit was disabled");
|
||||
#[cfg(feature = "inspector")]
|
||||
{
|
||||
self.last_accesskit_update = Some(accesskit_update.clone());
|
||||
}
|
||||
self.kittest.update(accesskit_update);
|
||||
self.renderer.handle_delta(&output.textures_delta);
|
||||
self.output = output;
|
||||
|
||||
#[cfg(feature = "recording")]
|
||||
self.capture_frame_if_recording(false);
|
||||
|
||||
#[cfg(feature = "inspector")]
|
||||
self.send_to_inspector_if_attached();
|
||||
}
|
||||
|
||||
/// Calculate the rect that includes all popups and tooltips.
|
||||
@@ -675,7 +709,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the rendering fails.
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))]
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording", feature = "inspector"))]
|
||||
pub fn render(&mut self) -> Result<image::RgbaImage, String> {
|
||||
let mut output = self.output.clone();
|
||||
|
||||
@@ -757,6 +791,49 @@ impl<'a, State> Harness<'a, State> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch a `kittest_inspector` process and attach this harness to it.
|
||||
///
|
||||
/// After this call, every [`Self::step`] sends the rendered frame + accesskit tree to the
|
||||
/// inspector and blocks until the inspector replies. When paused, the harness blocks until
|
||||
/// the user clicks Play or Next in the inspector.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the inspector binary cannot be launched or the connection fails.
|
||||
#[cfg(feature = "inspector")]
|
||||
pub fn launch_inspector(&mut self) -> Result<(), InspectorError> {
|
||||
let label = std::thread::current().name().map(String::from);
|
||||
self.inspector = Some(inspector::Inspector::launch(label)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detach the inspector if attached. The inspector window will close on next message.
|
||||
#[cfg(feature = "inspector")]
|
||||
pub fn detach_inspector(&mut self) {
|
||||
self.inspector = None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "inspector")]
|
||||
fn send_to_inspector_if_attached(&mut self) {
|
||||
if self.inspector.is_none() {
|
||||
return;
|
||||
}
|
||||
let image = match self.render() {
|
||||
Ok(img) => img,
|
||||
Err(err) => {
|
||||
#[expect(clippy::print_stderr)]
|
||||
{
|
||||
eprintln!("egui_kittest inspector: render failed, skipping frame: {err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tree = self.last_accesskit_update.clone();
|
||||
let ppp = self.ctx.pixels_per_point();
|
||||
if let Some(inspector) = self.inspector.as_mut() {
|
||||
inspector.send_step(&image, ppp, tree);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the root viewport output
|
||||
fn root_viewport_output(&self) -> &egui::ViewportOutput {
|
||||
self.output
|
||||
|
||||
@@ -12,7 +12,7 @@ pub trait TestRenderer {
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the rendering fails.
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))]
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording", feature = "inspector"))]
|
||||
fn render(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
@@ -62,7 +62,7 @@ impl TestRenderer for LazyRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))]
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording", feature = "inspector"))]
|
||||
fn render(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:965e953ec7fef37770f40e4ec59e31bce853fc55ceab089c9208ac5270076e64
|
||||
size 71462
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cfd0808f85c7486b261250801f3d00545dde1325f733c9b475a2a8380c7afc32
|
||||
size 62708
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3c41f845dafd2572b366e607109e5d29901f825838c4dcd0188bf8eb94bbcd06
|
||||
size 192471
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eb32a0b8f6dc4905e92dcb1baa89fcbbe8a2bb75904be34813b3247e43c4ff32
|
||||
size 64465
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5b4e024dc1cdf69ffb7f4af0fd7a4cde5923e6ad4b8609262d7fc7506f310072
|
||||
size 14840
|
||||
Reference in New Issue
Block a user