From 06a82eff65153ad999471e20a1bdfa4c82624550 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Mon, 20 Apr 2026 12:17:18 +0200 Subject: [PATCH] Add basic inspector --- Cargo.lock | 32 +- Cargo.toml | 3 + crates/egui_kittest/Cargo.toml | 6 + crates/egui_kittest/src/inspector.rs | 160 ++++++ crates/egui_kittest/src/lib.rs | 93 +++- crates/egui_kittest/src/renderer.rs | 4 +- ...submenu_button_should_never_close_menu.gif | 3 + .../recordings/menu_close_on_click.gif | 3 + .../menu_close_on_click_outside.gif | 3 + .../snapshots/recordings/menu_snapshots.gif | 3 + .../recordings/test_interactive_tooltip.gif | 3 + crates/kittest_inspector/Cargo.toml | 40 ++ crates/kittest_inspector/src/lib.rs | 96 ++++ crates/kittest_inspector/src/main.rs | 503 ++++++++++++++++++ 14 files changed, 940 insertions(+), 12 deletions(-) create mode 100644 crates/egui_kittest/src/inspector.rs create mode 100644 crates/egui_kittest/tests/snapshots/recordings/clicking_submenu_button_should_never_close_menu.gif create mode 100644 crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click.gif create mode 100644 crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click_outside.gif create mode 100644 crates/egui_kittest/tests/snapshots/recordings/menu_snapshots.gif create mode 100644 crates/egui_kittest/tests/snapshots/recordings/test_interactive_tooltip.gif create mode 100644 crates/kittest_inspector/Cargo.toml create mode 100644 crates/kittest_inspector/src/lib.rs create mode 100644 crates/kittest_inspector/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 531435efd..265628ebb 100644 --- a/Cargo.lock +++ b/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" diff --git a/Cargo.toml b/Cargo.toml index 4559152a6..f40ce82d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 8b5bf36e2..37322a2b5 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -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 } diff --git a/crates/egui_kittest/src/inspector.rs b/crates/egui_kittest/src/inspector.rs new file mode 100644 index 000000000..239176c0c --- /dev/null +++ b/crates/egui_kittest/src/inspector.rs @@ -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, + reader: BufReader, + /// Keep the child alive until the harness drops. + _child: Child, + step: u64, + label: Option, + /// 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) -> Result { + 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, + ) { + 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 = 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, + }) +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index bb4a2c56e..cae0ef434 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -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, + + #[cfg(feature = "inspector")] + inspector: Option, + #[cfg(feature = "inspector")] + last_accesskit_update: Option, } impl 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 { 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 diff --git a/crates/egui_kittest/src/renderer.rs b/crates/egui_kittest/src/renderer.rs index ea06a7732..3a97438fe 100644 --- a/crates/egui_kittest/src/renderer.rs +++ b/crates/egui_kittest/src/renderer.rs @@ -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, diff --git a/crates/egui_kittest/tests/snapshots/recordings/clicking_submenu_button_should_never_close_menu.gif b/crates/egui_kittest/tests/snapshots/recordings/clicking_submenu_button_should_never_close_menu.gif new file mode 100644 index 000000000..529cbaf63 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/recordings/clicking_submenu_button_should_never_close_menu.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:965e953ec7fef37770f40e4ec59e31bce853fc55ceab089c9208ac5270076e64 +size 71462 diff --git a/crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click.gif b/crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click.gif new file mode 100644 index 000000000..15dbe229e --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfd0808f85c7486b261250801f3d00545dde1325f733c9b475a2a8380c7afc32 +size 62708 diff --git a/crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click_outside.gif b/crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click_outside.gif new file mode 100644 index 000000000..add82cf87 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/recordings/menu_close_on_click_outside.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c41f845dafd2572b366e607109e5d29901f825838c4dcd0188bf8eb94bbcd06 +size 192471 diff --git a/crates/egui_kittest/tests/snapshots/recordings/menu_snapshots.gif b/crates/egui_kittest/tests/snapshots/recordings/menu_snapshots.gif new file mode 100644 index 000000000..60f60cffc --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/recordings/menu_snapshots.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb32a0b8f6dc4905e92dcb1baa89fcbbe8a2bb75904be34813b3247e43c4ff32 +size 64465 diff --git a/crates/egui_kittest/tests/snapshots/recordings/test_interactive_tooltip.gif b/crates/egui_kittest/tests/snapshots/recordings/test_interactive_tooltip.gif new file mode 100644 index 000000000..fdc226af0 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/recordings/test_interactive_tooltip.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b4e024dc1cdf69ffb7f4af0fd7a4cde5923e6ad4b8609262d7fc7506f310072 +size 14840 diff --git a/crates/kittest_inspector/Cargo.toml b/crates/kittest_inspector/Cargo.toml new file mode 100644 index 000000000..804902709 --- /dev/null +++ b/crates/kittest_inspector/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "kittest_inspector" +version.workspace = true +authors = ["Lucas Meurer "] +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 diff --git a/crates/kittest_inspector/src/lib.rs b/crates/kittest_inspector/src/lib.rs new file mode 100644 index 000000000..4db1f0963 --- /dev/null +++ b/crates/kittest_inspector/src/lib.rs @@ -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, + /// Latest accesskit tree update, if any. + pub accesskit: Option, + /// Optional human-readable label (e.g. test name). + pub label: Option, +} + +/// 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(mut reader: R) -> io::Result +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(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(()) +} diff --git a/crates/kittest_inspector/src/main.rs b/crates/kittest_inspector/src/main.rs new file mode 100644 index 000000000..471d5220c --- /dev/null +++ b/crates/kittest_inspector/src/main.rs @@ -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::(); + 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, 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, + release_tx: ReleaseTx, + play_state: PlayState, + /// True when the worker is blocked waiting for a release. + worker_waiting: bool, + current_frame: Option, + current_texture: Option, + received_count: u64, + connected: bool, + /// Currently hovered widget (cleared every frame, set during central-panel paint). + hovered_node: Option, + /// Last clicked widget (sticky). + selected_node: Option, +} + +impl InspectorApp { + fn new( + _cc: &eframe::CreationContext<'_>, + worker_rx: mpsc::Receiver, + 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(); + } + }); +}