mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Add basic inspector
This commit is contained in:
32
Cargo.lock
32
Cargo.lock
@@ -529,6 +529,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"unty",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -1390,6 +1400,7 @@ dependencies = [
|
||||
"egui_extras",
|
||||
"image",
|
||||
"kittest",
|
||||
"kittest_inspector",
|
||||
"open",
|
||||
"pollster",
|
||||
"serde",
|
||||
@@ -2505,6 +2516,17 @@ dependencies = [
|
||||
"accesskit_consumer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kittest_inspector"
|
||||
version = "0.34.1"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"bincode 2.0.1",
|
||||
"eframe",
|
||||
"egui_extras",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.11.1"
|
||||
@@ -3576,7 +3598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa9dae7b05c02ec1a6bc9bcf20d8bc64a7dcbf57934107902a872014899b741f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
"bincode 1.3.3",
|
||||
"byteorder",
|
||||
"cfg-if",
|
||||
"itertools 0.10.5",
|
||||
@@ -4378,7 +4400,7 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"bincode 1.3.3",
|
||||
"fancy-regex",
|
||||
"flate2",
|
||||
"fnv",
|
||||
@@ -4821,6 +4843,12 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "unty"
|
||||
version = "0.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.3.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"crates/egui_extras",
|
||||
"crates/egui_glow",
|
||||
"crates/egui_kittest",
|
||||
"crates/kittest_inspector",
|
||||
"crates/egui-wgpu",
|
||||
"crates/egui-winit",
|
||||
"crates/egui",
|
||||
@@ -67,6 +68,7 @@ egui_demo_lib = { version = "0.34.1", path = "crates/egui_demo_lib", default-fea
|
||||
egui_glow = { version = "0.34.1", path = "crates/egui_glow", default-features = false }
|
||||
egui_kittest = { version = "0.34.1", path = "crates/egui_kittest", default-features = false }
|
||||
eframe = { version = "0.34.1", path = "crates/eframe", default-features = false }
|
||||
kittest_inspector = { version = "0.34.1", path = "crates/kittest_inspector", default-features = false }
|
||||
|
||||
accesskit = "0.24.0"
|
||||
accesskit_consumer = "0.35.0"
|
||||
@@ -78,6 +80,7 @@ ahash = { version = "0.8.12", default-features = false, features = [
|
||||
android_logger = "0.15.1"
|
||||
arboard = { version = "3.6.1", default-features = false }
|
||||
backtrace = "0.3.76"
|
||||
bincode = { version = "2.0.1", default-features = false, features = ["std", "serde"] }
|
||||
bitflags = "2.9.4"
|
||||
bytemuck = "1.24.0"
|
||||
cint = "0.3.1"
|
||||
|
||||
@@ -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
|
||||
40
crates/kittest_inspector/Cargo.toml
Normal file
40
crates/kittest_inspector/Cargo.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "kittest_inspector"
|
||||
version.workspace = true
|
||||
authors = ["Lucas Meurer <hi@lucasmerlin.me>"]
|
||||
description = "Live inspector eframe app for egui_kittest tests (frame + accesskit tree + step controls)"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository = "https://github.com/emilk/egui"
|
||||
categories = ["gui", "development-tools::testing"]
|
||||
keywords = ["egui", "kittest", "testing", "inspector"]
|
||||
readme = "./README.md"
|
||||
include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
|
||||
|
||||
[lib]
|
||||
name = "kittest_inspector"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "kittest_inspector"
|
||||
path = "src/main.rs"
|
||||
required-features = ["app"]
|
||||
|
||||
[features]
|
||||
default = ["app"]
|
||||
|
||||
## Build the eframe inspector binary.
|
||||
app = ["dep:eframe", "dep:egui_extras"]
|
||||
|
||||
[dependencies]
|
||||
accesskit = { workspace = true, features = ["serde"] }
|
||||
bincode = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
# `app` feature dependencies:
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu"], optional = true }
|
||||
egui_extras = { workspace = true, features = ["image"], optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
96
crates/kittest_inspector/src/lib.rs
Normal file
96
crates/kittest_inspector/src/lib.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Wire protocol for `kittest_inspector`.
|
||||
//!
|
||||
//! The harness launches `kittest_inspector` as a child process with piped stdin/stdout.
|
||||
//! For each step, the harness writes a [`HarnessMessage`] to the child's stdin and reads an
|
||||
//! [`InspectorReply`] from its stdout. The inspector decides whether to reply immediately
|
||||
//! (playing) or to wait for the user to click Play/Next (paused).
|
||||
//!
|
||||
//! Messages are framed as a 4-byte big-endian length followed by a bincode-encoded body.
|
||||
//! Anything the inspector wants to log goes to stderr (which the harness inherits), keeping
|
||||
//! stdout reserved for protocol traffic.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// A single rendered frame plus the accesskit tree update produced by the harness step.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Frame {
|
||||
/// Monotonically increasing step counter.
|
||||
pub step: u64,
|
||||
/// Image width in physical pixels.
|
||||
pub width: u32,
|
||||
/// Image height in physical pixels.
|
||||
pub height: u32,
|
||||
/// `physical_pixel = logical_point * pixels_per_point`. AccessKit bounds are in logical
|
||||
/// coords, the rendered image is in physical pixels — multiply by this to align them.
|
||||
pub pixels_per_point: f32,
|
||||
/// Tightly packed RGBA8 pixels (length = `width * height * 4`).
|
||||
pub rgba: Vec<u8>,
|
||||
/// Latest accesskit tree update, if any.
|
||||
pub accesskit: Option<accesskit::TreeUpdate>,
|
||||
/// Optional human-readable label (e.g. test name).
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Sent harness → inspector after every step, and once when the harness disconnects.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum HarnessMessage {
|
||||
/// A new frame is available.
|
||||
Frame(Frame),
|
||||
/// The harness is shutting down (e.g. `Drop`).
|
||||
Goodbye,
|
||||
}
|
||||
|
||||
/// Sent inspector → harness in response to a [`HarnessMessage::Frame`].
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
||||
pub enum InspectorReply {
|
||||
/// Resume the harness (it will continue running steps and may send another frame soon).
|
||||
Continue,
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_BYTES: usize = 256 * 1024 * 1024; // 256 MiB sanity cap
|
||||
|
||||
/// Read a length-prefixed bincode message.
|
||||
///
|
||||
/// # Errors
|
||||
/// I/O or decode failures.
|
||||
pub fn read_message<R, T>(mut reader: R) -> io::Result<T>
|
||||
where
|
||||
R: Read,
|
||||
T: for<'de> serde::Deserialize<'de>,
|
||||
{
|
||||
let mut len_buf = [0u8; 4];
|
||||
reader.read_exact(&mut len_buf)?;
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_MESSAGE_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("message too large: {len} bytes"),
|
||||
));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
reader.read_exact(&mut buf)?;
|
||||
let config = bincode::config::standard();
|
||||
let (value, _) = bincode::serde::decode_from_slice(&buf, config)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Write a length-prefixed bincode message.
|
||||
///
|
||||
/// # Errors
|
||||
/// I/O or encode failures.
|
||||
pub fn write_message<W, T>(mut writer: W, value: &T) -> io::Result<()>
|
||||
where
|
||||
W: Write,
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let config = bincode::config::standard();
|
||||
let bytes = bincode::serde::encode_to_vec(value, config)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?;
|
||||
let len = u32::try_from(bytes.len())
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
writer.write_all(&len.to_be_bytes())?;
|
||||
writer.write_all(&bytes)?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
503
crates/kittest_inspector/src/main.rs
Normal file
503
crates/kittest_inspector/src/main.rs
Normal file
@@ -0,0 +1,503 @@
|
||||
//! Eframe app that displays frames + accesskit trees streamed from an `egui_kittest` harness,
|
||||
//! and lets the user pause / resume / single-step the test and inspect individual widgets.
|
||||
//!
|
||||
//! Communication is over stdin/stdout: the harness pipes [`HarnessMessage`]s into our stdin
|
||||
//! and reads [`InspectorReply`]s from our stdout. All logging goes to stderr.
|
||||
|
||||
#![expect(clippy::print_stderr)] // The inspector binary's only logging channel is stderr.
|
||||
|
||||
use std::io::{self, BufReader, BufWriter};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
use eframe::egui;
|
||||
use kittest_inspector::{
|
||||
Frame, HarnessMessage, InspectorReply, read_message, write_message,
|
||||
};
|
||||
|
||||
use accesskit::{Node, NodeId, Rect as AkRect};
|
||||
|
||||
/// Internal worker → UI message.
|
||||
enum WorkerEvent {
|
||||
Frame(Frame),
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// UI → worker message: "you may send `Continue` to the harness now".
|
||||
type ReleaseTx = mpsc::Sender<()>;
|
||||
type ReleaseRx = mpsc::Receiver<()>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PlayState {
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let (worker_tx, worker_rx) = mpsc::channel::<WorkerEvent>();
|
||||
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||
|
||||
thread::Builder::new()
|
||||
.name("kittest_inspector_io".into())
|
||||
.spawn(move || run_io(&worker_tx, &release_rx))
|
||||
.expect("spawn io thread");
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("kittest inspector")
|
||||
.with_inner_size([1100.0, 750.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"kittest inspector",
|
||||
options,
|
||||
Box::new(|cc| Ok(Box::new(InspectorApp::new(cc, worker_rx, release_tx)))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read frames from stdin, forward to UI, wait for a release, then write Continue to stdout.
|
||||
fn run_io(ui_tx: &mpsc::Sender<WorkerEvent>, release_rx: &ReleaseRx) {
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let mut reader = BufReader::new(stdin.lock());
|
||||
let mut writer = BufWriter::new(stdout.lock());
|
||||
|
||||
loop {
|
||||
match read_message::<_, HarnessMessage>(&mut reader) {
|
||||
Ok(HarnessMessage::Frame(frame)) => {
|
||||
if ui_tx.send(WorkerEvent::Frame(frame)).is_err() {
|
||||
return;
|
||||
}
|
||||
if release_rx.recv().is_err() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = write_message(&mut writer, &InspectorReply::Continue) {
|
||||
eprintln!("kittest_inspector: write failed: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(HarnessMessage::Goodbye) => {
|
||||
let _ = ui_tx.send(WorkerEvent::Disconnected);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.kind() != io::ErrorKind::UnexpectedEof {
|
||||
eprintln!("kittest_inspector: read failed: {err}");
|
||||
}
|
||||
let _ = ui_tx.send(WorkerEvent::Disconnected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InspectorApp {
|
||||
worker_rx: mpsc::Receiver<WorkerEvent>,
|
||||
release_tx: ReleaseTx,
|
||||
play_state: PlayState,
|
||||
/// True when the worker is blocked waiting for a release.
|
||||
worker_waiting: bool,
|
||||
current_frame: Option<Frame>,
|
||||
current_texture: Option<egui::TextureHandle>,
|
||||
received_count: u64,
|
||||
connected: bool,
|
||||
/// Currently hovered widget (cleared every frame, set during central-panel paint).
|
||||
hovered_node: Option<NodeId>,
|
||||
/// Last clicked widget (sticky).
|
||||
selected_node: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl InspectorApp {
|
||||
fn new(
|
||||
_cc: &eframe::CreationContext<'_>,
|
||||
worker_rx: mpsc::Receiver<WorkerEvent>,
|
||||
release_tx: ReleaseTx,
|
||||
) -> Self {
|
||||
Self {
|
||||
worker_rx,
|
||||
release_tx,
|
||||
play_state: PlayState::Paused,
|
||||
worker_waiting: false,
|
||||
current_frame: None,
|
||||
current_texture: None,
|
||||
received_count: 0,
|
||||
connected: true,
|
||||
hovered_node: None,
|
||||
selected_node: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pump_worker(&mut self, ctx: &egui::Context) {
|
||||
while let Ok(event) = self.worker_rx.try_recv() {
|
||||
match event {
|
||||
WorkerEvent::Frame(frame) => {
|
||||
self.received_count += 1;
|
||||
self.upload_frame(ctx, &frame);
|
||||
// Keep the selection sticky across frames (same NodeId may still exist).
|
||||
self.current_frame = Some(frame);
|
||||
self.worker_waiting = true;
|
||||
if self.play_state == PlayState::Playing {
|
||||
self.send_release();
|
||||
}
|
||||
}
|
||||
WorkerEvent::Disconnected => {
|
||||
self.connected = false;
|
||||
self.worker_waiting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_frame(&mut self, ctx: &egui::Context, frame: &Frame) {
|
||||
let size = [frame.width as usize, frame.height as usize];
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(size, &frame.rgba);
|
||||
let texture = ctx.load_texture("kittest_inspector_frame", color_image, Default::default());
|
||||
self.current_texture = Some(texture);
|
||||
}
|
||||
|
||||
fn send_release(&mut self) {
|
||||
if !self.worker_waiting {
|
||||
return;
|
||||
}
|
||||
if self.release_tx.send(()).is_ok() {
|
||||
self.worker_waiting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for InspectorApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.pump_worker(&ctx);
|
||||
// Reset hover each frame — central panel will set it again if mouse is over the image.
|
||||
self.hovered_node = None;
|
||||
|
||||
controls_panel(self, ui);
|
||||
details_panel(self, ui);
|
||||
central_panel(self, ui);
|
||||
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn controls_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
egui::Panel::top("controls").show_inside(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let playing = app.play_state == PlayState::Playing;
|
||||
if ui.selectable_label(playing, "▶ Play").clicked() {
|
||||
app.play_state = PlayState::Playing;
|
||||
app.send_release();
|
||||
}
|
||||
if ui
|
||||
.selectable_label(!playing, "⏸ Pause")
|
||||
.on_hover_text("Pause harness after the next frame")
|
||||
.clicked()
|
||||
{
|
||||
app.play_state = PlayState::Paused;
|
||||
}
|
||||
let can_step = app.play_state == PlayState::Paused && app.worker_waiting;
|
||||
if ui
|
||||
.add_enabled(can_step, egui::Button::new("⏭ Next"))
|
||||
.on_hover_text("Advance one harness step")
|
||||
.clicked()
|
||||
{
|
||||
app.send_release();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.label(format!(
|
||||
"frames: {} | state: {:?} | {}",
|
||||
app.received_count,
|
||||
app.play_state,
|
||||
if app.connected {
|
||||
if app.worker_waiting {
|
||||
"harness blocked"
|
||||
} else {
|
||||
"harness running"
|
||||
}
|
||||
} else {
|
||||
"harness disconnected"
|
||||
}
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn details_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
egui::Panel::right("details")
|
||||
.resizable(true)
|
||||
.default_size(380.0)
|
||||
.show_inside(ui, |ui| {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
let Some(frame) = app.current_frame.clone() else {
|
||||
ui.weak("Waiting for frames...");
|
||||
return;
|
||||
};
|
||||
|
||||
egui::CollapsingHeader::new("Frame")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
kv_grid(ui, "frame_grid", |ui| {
|
||||
if let Some(label) = &frame.label {
|
||||
ui.label("Test:");
|
||||
ui.monospace(label);
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Step:");
|
||||
ui.monospace(frame.step.to_string());
|
||||
ui.end_row();
|
||||
ui.label("Size (px):");
|
||||
ui.monospace(format!("{} × {}", frame.width, frame.height));
|
||||
ui.end_row();
|
||||
ui.label("Pixels per point:");
|
||||
ui.monospace(format!("{:.2}", frame.pixels_per_point));
|
||||
ui.end_row();
|
||||
let node_count = frame
|
||||
.accesskit
|
||||
.as_ref()
|
||||
.map_or(0, |u| u.nodes.len());
|
||||
ui.label("AccessKit nodes:");
|
||||
ui.monospace(node_count.to_string());
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
|
||||
let target = app.selected_node.or(app.hovered_node);
|
||||
let header = if app.selected_node.is_some() {
|
||||
"Selected widget"
|
||||
} else if app.hovered_node.is_some() {
|
||||
"Hovered widget"
|
||||
} else {
|
||||
"Widget"
|
||||
};
|
||||
egui::CollapsingHeader::new(header)
|
||||
.default_open(true)
|
||||
.show(ui, |ui| match (target, &frame.accesskit) {
|
||||
(Some(id), Some(update)) => {
|
||||
if let Some((_, node)) = update.nodes.iter().find(|(nid, _)| *nid == id)
|
||||
{
|
||||
widget_details(ui, id, node);
|
||||
} else {
|
||||
ui.weak("(node not in latest tree)");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.weak("Hover over the rendered frame to inspect a widget.");
|
||||
}
|
||||
});
|
||||
|
||||
if app.selected_node.is_some()
|
||||
&& ui
|
||||
.small_button("clear selection")
|
||||
.on_hover_text("Stop pinning the selected widget")
|
||||
.clicked()
|
||||
{
|
||||
app.selected_node = None;
|
||||
}
|
||||
|
||||
egui::CollapsingHeader::new("All AccessKit nodes")
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
if let Some(update) = &frame.accesskit {
|
||||
for (id, node) in &update.nodes {
|
||||
let role = format!("{:?}", node.role());
|
||||
let label = node
|
||||
.label()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| node.value().map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
let selected = Some(*id) == app.selected_node;
|
||||
if ui
|
||||
.selectable_label(
|
||||
selected,
|
||||
format!("{:?} {role} {label:?}", id.0),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.selected_node = Some(*id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.weak("(no accesskit tree)");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn central_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
let Some(tex) = app.current_texture.clone() else {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label("Waiting for harness to connect...");
|
||||
});
|
||||
return;
|
||||
};
|
||||
let Some(frame) = app.current_frame.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let physical = tex.size_vec2(); // physical pixels of the rendered frame
|
||||
let avail = ui.available_size();
|
||||
let scale = (avail.x / physical.x).min(avail.y / physical.y).clamp(0.05, 1.0);
|
||||
let display_size = physical * scale;
|
||||
|
||||
let (image_rect, response) = ui.allocate_exact_size(
|
||||
display_size,
|
||||
egui::Sense::click().union(egui::Sense::hover()),
|
||||
);
|
||||
ui.painter().image(
|
||||
tex.id(),
|
||||
image_rect,
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
|
||||
// logical_point → screen_position:
|
||||
// screen = image_rect.min + ak_rect * pixels_per_point * scale
|
||||
let logical_to_screen = |r: AkRect| -> egui::Rect {
|
||||
let f = frame.pixels_per_point * scale;
|
||||
egui::Rect::from_min_max(
|
||||
image_rect.min + egui::vec2(r.x0 as f32 * f, r.y0 as f32 * f),
|
||||
image_rect.min + egui::vec2(r.x1 as f32 * f, r.y1 as f32 * f),
|
||||
)
|
||||
};
|
||||
let screen_to_logical = |p: egui::Pos2| -> (f64, f64) {
|
||||
let f = (frame.pixels_per_point * scale) as f64;
|
||||
(
|
||||
((p.x - image_rect.min.x) as f64) / f,
|
||||
((p.y - image_rect.min.y) as f64) / f,
|
||||
)
|
||||
};
|
||||
|
||||
// Hit test: smallest containing widget wins.
|
||||
if let (Some(pos), Some(update)) = (response.hover_pos(), &frame.accesskit) {
|
||||
let (lx, ly) = screen_to_logical(pos);
|
||||
let mut best: Option<(NodeId, f64)> = None;
|
||||
for (id, node) in &update.nodes {
|
||||
let Some(b) = node.bounds() else { continue };
|
||||
if lx >= b.x0 && lx <= b.x1 && ly >= b.y0 && ly <= b.y1 {
|
||||
let area = (b.x1 - b.x0).max(0.0) * (b.y1 - b.y0).max(0.0);
|
||||
if best.is_none_or(|(_, a)| area < a) {
|
||||
best = Some((*id, area));
|
||||
}
|
||||
}
|
||||
}
|
||||
app.hovered_node = best.map(|(id, _)| id);
|
||||
}
|
||||
if response.clicked() {
|
||||
app.selected_node = app.hovered_node;
|
||||
}
|
||||
|
||||
let painter = ui.painter_at(image_rect);
|
||||
if let Some(update) = &frame.accesskit {
|
||||
// Highlight selection (blue) and hover (yellow).
|
||||
let draw = |id: NodeId, color: egui::Color32| {
|
||||
if let Some((_, node)) = update.nodes.iter().find(|(nid, _)| *nid == id)
|
||||
&& let Some(b) = node.bounds()
|
||||
{
|
||||
painter.rect_stroke(
|
||||
logical_to_screen(b),
|
||||
2.0,
|
||||
egui::Stroke::new(1.5, color),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Some(id) = app.selected_node {
|
||||
draw(id, egui::Color32::from_rgb(80, 180, 255));
|
||||
}
|
||||
if let Some(id) = app.hovered_node
|
||||
&& app.hovered_node != app.selected_node
|
||||
{
|
||||
draw(id, egui::Color32::from_rgb(255, 220, 90));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn kv_grid(ui: &mut egui::Ui, id: &str, body: impl FnOnce(&mut egui::Ui)) {
|
||||
egui::Grid::new(id)
|
||||
.num_columns(2)
|
||||
.striped(true)
|
||||
.show(ui, body);
|
||||
}
|
||||
|
||||
/// Render the inspector grid for a single accesskit node, mimicking egui's `inspection_ui`.
|
||||
fn widget_details(ui: &mut egui::Ui, id: NodeId, node: &Node) {
|
||||
kv_grid(ui, "widget_grid", |ui| {
|
||||
ui.label("ID:");
|
||||
ui.monospace(format!("{:?}", id.0));
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Role:");
|
||||
ui.monospace(format!("{:?}", node.role()));
|
||||
ui.end_row();
|
||||
|
||||
if let Some(b) = node.bounds() {
|
||||
ui.label("Bounds:");
|
||||
ui.monospace(format!(
|
||||
"({:.1}, {:.1}) → ({:.1}, {:.1}) [{:.1} × {:.1}]",
|
||||
b.x0,
|
||||
b.y0,
|
||||
b.x1,
|
||||
b.y1,
|
||||
b.x1 - b.x0,
|
||||
b.y1 - b.y0,
|
||||
));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
for (label, value) in [
|
||||
("Label:", node.label()),
|
||||
("Value:", node.value()),
|
||||
("Description:", node.description()),
|
||||
("Placeholder:", node.placeholder()),
|
||||
("Tooltip:", node.tooltip()),
|
||||
("Class:", node.class_name()),
|
||||
("Author ID:", node.author_id()),
|
||||
("Keyboard:", node.keyboard_shortcut()),
|
||||
] {
|
||||
if let Some(v) = value
|
||||
&& !v.is_empty()
|
||||
{
|
||||
ui.label(label);
|
||||
ui.monospace(v);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
let flags = [
|
||||
("Disabled", node.is_disabled()),
|
||||
("Hidden", node.is_hidden()),
|
||||
("Read-only", node.is_read_only()),
|
||||
];
|
||||
let mut on_flags: Vec<&str> = flags
|
||||
.iter()
|
||||
.filter(|(_, on)| *on)
|
||||
.map(|(n, _)| *n)
|
||||
.collect();
|
||||
if let Some(sel) = node.is_selected() {
|
||||
on_flags.push(if sel { "Selected" } else { "Unselected" });
|
||||
}
|
||||
if !on_flags.is_empty() {
|
||||
ui.label("Flags:");
|
||||
ui.monospace(on_flags.join(", "));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
if let Some(t) = node.toggled() {
|
||||
ui.label("Toggled:");
|
||||
ui.monospace(format!("{t:?}"));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
let child_count = node.children().len();
|
||||
if child_count > 0 {
|
||||
ui.label("Children:");
|
||||
ui.monospace(child_count.to_string());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user