mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
Add egui_inspection crate and eframe inspection hooks
New `egui_inspection` crate ships: - `protocol` (default): wire types + length-prefixed msgpack framing for the inspector ↔ egui-peer connection. Transport-neutral (stdio / unix socket / TCP). - `plugin`: `InspectionPlugin`, an `egui::Plugin` that dials a unix socket from `EGUI_INSPECTION_SOCKET`, streams frames + accesskit tree updates, and applies inbound `InspectorCommand`s back into the running `egui::Context`. eframe gains an `inspection` feature that auto-attaches the plugin during native startup (glow + wgpu integrations) when the env var is set. Connection failures log via `log::warn!` and do not abort startup. Lives in its own crate (rather than `egui_kittest`) so eframe can pull the protocol in without picking up the test harness, and so external tools can depend on it directly.
This commit is contained in:
42
Cargo.lock
42
Cargo.lock
@@ -1219,6 +1219,7 @@ dependencies = [
|
|||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
"egui-winit",
|
"egui-winit",
|
||||||
"egui_glow",
|
"egui_glow",
|
||||||
|
"egui_inspection",
|
||||||
"glow",
|
"glow",
|
||||||
"glutin",
|
"glutin",
|
||||||
"glutin-winit",
|
"glutin-winit",
|
||||||
@@ -1391,6 +1392,18 @@ dependencies = [
|
|||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "egui_inspection"
|
||||||
|
version = "0.34.2"
|
||||||
|
dependencies = [
|
||||||
|
"document-features",
|
||||||
|
"egui",
|
||||||
|
"image",
|
||||||
|
"rmp-serde",
|
||||||
|
"serde",
|
||||||
|
"serde_bytes",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui_kittest"
|
name = "egui_kittest"
|
||||||
version = "0.34.2"
|
version = "0.34.2"
|
||||||
@@ -3947,6 +3960,25 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp"
|
||||||
|
version = "0.8.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp-serde"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||||
|
dependencies = [
|
||||||
|
"rmp",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ron"
|
name = "ron"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -4144,6 +4176,16 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_bytes"
|
||||||
|
version = "0.11.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/egui_demo_lib",
|
"crates/egui_demo_lib",
|
||||||
"crates/egui_extras",
|
"crates/egui_extras",
|
||||||
"crates/egui_glow",
|
"crates/egui_glow",
|
||||||
|
"crates/egui_inspection",
|
||||||
"crates/egui_kittest",
|
"crates/egui_kittest",
|
||||||
"crates/egui-wgpu",
|
"crates/egui-wgpu",
|
||||||
"crates/egui-winit",
|
"crates/egui-winit",
|
||||||
@@ -65,6 +66,7 @@ egui_extras = { version = "0.34.2", path = "crates/egui_extras", default-feature
|
|||||||
egui-wgpu = { version = "0.34.2", path = "crates/egui-wgpu", default-features = false }
|
egui-wgpu = { version = "0.34.2", path = "crates/egui-wgpu", default-features = false }
|
||||||
egui_demo_lib = { version = "0.34.2", path = "crates/egui_demo_lib", default-features = false }
|
egui_demo_lib = { version = "0.34.2", path = "crates/egui_demo_lib", default-features = false }
|
||||||
egui_glow = { version = "0.34.2", path = "crates/egui_glow", default-features = false }
|
egui_glow = { version = "0.34.2", path = "crates/egui_glow", default-features = false }
|
||||||
|
egui_inspection = { version = "0.34.2", path = "crates/egui_inspection", default-features = false }
|
||||||
egui_kittest = { version = "0.34.2", path = "crates/egui_kittest", default-features = false }
|
egui_kittest = { version = "0.34.2", path = "crates/egui_kittest", default-features = false }
|
||||||
eframe = { version = "0.34.2", path = "crates/eframe", default-features = false }
|
eframe = { version = "0.34.2", path = "crates/eframe", default-features = false }
|
||||||
|
|
||||||
@@ -123,6 +125,7 @@ raw-window-handle = "0.6.2"
|
|||||||
rayon = "1.11.0"
|
rayon = "1.11.0"
|
||||||
resvg = { version = "0.45.1", default-features = false }
|
resvg = { version = "0.45.1", default-features = false }
|
||||||
rfd = "0.17.2"
|
rfd = "0.17.2"
|
||||||
|
rmp-serde = "1.3.1"
|
||||||
ron = "0.12.0"
|
ron = "0.12.0"
|
||||||
self_cell = "1.2.1"
|
self_cell = "1.2.1"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ x11 = [
|
|||||||
## This is used to generate images for examples.
|
## This is used to generate images for examples.
|
||||||
__screenshot = []
|
__screenshot = []
|
||||||
|
|
||||||
|
## Enable the [`egui_inspection`] plugin. When the `EGUI_INSPECTION_SOCKET` env var points
|
||||||
|
## at a unix socket, eframe attaches an `InspectionPlugin` to the egui context on startup
|
||||||
|
## that streams the AccessKit tree and applies received commands. Unix-only; no-op on
|
||||||
|
## non-unix targets.
|
||||||
|
inspection = ["dep:egui_inspection", "accesskit"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
egui = { workspace = true, default-features = false, features = ["bytemuck"] }
|
egui = { workspace = true, default-features = false, features = ["bytemuck"] }
|
||||||
|
|
||||||
@@ -131,6 +137,7 @@ web-time.workspace = true
|
|||||||
# Optional dependencies
|
# Optional dependencies
|
||||||
|
|
||||||
egui_glow = { workspace = true, optional = true, default-features = false }
|
egui_glow = { workspace = true, optional = true, default-features = false }
|
||||||
|
egui_inspection = { workspace = true, optional = true, features = ["plugin"] }
|
||||||
glow = { workspace = true, optional = true }
|
glow = { workspace = true, optional = true }
|
||||||
ron = { workspace = true, optional = true, features = ["integer128"] }
|
ron = { workspace = true, optional = true, features = ["integer128"] }
|
||||||
serde = { workspace = true, optional = true }
|
serde = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -209,6 +209,33 @@ pub use native::file_storage::storage_dir;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod icon_data;
|
pub mod icon_data;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Attach an [`egui_inspection::InspectionPlugin`] to `ctx` if the
|
||||||
|
/// `EGUI_INSPECTION_SOCKET` env var points at a reachable unix socket.
|
||||||
|
///
|
||||||
|
/// No-op when:
|
||||||
|
/// - the `inspection` feature isn't enabled,
|
||||||
|
/// - the target isn't unix, or
|
||||||
|
/// - the env var is unset.
|
||||||
|
///
|
||||||
|
/// Connection failures are logged via `log::warn!` but do not abort startup — running
|
||||||
|
/// without an inspector is always valid.
|
||||||
|
#[cfg(all(feature = "inspection", unix, not(target_arch = "wasm32")))]
|
||||||
|
pub(crate) fn maybe_attach_inspection_plugin(ctx: &egui::Context, label: Option<String>) {
|
||||||
|
match egui_inspection::InspectionPlugin::from_env(label) {
|
||||||
|
Ok(Some(plugin)) => {
|
||||||
|
log::info!("eframe: attaching egui_inspection plugin");
|
||||||
|
ctx.add_plugin(plugin);
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(err) => log::warn!("eframe: egui_inspection attach failed: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(all(feature = "inspection", unix, not(target_arch = "wasm32"))))]
|
||||||
|
pub(crate) fn maybe_attach_inspection_plugin(_ctx: &egui::Context, _label: Option<String>) {}
|
||||||
|
|
||||||
/// This is how you start a native (desktop) app.
|
/// This is how you start a native (desktop) app.
|
||||||
///
|
///
|
||||||
/// The first argument is name of your app, which is an identifier
|
/// The first argument is name of your app, which is an identifier
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ impl<'app> GlowWinitApp<'app> {
|
|||||||
let app_creator = std::mem::take(&mut self.app_creator)
|
let app_creator = std::mem::take(&mut self.app_creator)
|
||||||
.expect("Single-use AppCreator has unexpectedly already been taken");
|
.expect("Single-use AppCreator has unexpectedly already been taken");
|
||||||
|
|
||||||
|
crate::maybe_attach_inspection_plugin(&integration.egui_ctx, Some(self.app_name.clone()));
|
||||||
|
|
||||||
let app: Box<dyn 'app + App> = {
|
let app: Box<dyn 'app + App> = {
|
||||||
// Use latest raw_window_handle for eframe compatibility
|
// Use latest raw_window_handle for eframe compatibility
|
||||||
use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _};
|
use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _};
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ impl<'app> WgpuWinitApp<'app> {
|
|||||||
|
|
||||||
let app_creator = std::mem::take(&mut self.app_creator)
|
let app_creator = std::mem::take(&mut self.app_creator)
|
||||||
.expect("Single-use AppCreator has unexpectedly already been taken");
|
.expect("Single-use AppCreator has unexpectedly already been taken");
|
||||||
|
|
||||||
|
crate::maybe_attach_inspection_plugin(&egui_ctx, Some(self.app_name.clone()));
|
||||||
|
|
||||||
let cc = CreationContext {
|
let cc = CreationContext {
|
||||||
egui_ctx: egui_ctx.clone(),
|
egui_ctx: egui_ctx.clone(),
|
||||||
integration_info: integration.frame.info().clone(),
|
integration_info: integration.frame.info().clone(),
|
||||||
|
|||||||
45
crates/egui_inspection/Cargo.toml
Normal file
45
crates/egui_inspection/Cargo.toml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
[package]
|
||||||
|
name = "egui_inspection"
|
||||||
|
version.workspace = true
|
||||||
|
authors = [
|
||||||
|
"Lucas Meurer <hi@lucasmerlin.me>",
|
||||||
|
"Emil Ernerfeldt <emil.ernerfeldt@gmail.com>",
|
||||||
|
]
|
||||||
|
description = "Wire protocol and egui::Plugin for live inspection of running egui apps and kittest harnesses"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
homepage = "https://github.com/emilk/egui"
|
||||||
|
license.workspace = true
|
||||||
|
readme = "./README.md"
|
||||||
|
repository = "https://github.com/emilk/egui"
|
||||||
|
categories = ["gui", "development-tools::testing", "accessibility"]
|
||||||
|
keywords = ["gui", "egui", "inspector", "accesskit", "testing"]
|
||||||
|
include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
rustdoc-args = ["--generate-link-to-definition"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["protocol"]
|
||||||
|
|
||||||
|
## Wire-protocol types (`HarnessMessage`, `InspectorCommand`, …) plus length-prefixed
|
||||||
|
## MessagePack framing helpers. No `egui` dependency beyond `egui::accesskit`.
|
||||||
|
protocol = ["dep:rmp-serde", "dep:serde", "dep:serde_bytes", "egui/serde"]
|
||||||
|
|
||||||
|
## `InspectionPlugin` — an `egui::Plugin` impl that streams frames + accesskit tree to
|
||||||
|
## an inspector over a unix socket and applies received commands. Auto-attaches when
|
||||||
|
## the [`INSPECTION_SOCKET_ENV_VAR`] env var is set.
|
||||||
|
plugin = ["protocol", "dep:image"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
egui.workspace = true
|
||||||
|
serde = { workspace = true, optional = true }
|
||||||
|
serde_bytes = { version = "0.11.17", optional = true }
|
||||||
|
rmp-serde = { workspace = true, optional = true }
|
||||||
|
image = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
document-features = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
15
crates/egui_inspection/README.md
Normal file
15
crates/egui_inspection/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# egui_inspection
|
||||||
|
|
||||||
|
Wire protocol and `egui::Plugin` for live inspection of running egui apps and
|
||||||
|
kittest harnesses.
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
|
||||||
|
- **`protocol`** (default feature): length-prefixed MessagePack messages used by
|
||||||
|
`egui_kittest`'s inspector, the external `kittest_inspector` UI, and the
|
||||||
|
`egui_kittest_mcp` server.
|
||||||
|
|
||||||
|
- **`plugin`** (opt-in): an `egui::Plugin` implementation that streams frames +
|
||||||
|
AccessKit tree updates to an inspector over a unix domain socket and applies
|
||||||
|
received `InspectorCommand`s back into the running app. Auto-attaches when
|
||||||
|
`EGUI_INSPECTION_SOCKET` is set.
|
||||||
29
crates/egui_inspection/src/lib.rs
Normal file
29
crates/egui_inspection/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#![cfg_attr(doc, doc = include_str!("../README.md"))]
|
||||||
|
//!
|
||||||
|
//! ## Feature flags
|
||||||
|
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
|
||||||
|
|
||||||
|
#[cfg(feature = "protocol")]
|
||||||
|
pub mod protocol;
|
||||||
|
|
||||||
|
#[cfg(feature = "protocol")]
|
||||||
|
pub use protocol::{
|
||||||
|
Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, MAX_MESSAGE_BYTES,
|
||||||
|
PROTOCOL_VERSION, PeerHello, PeerKind, SourceView, read_message, write_message,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Environment variable: when set to a unix socket path, [`InspectionPlugin::from_env`]
|
||||||
|
/// (and similar inspector-side code) connects to it.
|
||||||
|
///
|
||||||
|
/// Exposed unconditionally so both ends of the connection — the plugin (on `plugin`,
|
||||||
|
/// unix) and the inspector / MCP server — can reference the same name without pulling in
|
||||||
|
/// the full plugin impl.
|
||||||
|
pub const INSPECTION_SOCKET_ENV_VAR: &str = "EGUI_INSPECTION_SOCKET";
|
||||||
|
|
||||||
|
// The plugin uses `std::os::unix::net::UnixStream` for transport, so the impl is
|
||||||
|
// unix-only. Non-unix builds with `plugin` enabled still get the protocol types.
|
||||||
|
#[cfg(all(feature = "plugin", unix))]
|
||||||
|
mod plugin;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "plugin", unix))]
|
||||||
|
pub use plugin::{InspectionError, InspectionPlugin};
|
||||||
356
crates/egui_inspection/src/plugin.rs
Normal file
356
crates/egui_inspection/src/plugin.rs
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
//! [`InspectionPlugin`] — an [`egui::Plugin`] that streams frames + AccessKit tree updates
|
||||||
|
//! to an inspector over a unix domain socket and applies received commands back into the
|
||||||
|
//! running app.
|
||||||
|
//!
|
||||||
|
//! Connection model:
|
||||||
|
//! - The inspector binds a unix socket. The egui peer dials it.
|
||||||
|
//! - The plugin spawns one reader thread and one writer thread, each owning one half of the
|
||||||
|
//! stream. UI-thread hooks (`input_hook` / `output_hook`) only touch in-process channels
|
||||||
|
//! and the reader-side command queue.
|
||||||
|
//! - If the writer channel is saturated, the plugin drops the oldest frame in favor of the
|
||||||
|
//! newest so the UI thread never blocks on a slow inspector.
|
||||||
|
//!
|
||||||
|
//! Live apps don't own a deterministic run loop, so `Step` / `Run` / `Play` / `Pause`
|
||||||
|
//! commands are no-ops. `Handle { events }` is honored by appending the events to the next
|
||||||
|
//! `RawInput`. After every received command the reader thread calls
|
||||||
|
//! `Context::request_repaint` so the integration wakes up even when the UI is otherwise
|
||||||
|
//! idle — without this, queued events would sit in the channel until the next mouse move.
|
||||||
|
//!
|
||||||
|
//! # Reference cycle
|
||||||
|
//!
|
||||||
|
//! The plugin holds a clone of `egui::Context` so the reader thread can wake the UI loop.
|
||||||
|
//! `egui::Context` is `Arc<RwLock<…>>` and the context owns its plugins, so this creates an
|
||||||
|
//! intentional cycle: the context will not drop until the process exits. Acceptable for a
|
||||||
|
//! live-debugging inspector — the typical workflow is "attach for the lifetime of the
|
||||||
|
//! process, then exit." For deterministic shutdown, kill the process.
|
||||||
|
|
||||||
|
use std::io::{BufReader, BufWriter};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use egui::{Context, FullOutput, RawInput};
|
||||||
|
|
||||||
|
use crate::INSPECTION_SOCKET_ENV_VAR;
|
||||||
|
use crate::protocol::{
|
||||||
|
Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION,
|
||||||
|
PeerHello, PeerKind, read_message, write_message,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Errors that can occur attaching to an inspector.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum InspectionError {
|
||||||
|
/// Failed to dial the inspector socket.
|
||||||
|
Connect(std::io::Error),
|
||||||
|
/// Failed to set up reader / writer threads.
|
||||||
|
Pipe(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for InspectionError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Connect(err) => write!(
|
||||||
|
f,
|
||||||
|
"failed to connect to egui_inspection socket (set {INSPECTION_SOCKET_ENV_VAR}): {err}"
|
||||||
|
),
|
||||||
|
Self::Pipe(msg) => write!(f, "egui_inspection pipe setup failed: {msg}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for InspectionError {}
|
||||||
|
|
||||||
|
/// Bounded outbound queue depth. If the inspector falls behind we drop oldest frames
|
||||||
|
/// rather than block the UI thread.
|
||||||
|
const OUTBOUND_QUEUE_DEPTH: usize = 8;
|
||||||
|
|
||||||
|
/// Shared between [`InspectionPlugin::setup`] and the reader thread so the reader can wake
|
||||||
|
/// the UI loop after each received command. Written exactly once in `setup`.
|
||||||
|
type SharedCtx = Arc<OnceLock<Context>>;
|
||||||
|
|
||||||
|
/// `egui::Plugin` that streams the running app's state to an inspector.
|
||||||
|
pub struct InspectionPlugin {
|
||||||
|
/// Incoming commands from the inspector.
|
||||||
|
command_rx: Arc<Mutex<mpsc::Receiver<InspectorCommand>>>,
|
||||||
|
/// Outbound messages → writer thread → socket. Bounded; oldest is dropped on overflow.
|
||||||
|
outbound_tx: mpsc::SyncSender<HarnessMessage>,
|
||||||
|
/// Filled in `Plugin::setup`; read by the reader thread to call `request_repaint` after
|
||||||
|
/// every received command.
|
||||||
|
shared_ctx: SharedCtx,
|
||||||
|
/// Monotonic frame counter.
|
||||||
|
step: u64,
|
||||||
|
/// Frame data (accesskit + meta) captured in `output_hook`, held until the matching
|
||||||
|
/// `Event::Screenshot` arrives in the next `input_hook`. Emitting only on pair-up keeps
|
||||||
|
/// the inspector's screenshot and accesskit tree in lockstep — the alternative (emit
|
||||||
|
/// accesskit now, screenshot later) shows widget boxes that don't match the rendered
|
||||||
|
/// frame they overlay.
|
||||||
|
pending_frame: Option<Frame>,
|
||||||
|
/// `true` between dispatching `ViewportCommand::Screenshot` and observing the reply
|
||||||
|
/// `Event::Screenshot`. While set, the plugin keeps requesting repaints so the
|
||||||
|
/// integration eventually paints a visible frame and the screenshot fulfills (the eframe
|
||||||
|
/// wgpu path skips capture when the viewport reports `visible=false`).
|
||||||
|
awaiting_screenshot: bool,
|
||||||
|
/// Set by [`InspectorCommand::Screenshot`]; consumed by the next `output_hook` which
|
||||||
|
/// dispatches a `ViewportCommand::Screenshot` and stashes the frame.
|
||||||
|
one_shot_screenshot: bool,
|
||||||
|
/// When `true`, every `output_hook` requests a `ViewportCommand::Screenshot` and holds
|
||||||
|
/// the frame until the screenshot returns. Toggled by
|
||||||
|
/// [`InspectorCommand::SetContinuousScreenshots`].
|
||||||
|
continuous_screenshots: bool,
|
||||||
|
/// Background threads — held so they live as long as the plugin.
|
||||||
|
_reader_thread: thread::JoinHandle<()>,
|
||||||
|
_writer_thread: thread::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InspectionPlugin {
|
||||||
|
/// If [`INSPECTION_SOCKET_ENV_VAR`] is set, return a plugin connected to it.
|
||||||
|
/// Returns `Ok(None)` when the env var is unset.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// When the env var is set but the socket can't be dialed.
|
||||||
|
pub fn from_env(label: Option<String>) -> Result<Option<Self>, InspectionError> {
|
||||||
|
let Ok(path) = std::env::var(INSPECTION_SOCKET_ENV_VAR) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Self::attach(PathBuf::from(path), label).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dial the given unix socket and attach.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// When the socket can't be dialed or a thread can't be spawned.
|
||||||
|
pub fn attach(socket_path: PathBuf, label: Option<String>) -> Result<Self, InspectionError> {
|
||||||
|
let stream = UnixStream::connect(&socket_path).map_err(InspectionError::Connect)?;
|
||||||
|
let reader_stream = stream
|
||||||
|
.try_clone()
|
||||||
|
.map_err(InspectionError::Connect)?;
|
||||||
|
let writer_stream = stream;
|
||||||
|
|
||||||
|
let shared_ctx: SharedCtx = Arc::new(OnceLock::new());
|
||||||
|
|
||||||
|
let (command_tx, command_rx) = mpsc::channel::<InspectorCommand>();
|
||||||
|
let reader_ctx = shared_ctx.clone();
|
||||||
|
let reader_thread = thread::Builder::new()
|
||||||
|
.name("egui_inspection_reader".into())
|
||||||
|
.spawn(move || run_reader(BufReader::new(reader_stream), &command_tx, &reader_ctx))
|
||||||
|
.map_err(|err| InspectionError::Pipe(format!("spawn reader thread: {err}")))?;
|
||||||
|
|
||||||
|
let (outbound_tx, outbound_rx) = mpsc::sync_channel::<HarnessMessage>(OUTBOUND_QUEUE_DEPTH);
|
||||||
|
let writer_thread = thread::Builder::new()
|
||||||
|
.name("egui_inspection_writer".into())
|
||||||
|
.spawn(move || run_writer(BufWriter::new(writer_stream), outbound_rx))
|
||||||
|
.map_err(|err| InspectionError::Pipe(format!("spawn writer thread: {err}")))?;
|
||||||
|
|
||||||
|
// Hello must be the first message on the wire. Send via the writer-thread queue
|
||||||
|
// (rather than directly on the stream) so ordering against later frames is
|
||||||
|
// preserved even under contention.
|
||||||
|
let hello = HarnessMessage::Hello(PeerHello {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
peer_kind: PeerKind::Live,
|
||||||
|
capabilities: Capabilities::LIVE,
|
||||||
|
// Live apps start accesskit-only; inspector flips on via
|
||||||
|
// `SetContinuousScreenshots(true)` when it wants images.
|
||||||
|
continuous_screenshots: false,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
outbound_tx
|
||||||
|
.send(hello)
|
||||||
|
.map_err(|err| InspectionError::Pipe(format!("send Hello: {err}")))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
command_rx: Arc::new(Mutex::new(command_rx)),
|
||||||
|
outbound_tx,
|
||||||
|
shared_ctx,
|
||||||
|
step: 0,
|
||||||
|
pending_frame: None,
|
||||||
|
awaiting_screenshot: false,
|
||||||
|
one_shot_screenshot: false,
|
||||||
|
continuous_screenshots: false,
|
||||||
|
_reader_thread: reader_thread,
|
||||||
|
_writer_thread: writer_thread,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort send. Drops oldest frame on overflow so the UI thread never blocks.
|
||||||
|
fn send(&self, msg: HarnessMessage) {
|
||||||
|
match self.outbound_tx.try_send(msg) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(mpsc::TrySendError::Full(msg)) => {
|
||||||
|
// Queue saturated — try once more in case the writer just drained a slot.
|
||||||
|
// If still full we drop the message. UI thread never blocks.
|
||||||
|
let _ = self.outbound_tx.try_send(msg);
|
||||||
|
}
|
||||||
|
Err(mpsc::TrySendError::Disconnected(_)) => { /* writer is gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl egui::Plugin for InspectionPlugin {
|
||||||
|
fn debug_name(&self) -> &'static str {
|
||||||
|
"egui_inspection"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(&mut self, ctx: &Context) {
|
||||||
|
// We rely on the AccessKit tree to describe the UI structure to the inspector.
|
||||||
|
ctx.enable_accesskit();
|
||||||
|
// Hand the context to the reader thread so it can wake the UI loop when commands
|
||||||
|
// arrive on an otherwise-idle app. `set` only succeeds the first time, which is
|
||||||
|
// what we want — `setup` is documented to run once per plugin registration.
|
||||||
|
let _ = self.shared_ctx.set(ctx.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_hook(&mut self, input: &mut RawInput) {
|
||||||
|
// Capture any screenshot reply the integration produced in response to our previous
|
||||||
|
// `ViewportCommand::Screenshot`. If we're holding a frame waiting for this
|
||||||
|
// screenshot, attach the pixels and emit the pair now. Without a pending frame the
|
||||||
|
// screenshot is stray (we never dispatched) and we drop it. We observe (don't
|
||||||
|
// consume) — apps using the same event keep getting it.
|
||||||
|
for ev in &input.events {
|
||||||
|
if let egui::Event::Screenshot { image, .. } = ev {
|
||||||
|
self.awaiting_screenshot = false;
|
||||||
|
if let Some(mut frame) = self.pending_frame.take() {
|
||||||
|
let [w, h] = [image.size[0] as u32, image.size[1] as u32];
|
||||||
|
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||||
|
frame.screenshot = Some(FrameScreenshot {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
rgba,
|
||||||
|
});
|
||||||
|
self.send(HarnessMessage::Frame(Box::new(frame)));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain any commands the inspector sent since the previous frame.
|
||||||
|
let mut got_command = false;
|
||||||
|
let rx = self.command_rx.lock().expect("poisoned");
|
||||||
|
while let Ok(cmd) = rx.try_recv() {
|
||||||
|
got_command = true;
|
||||||
|
match cmd {
|
||||||
|
InspectorCommand::Handle { events } => {
|
||||||
|
input.events.extend(events);
|
||||||
|
}
|
||||||
|
InspectorCommand::Screenshot => {
|
||||||
|
self.one_shot_screenshot = true;
|
||||||
|
}
|
||||||
|
InspectorCommand::SetContinuousScreenshots(on) => {
|
||||||
|
self.continuous_screenshots = on;
|
||||||
|
}
|
||||||
|
InspectorCommand::Resize { width, height } => {
|
||||||
|
if let Some(ctx) = self.shared_ctx.get() {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
|
||||||
|
width as f32,
|
||||||
|
height as f32,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The live-app path doesn't own a deterministic run loop, so the
|
||||||
|
// step/run/play/pause commands are no-ops here. The deterministic side
|
||||||
|
// lives in `egui_kittest::InspectorPlugin`.
|
||||||
|
InspectorCommand::Step
|
||||||
|
| InspectorCommand::Run
|
||||||
|
| InspectorCommand::Play
|
||||||
|
| InspectorCommand::Pause => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive-mode apps only paint on input. The reader thread's `request_repaint`
|
||||||
|
// woke us for the current frame, but viewport-command replies (`Event::Screenshot`)
|
||||||
|
// and synthetic `Handle` events both need at least one *more* frame to be observed
|
||||||
|
// by the host app and round-trip back into a `Frame` we can emit. Without an extra
|
||||||
|
// repaint scheduled now, the app goes idle until an unrelated wake-up (mouse move,
|
||||||
|
// timer) and the inspector sees a multi-second stall.
|
||||||
|
//
|
||||||
|
// While a screenshot is outstanding (or continuous mode is on), keep requesting
|
||||||
|
// repaints every frame — eframe's wgpu path skips screenshot capture when the
|
||||||
|
// viewport reports `visible=false`, so a backgrounded window won't fulfill the
|
||||||
|
// request until it next becomes visible. We can't force visibility from here without
|
||||||
|
// disturbing focus, but pumping repaints keeps the app alive so the moment the OS
|
||||||
|
// reports visibility (cursor enters, app brought forward, system unhide) the queued
|
||||||
|
// action fires.
|
||||||
|
if got_command || self.awaiting_screenshot || self.continuous_screenshots {
|
||||||
|
if let Some(ctx) = self.shared_ctx.get() {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_hook(&mut self, output: &mut FullOutput) {
|
||||||
|
self.step = self.step.saturating_add(1);
|
||||||
|
let want_screenshot = self.continuous_screenshots || self.one_shot_screenshot;
|
||||||
|
self.one_shot_screenshot = false;
|
||||||
|
|
||||||
|
// Pull the AccessKit tree update out of the PlatformOutput. We *clone* rather than
|
||||||
|
// take so the host integration still receives it for the real accessibility stack.
|
||||||
|
let tree = output.platform_output.accesskit_update.clone();
|
||||||
|
|
||||||
|
let frame = Frame {
|
||||||
|
step: self.step,
|
||||||
|
pixels_per_point: output.pixels_per_point,
|
||||||
|
screenshot: None,
|
||||||
|
accesskit: tree,
|
||||||
|
source: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !want_screenshot {
|
||||||
|
// No screenshot needed — emit immediately.
|
||||||
|
self.send(HarnessMessage::Frame(Box::new(frame)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Want a screenshot. If the previous frame's request is still outstanding, drop
|
||||||
|
// this output entirely (the screenshot reply would otherwise pair with a stale
|
||||||
|
// accesskit tree). Slow inspector → matched-pair frames > throughput; the user
|
||||||
|
// explicitly opted into this delay by enabling continuous screenshots.
|
||||||
|
if self.awaiting_screenshot {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold the frame; dispatch a screenshot request for what was just rendered. The
|
||||||
|
// matching `Event::Screenshot` arrives in the next `input_hook`, where we attach
|
||||||
|
// pixels and emit.
|
||||||
|
self.pending_frame = Some(frame);
|
||||||
|
if let Some(ctx) = self.shared_ctx.get() {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
|
||||||
|
self.awaiting_screenshot = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reader-thread entry point: forward every decoded [`InspectorCommand`] into the channel
|
||||||
|
/// until EOF or the receiver is dropped. After each enqueue, wake the UI thread so an
|
||||||
|
/// otherwise-idle app actually processes the command on its next frame.
|
||||||
|
fn run_reader(
|
||||||
|
mut reader: BufReader<UnixStream>,
|
||||||
|
tx: &mpsc::Sender<InspectorCommand>,
|
||||||
|
ctx: &SharedCtx,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
match read_message::<_, InspectorCommand>(&mut reader) {
|
||||||
|
Ok(cmd) => {
|
||||||
|
if tx.send(cmd).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(ctx) = ctx.get() {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writer-thread entry point: drain the outbound queue, framing each message to the socket.
|
||||||
|
fn run_writer(
|
||||||
|
mut writer: BufWriter<UnixStream>,
|
||||||
|
rx: mpsc::Receiver<HarnessMessage>,
|
||||||
|
) {
|
||||||
|
while let Ok(msg) = rx.recv() {
|
||||||
|
if write_message(&mut writer, &msg).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
280
crates/egui_inspection/src/protocol.rs
Normal file
280
crates/egui_inspection/src/protocol.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
//! Wire protocol shared between an egui peer (an `egui_kittest::Harness` or a live
|
||||||
|
//! `eframe` app running [`crate::InspectionPlugin`]) and an external inspector
|
||||||
|
//! (the standalone `kittest_inspector` UI binary, or the `egui_kittest_mcp` server).
|
||||||
|
//!
|
||||||
|
//! The egui peer writes [`HarnessMessage`]s (frames plus blocking-state updates) into the
|
||||||
|
//! transport. The inspector writes [`InspectorCommand`]s back to drive the peer. Shutdown
|
||||||
|
//! is detected on either side via EOF — no explicit goodbye message.
|
||||||
|
//!
|
||||||
|
//! Messages are framed as a 4-byte big-endian length followed by a MessagePack-encoded body
|
||||||
|
//! (`rmp-serde`). Transport-neutral: the same framing works on stdio, unix sockets, and TCP.
|
||||||
|
//!
|
||||||
|
//! Living in its own crate (rather than `egui_kittest`) lets eframe pull the protocol in
|
||||||
|
//! without picking up the test harness, and lets external tools depend on it directly.
|
||||||
|
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
|
||||||
|
use egui::accesskit;
|
||||||
|
|
||||||
|
/// Wire-protocol version sent in [`PeerHello::protocol_version`]. Bump whenever a
|
||||||
|
/// non-additive change is made to [`HarnessMessage`] / [`InspectorCommand`] / their
|
||||||
|
/// payload structs. The inspector should refuse peers with a higher major version than
|
||||||
|
/// it understands.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// What kind of egui peer the inspector is talking to. Determines which controls the
|
||||||
|
/// inspector UI should render (Step / Pause buttons make no sense against a live app).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum PeerKind {
|
||||||
|
/// A deterministic `egui_kittest::Harness` — supports stepping, pause/play, panic
|
||||||
|
/// capture, and source highlighting.
|
||||||
|
Kittest,
|
||||||
|
/// A live `eframe` app running [`crate::InspectionPlugin`] — no deterministic run
|
||||||
|
/// loop, no panic capture, no source view.
|
||||||
|
Live,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which optional [`InspectorCommand`] variants the peer honors. The inspector should
|
||||||
|
/// hide / disable UI for commands whose capability is `false`.
|
||||||
|
///
|
||||||
|
/// `Handle` is always supported (no flag) — every peer accepts event injection.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Capabilities {
|
||||||
|
/// Peer honors [`InspectorCommand::Step`].
|
||||||
|
pub step: bool,
|
||||||
|
/// Peer honors [`InspectorCommand::Run`].
|
||||||
|
pub run: bool,
|
||||||
|
/// Peer honors [`InspectorCommand::Play`] / [`InspectorCommand::Pause`].
|
||||||
|
pub play_pause: bool,
|
||||||
|
/// Peer honors [`InspectorCommand::Screenshot`].
|
||||||
|
pub screenshot: bool,
|
||||||
|
/// Peer honors [`InspectorCommand::SetContinuousScreenshots`] — i.e. it can be asked to
|
||||||
|
/// attach a fresh [`FrameScreenshot`] to every outgoing [`Frame`] until told to stop.
|
||||||
|
pub continuous_screenshots: bool,
|
||||||
|
/// Peer honors [`InspectorCommand::Resize`].
|
||||||
|
pub resize: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capabilities {
|
||||||
|
/// Capabilities of a deterministic kittest harness: all execution-control commands plus
|
||||||
|
/// both one-shot and continuous screenshot modes. The harness ships with continuous on
|
||||||
|
/// by default (matching the pre-flag behavior of always-fresh frames); the inspector
|
||||||
|
/// can flip it off via [`InspectorCommand::SetContinuousScreenshots`]`(false)` to skip
|
||||||
|
/// the per-step render cost when it only needs the accesskit tree.
|
||||||
|
pub const KITTEST: Self = Self {
|
||||||
|
step: true,
|
||||||
|
run: true,
|
||||||
|
play_pause: true,
|
||||||
|
screenshot: true,
|
||||||
|
continuous_screenshots: true,
|
||||||
|
resize: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Capabilities of a live `eframe` app: no execution-control (no own run loop), but
|
||||||
|
/// the integration honors viewport-level screenshot and resize requests, and the
|
||||||
|
/// plugin can be flipped into per-frame screenshot mode.
|
||||||
|
pub const LIVE: Self = Self {
|
||||||
|
step: false,
|
||||||
|
run: false,
|
||||||
|
play_pause: false,
|
||||||
|
screenshot: true,
|
||||||
|
continuous_screenshots: true,
|
||||||
|
resize: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First [`HarnessMessage`] sent on every connection. Identifies the peer and declares
|
||||||
|
/// which optional commands it will honor. The inspector should treat the absence of a
|
||||||
|
/// `Hello` (i.e. a `Frame` arriving first) as a protocol error.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PeerHello {
|
||||||
|
/// [`PROTOCOL_VERSION`] of the peer.
|
||||||
|
pub protocol_version: u32,
|
||||||
|
pub peer_kind: PeerKind,
|
||||||
|
pub capabilities: Capabilities,
|
||||||
|
/// Whether the peer starts in continuous-screenshot mode (i.e. attaches a
|
||||||
|
/// [`FrameScreenshot`] to every `Frame` until told otherwise). Inspectors should treat
|
||||||
|
/// this as the authoritative initial state rather than relying on per-peer defaults.
|
||||||
|
/// Only meaningful when [`Capabilities::continuous_screenshots`] is `true`.
|
||||||
|
pub continuous_screenshots: bool,
|
||||||
|
/// Human-readable identifier (test name, app name). Replaces the per-`Frame` label.
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One source file plus the test-source lines the inspector should highlight inside it.
|
||||||
|
///
|
||||||
|
/// The harness captures `#[track_caller]` locations for the `.run()`/`.step()` call that
|
||||||
|
/// produced the frame and for each event consumed by it. The inspector highlights
|
||||||
|
/// [`Self::call_site_line`] for the runner call and [`Self::event_lines`] for each event.
|
||||||
|
///
|
||||||
|
/// Only populated by `egui_kittest`. Live apps (via [`crate::InspectionPlugin`]) leave this
|
||||||
|
/// `None` on every [`Frame`] — they have no test source to point at.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct SourceView {
|
||||||
|
/// Absolute or crate-relative path as reported by `std::panic::Location::file`.
|
||||||
|
pub path: String,
|
||||||
|
/// Entire file contents, lines separated by `\n`. `None` if the file couldn't be read.
|
||||||
|
pub contents: Option<String>,
|
||||||
|
/// Line number of the `.run()` / `.step()` call that produced this frame.
|
||||||
|
pub call_site_line: Option<u32>,
|
||||||
|
/// Line numbers of events consumed by this frame's step, in queue order.
|
||||||
|
pub event_lines: Vec<u32>,
|
||||||
|
/// Line number of a panic captured in this file. The inspector highlights this line in
|
||||||
|
/// red. Set on the [`HarnessMessage::Finished`] source view when a panic was captured.
|
||||||
|
pub panic_line: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendered framebuffer attached to a [`Frame`]. Absent on accesskit-only frames (live
|
||||||
|
/// apps default to "tree-only" until the inspector asks for screenshots).
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct FrameScreenshot {
|
||||||
|
/// Image width in physical pixels.
|
||||||
|
pub width: u32,
|
||||||
|
/// Image height in physical pixels.
|
||||||
|
pub height: u32,
|
||||||
|
/// Tightly packed RGBA8 pixels (length = `width * height * 4`). `serde_bytes` encodes
|
||||||
|
/// this as a msgpack `bin` blob (one type tag + raw bytes) instead of the default
|
||||||
|
/// `Vec<u8>` path of one type tag *per byte*, which would roughly double on-wire size.
|
||||||
|
#[serde(with = "serde_bytes")]
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single update from the egui peer: accesskit tree + optional screenshot.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Frame {
|
||||||
|
/// Monotonically increasing step counter.
|
||||||
|
pub step: u64,
|
||||||
|
/// `physical_pixel = logical_point * pixels_per_point`. AccessKit bounds are in logical
|
||||||
|
/// coords, the screenshot is in physical pixels — multiply by this to align them.
|
||||||
|
pub pixels_per_point: f32,
|
||||||
|
/// Rendered framebuffer for this step, when available. `None` for live-app frames
|
||||||
|
/// outside continuous-screenshot mode that didn't receive an `Event::Screenshot` reply
|
||||||
|
/// (i.e. accesskit-only updates). Kittest harnesses populate this on every frame.
|
||||||
|
pub screenshot: Option<FrameScreenshot>,
|
||||||
|
/// Latest accesskit tree update, if any.
|
||||||
|
pub accesskit: Option<accesskit::TreeUpdate>,
|
||||||
|
/// The test source file associated with this frame + the lines to highlight inside it.
|
||||||
|
/// `None` for live apps.
|
||||||
|
pub source: Option<SourceView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sent egui-peer → inspector. Always begins with a single [`Self::Hello`]. After that,
|
||||||
|
/// frames carry rendered images; `Blocked` signals when the harness's blocking state
|
||||||
|
/// changes without a visual update (e.g. at `after_run`, where nothing has re-rendered
|
||||||
|
/// since the last `after_step`). Live apps never send `Blocked`.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum HarnessMessage {
|
||||||
|
/// Identifies the peer and declares its capabilities. Sent exactly once, as the very
|
||||||
|
/// first message on the connection, before any [`Self::Frame`].
|
||||||
|
Hello(PeerHello),
|
||||||
|
/// A new frame (image + tree + source) is available.
|
||||||
|
Frame(Box<Frame>),
|
||||||
|
/// The peer is now either blocked (`true`) waiting for an [`InspectorCommand`], or
|
||||||
|
/// running freely (`false`).
|
||||||
|
Blocked(bool),
|
||||||
|
/// The test has ended. Implies [`Self::Blocked`]`(true)`: the harness blocks after
|
||||||
|
/// sending this, and any subsequent `Step` / `Run` / `Play` command dismisses the result
|
||||||
|
/// and lets the harness drop.
|
||||||
|
///
|
||||||
|
/// Live apps never send this.
|
||||||
|
Finished {
|
||||||
|
/// `true` on pass; `false` if a panic was in progress when the harness dropped.
|
||||||
|
ok: bool,
|
||||||
|
/// Panic message, if captured (requires `egui_kittest::install_panic_hook()`).
|
||||||
|
message: Option<String>,
|
||||||
|
/// Final-frame source context: the test entry point's file, with the panic line (if
|
||||||
|
/// any and if it matches that file) recorded in [`SourceView::panic_line`].
|
||||||
|
source: Option<SourceView>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sent inspector → egui peer at any time to drive execution.
|
||||||
|
///
|
||||||
|
/// `egui_kittest` blocks at `after_step` / `after_run` hooks (and at those hooks only).
|
||||||
|
/// Which command it waits for, and whether it returns to blocking after executing one,
|
||||||
|
/// depends on the command that last arrived — see each variant's docs.
|
||||||
|
///
|
||||||
|
/// Live apps (via [`crate::InspectionPlugin`]) treat `Step` / `Run` / `Play` / `Pause` as
|
||||||
|
/// no-ops — they don't own a deterministic run loop. `Handle` is honored on the next frame.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum InspectorCommand {
|
||||||
|
/// Advance one frame, then block at the next `after_step`.
|
||||||
|
Step,
|
||||||
|
/// Run until the next `after_run` hook fires, then block.
|
||||||
|
Run,
|
||||||
|
/// Run freely until a [`Self::Pause`], [`Self::Step`], or [`Self::Run`] command arrives.
|
||||||
|
/// Frames keep streaming while playing — the inspector may send [`Self::Handle`] at any
|
||||||
|
/// point without interrupting play.
|
||||||
|
Play,
|
||||||
|
/// Cancel [`Self::Play`] (no-op when already blocked).
|
||||||
|
Pause,
|
||||||
|
/// Queue these events on the peer and run a single step. Does not change the peer's
|
||||||
|
/// Pause / Play / Run state.
|
||||||
|
Handle { events: Vec<egui::Event> },
|
||||||
|
/// Request a full-framebuffer screenshot for the next frame.
|
||||||
|
///
|
||||||
|
/// Live apps (via [`crate::InspectionPlugin`]) issue a
|
||||||
|
/// [`egui::ViewportCommand::Screenshot`], intercept the resulting
|
||||||
|
/// [`egui::Event::Screenshot`], and emit a [`HarnessMessage::Frame`] with
|
||||||
|
/// [`Frame::screenshot`] populated. The deterministic kittest path already attaches a
|
||||||
|
/// screenshot to every frame, so it treats this as a no-op.
|
||||||
|
Screenshot,
|
||||||
|
/// Toggle continuous screenshot mode. While `true`, the peer attaches a fresh
|
||||||
|
/// [`FrameScreenshot`] to every outgoing [`Frame`] until told otherwise. Useful for
|
||||||
|
/// inspectors that always want a current image (mirror the app's window) without
|
||||||
|
/// having to issue per-step [`Self::Screenshot`] requests.
|
||||||
|
///
|
||||||
|
/// Kittest harnesses ignore this (they already screenshot every frame).
|
||||||
|
SetContinuousScreenshots(bool),
|
||||||
|
/// Resize the peer's viewport / harness to the given logical-point dimensions.
|
||||||
|
///
|
||||||
|
/// Live apps issue a [`egui::ViewportCommand::InnerSize`]. The deterministic kittest
|
||||||
|
/// path calls `Harness::set_size`.
|
||||||
|
Resize { width: u32, height: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard cap on a single framed message. Matches the sanity limit enforced by both ends.
|
||||||
|
pub const MAX_MESSAGE_BYTES: usize = 256 * 1024 * 1024; // 256 MiB
|
||||||
|
|
||||||
|
/// Read a length-prefixed MessagePack 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)?;
|
||||||
|
rmp_serde::from_slice(&buf)
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a length-prefixed MessagePack 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 bytes = rmp_serde::to_vec(value)
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user