1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-30 21:30:03 -04:00

Add egui_kittest_mcp server

New binary crate that exposes an MCP (Model Context Protocol) server backed by
the `egui_inspection` protocol. The server bridges a running egui peer — a
spawned `egui_kittest` harness child process or an attached live `eframe` app —
to MCP tool handlers that drive it.

Components:
- `bridge.rs`: spawns / attaches a peer over a unix socket, runs reader+writer
  Tokio tasks that pump `HarnessMessage` ↔ `InspectorCommand` and track the
  peer's `Hello`, latest frame, accesskit tree, and blocked / finished state.
- `tools.rs`: `rmcp`-derived tool router with commands for stepping, event
  injection (click / type / scroll / hover / drag / keys), resizing, screenshot
  capture, accesskit tree queries, and lifecycle (launch / attach / kill).
- `tree.rs`: accesskit-tree projection helpers shared by the tools.
- `shim.rs` / `main.rs`: shim role that lets the same binary act as the child
  inspector for kittest harnesses, relaying bytes between the harness stdio
  and the MCP server's unix socket.
- `server.rs`: rmcp stdio entry point.

Live-app example added at `examples/egui_mcp/`.
This commit is contained in:
lucasmerlin
2026-05-21 12:11:00 +02:00
parent e3bdddf306
commit e15ba138d6
11 changed files with 2553 additions and 9 deletions

View File

@@ -0,0 +1,49 @@
[package]
name = "egui_kittest_mcp"
version.workspace = true
authors = ["Lucas Meurer <hi@lucasmerlin.me>"]
description = "MCP server that drives egui apps via the kittest inspector protocol"
edition.workspace = true
rust-version.workspace = true
homepage = "https://github.com/emilk/egui"
license.workspace = true
repository = "https://github.com/emilk/egui"
categories = ["gui", "development-tools::testing"]
keywords = ["egui", "kittest", "mcp", "testing", "accesskit"]
publish = false
[[bin]]
name = "kittest-mcp"
path = "src/main.rs"
[dependencies]
egui_kittest = { workspace = true, features = ["inspector_api", "wgpu", "snapshot"] }
egui_inspection = { workspace = true, features = ["protocol"] }
egui.workspace = true
accesskit.workspace = true
accesskit_consumer.workspace = true
image = { workspace = true, features = ["png"] }
rmp-serde.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json = "1.0"
schemars = "1.0"
rmcp = { version = "1.7", features = ["server", "macros", "transport-io", "schemars"] }
tempfile.workspace = true
tokio = { version = "1.49", features = [
"rt-multi-thread",
"io-std",
"io-util",
"process",
"net",
"sync",
"macros",
"time",
"signal",
] }
base64 = "0.22"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[lints]
workspace = true

View File

@@ -0,0 +1,435 @@
//! Bridge between the MCP server and a running kittest harness child process.
//!
//! Lifecycle:
//! 1. [`Bridge::launch`] binds a unix domain socket, spawns the target binary with
//! [`crate::HANDSHAKE_ENV_VAR`] + `KITTEST_INSPECTOR=1` +
//! `KITTEST_INSPECTOR_PATH=<self>`, and waits for the shim to connect.
//! 2. A reader task decodes [`HarnessMessage`]s from the socket and updates [`SharedState`].
//! 3. A writer task drains [`InspectorCommand`]s queued by MCP tool handlers and writes
//! them to the socket.
//! 4. Tool handlers observe [`SharedState`] via [`Bridge::snapshot`] and wait for new
//! frames or `Finished` via [`Bridge::wait_for_frame_after`].
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, anyhow, bail};
use egui_inspection::protocol::{Frame, HarnessMessage, InspectorCommand, SourceView};
use serde::Serialize;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::UnixListener;
use tokio::process::{Child, Command};
use tokio::sync::{Mutex, Notify, mpsc};
use tokio::task::JoinHandle;
use tokio::time::timeout;
/// Hard cap matching `inspector_api::MAX_MESSAGE_BYTES` so framing-level DoS is bounded.
const MAX_MESSAGE_BYTES: usize = 256 * 1024 * 1024;
/// One in-flight peer (a spawned kittest harness or an attached live app) + the tasks
/// that talk to it.
pub struct Bridge {
pub state: Arc<SharedState>,
/// Outgoing command queue → writer task → socket.
cmd_tx: mpsc::UnboundedSender<InspectorCommand>,
/// Tokio task handles. Aborted on `Drop`; the child is killed too.
_reader_task: JoinHandle<()>,
_writer_task: JoinHandle<()>,
/// `Child` wrapped in a `Mutex` so a `kill` tool can take it. `None` in attach mode —
/// we don't own the lifecycle of an externally-started app.
child: Arc<Mutex<Option<Child>>>,
/// Temp dir holding the unix socket — kept alive while the bridge is.
_socket_dir: tempfile::TempDir,
/// How this bridge was created (informational).
pub peer_info: PeerInfo,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum PeerInfo {
/// Bridge spawned a child harness process.
Launched {
bin: PathBuf,
args: Vec<String>,
pid: u32,
},
/// Bridge bound a socket and accepted an incoming connection from a live app.
Attached { socket_path: PathBuf },
}
/// Mutable state observed by MCP tool handlers.
///
/// Guarded by a `Mutex` (not `RwLock`) because writers and readers contend on the same
/// fields and acquire-cost is dominated by the rare `Frame` arrival, not lock contention.
pub struct SharedState {
inner: Mutex<Inner>,
/// Notified whenever `inner` changes in a way a waiter might care about (new frame,
/// blocked transition, finished). Coarse-grained on purpose.
notify: Notify,
}
#[derive(Default)]
struct Inner {
/// Set on receipt of [`HarnessMessage::Hello`]. `None` until the peer connects.
pub hello: Option<egui_inspection::protocol::PeerHello>,
pub latest_frame: Option<Box<Frame>>,
pub blocked: bool,
pub finished: Option<FinishedInfo>,
/// Latest accesskit tree (re-built each time a `TreeUpdate` arrives).
pub accesskit_tree: Option<accesskit_consumer::Tree>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FinishedInfo {
pub ok: bool,
pub message: Option<String>,
pub source: Option<SourceView>,
}
/// Snapshot returned to tool handlers so they can drop the mutex before responding.
#[derive(Clone)]
pub struct StateSnapshot {
/// Peer identity + capabilities, captured at connect time. Used by tool handlers to
/// gate commands the peer doesn't honor (Step/Run/Pause against a live app, etc.).
#[expect(dead_code, reason = "consumed by upcoming capability-gating in tool handlers")]
pub hello: Option<egui_inspection::protocol::PeerHello>,
pub frame: Option<Box<Frame>>,
pub blocked: bool,
pub finished: Option<FinishedInfo>,
}
impl SharedState {
fn new() -> Arc<Self> {
Arc::new(Self {
inner: Mutex::new(Inner::default()),
notify: Notify::new(),
})
}
pub async fn snapshot(&self) -> StateSnapshot {
let g = self.inner.lock().await;
StateSnapshot {
hello: g.hello.clone(),
frame: g.latest_frame.clone(),
blocked: g.blocked,
finished: g.finished.clone(),
}
}
/// Read-only access to the accesskit tree via a closure. The tree isn't `Clone`, so
/// callers project the data they need (node list, lookup by id) before returning.
pub async fn with_tree<R>(
&self,
f: impl FnOnce(Option<&accesskit_consumer::Tree>) -> R,
) -> R {
let g = self.inner.lock().await;
f(g.accesskit_tree.as_ref())
}
/// Await the next state-change notification. Used by tools that poll (e.g. `wait_for`)
/// to wake on a new frame / blocked transition without busy-looping.
pub async fn notified(&self) {
self.notify.notified().await;
}
}
impl Bridge {
pub async fn launch(
bin: PathBuf,
args: Vec<String>,
env: Vec<(String, String)>,
cwd: Option<PathBuf>,
) -> anyhow::Result<Self> {
let self_path = std::env::current_exe()
.context("get current_exe for KITTEST_INSPECTOR_PATH")?;
let socket_dir = tempfile::Builder::new()
.prefix("kittest-mcp-")
.tempdir()
.context("create temp dir for handshake socket")?;
let socket_path = socket_dir.path().join("kittest.sock");
let listener = UnixListener::bind(&socket_path)
.with_context(|| format!("bind {}", socket_path.display()))?;
let mut cmd = Command::new(&bin);
cmd.args(&args)
.env("KITTEST_INSPECTOR", "1")
.env("KITTEST_INSPECTOR_PATH", &self_path)
.env(crate::HANDSHAKE_ENV_VAR, &socket_path)
.stdin(std::process::Stdio::null())
// Harness inspector path: the child's stdout/stderr aren't ours — they get
// captured by the shim. We don't need them in the MCP server.
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
for (k, v) in &env {
cmd.env(k, v);
}
if let Some(d) = &cwd {
cmd.current_dir(d);
}
let mut child = cmd
.spawn()
.with_context(|| format!("spawn {}", bin.display()))?;
let pid = child.id().unwrap_or(0);
// Accept with a short timeout. If the binary fails to start, exits early, or
// doesn't have the inspector wired up, we surface that instead of hanging forever.
let (stream, _addr) = match timeout(Duration::from_secs(10), listener.accept()).await {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => {
let _ = child.kill().await;
bail!("accept on handshake socket: {e}");
}
Err(_) => {
let _ = child.kill().await;
// Try to report the child's exit status if it died early.
let status_hint = match child.try_wait() {
Ok(Some(s)) => format!(" (child exited {s})"),
_ => String::new(),
};
bail!("timed out waiting for inspector handshake{status_hint}");
}
};
let (reader, writer) = stream.into_split();
let state = SharedState::new();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let child_arc: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(Some(child)));
let reader_task = tokio::spawn(read_loop(reader, state.clone(), child_arc.clone()));
let writer_task = tokio::spawn(write_loop(writer, cmd_rx));
Ok(Self {
state,
cmd_tx,
_reader_task: reader_task,
_writer_task: writer_task,
child: child_arc,
_socket_dir: socket_dir,
peer_info: PeerInfo::Launched { bin, args, pid },
})
}
/// Bind a unix socket and return the path immediately. The caller is responsible for
/// starting the app with `EGUI_INSPECTION_SOCKET` set to this path. Call
/// [`Self::accept_pending`] once the app is running.
///
/// Returns the temp-dir handle (must be kept alive) and the listener.
pub async fn prepare_attach() -> anyhow::Result<(tempfile::TempDir, UnixListener, PathBuf)> {
let socket_dir = tempfile::Builder::new()
.prefix("egui-inspection-")
.tempdir()
.context("create temp dir for inspection socket")?;
let socket_path = socket_dir.path().join("inspection.sock");
let listener = UnixListener::bind(&socket_path)
.with_context(|| format!("bind {}", socket_path.display()))?;
Ok((socket_dir, listener, socket_path))
}
/// Finish an attach started with [`Self::prepare_attach`] — wait for an inbound
/// connection and spawn the reader/writer tasks.
///
/// `child` is the optional child process that was spawned with the socket env var
/// pre-set. Passing it here lets `kill` reach it and `kill_on_drop` clean up if the
/// bridge is dropped.
pub async fn accept_pending(
socket_dir: tempfile::TempDir,
listener: UnixListener,
socket_path: PathBuf,
child: Option<Child>,
accept_timeout: Duration,
) -> anyhow::Result<Self> {
let (stream, _addr) = match timeout(accept_timeout, listener.accept()).await {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => bail!("accept on inspection socket: {e}"),
Err(_) => bail!("timed out waiting for inbound connection at {}", socket_path.display()),
};
let (reader, writer) = stream.into_split();
let state = SharedState::new();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let child_arc: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(child));
let reader_task = tokio::spawn(read_loop(reader, state.clone(), child_arc.clone()));
let writer_task = tokio::spawn(write_loop(writer, cmd_rx));
Ok(Self {
state,
cmd_tx,
_reader_task: reader_task,
_writer_task: writer_task,
child: child_arc,
_socket_dir: socket_dir,
peer_info: PeerInfo::Attached { socket_path },
})
}
pub fn send(&self, cmd: InspectorCommand) -> anyhow::Result<()> {
self.cmd_tx
.send(cmd)
.map_err(|_| anyhow!("inspector writer task is gone"))
}
/// Wait for either a new frame whose `step > prev_step`, or a `Finished` signal,
/// whichever comes first. Returns the resulting snapshot or times out.
pub async fn wait_for_frame_after(
&self,
prev_step: u64,
wait: Duration,
) -> anyhow::Result<StateSnapshot> {
let deadline = tokio::time::Instant::now() + wait;
loop {
let snap = self.state.snapshot().await;
if snap.finished.is_some() {
return Ok(snap);
}
if let Some(f) = &snap.frame {
if f.step > prev_step {
return Ok(snap);
}
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
bail!("timed out waiting for next frame after step {prev_step}");
}
let _ = timeout(remaining, self.state.notify.notified()).await;
}
}
pub async fn kill(&self) {
if let Some(mut c) = self.child.lock().await.take() {
let _ = c.kill().await;
}
}
}
impl Drop for Bridge {
fn drop(&mut self) {
// Best-effort: ensure the child is reaped. `kill_on_drop(true)` on `Command` also
// guarantees this, but we set it explicitly for the case where someone replaces the
// `Child` and forgets the flag.
if let Ok(mut g) = self.child.try_lock() {
if let Some(mut c) = g.take() {
let _ = c.start_kill();
}
}
}
}
async fn read_loop(
mut reader: tokio::net::unix::OwnedReadHalf,
state: Arc<SharedState>,
child: Arc<Mutex<Option<Child>>>,
) {
loop {
let msg = match read_message(&mut reader).await {
Ok(m) => m,
Err(e) => {
tracing::debug!("inspector socket read ended: {e}");
break;
}
};
apply_message(&state, msg).await;
}
// Reader ended → harness is gone. Make sure we eventually reap the child.
if let Some(mut c) = child.lock().await.take() {
let _ = c.kill().await;
}
// Wake any waiter so they can observe disconnection.
state.notify.notify_waiters();
}
async fn apply_message(state: &SharedState, msg: HarnessMessage) {
let mut g = state.inner.lock().await;
match msg {
HarnessMessage::Hello(hello) => {
g.hello = Some(hello);
}
HarnessMessage::Frame(frame) => {
if let Some(update) = &frame.accesskit {
let mut noop = NoopChangeHandler;
match g.accesskit_tree.as_mut() {
Some(tree) => tree.update_and_process_changes(update.clone(), &mut noop),
None => {
g.accesskit_tree =
Some(accesskit_consumer::Tree::new(update.clone(), false));
}
}
}
g.latest_frame = Some(frame);
}
HarnessMessage::Blocked(b) => g.blocked = b,
HarnessMessage::Finished {
ok,
message,
source,
} => {
g.finished = Some(FinishedInfo {
ok,
message,
source,
});
g.blocked = true;
}
}
drop(g);
state.notify.notify_waiters();
}
struct NoopChangeHandler;
impl accesskit_consumer::TreeChangeHandler for NoopChangeHandler {
fn node_added(&mut self, _: &accesskit_consumer::Node<'_>) {}
fn node_updated(
&mut self,
_: &accesskit_consumer::Node<'_>,
_: &accesskit_consumer::Node<'_>,
) {
}
fn focus_moved(
&mut self,
_: Option<&accesskit_consumer::Node<'_>>,
_: Option<&accesskit_consumer::Node<'_>>,
) {
}
fn node_removed(&mut self, _: &accesskit_consumer::Node<'_>) {}
}
async fn write_loop(
mut writer: tokio::net::unix::OwnedWriteHalf,
mut rx: mpsc::UnboundedReceiver<InspectorCommand>,
) {
while let Some(cmd) = rx.recv().await {
if let Err(e) = write_message(&mut writer, &cmd).await {
tracing::debug!("inspector socket write ended: {e}");
break;
}
}
}
async fn read_message(stream: &mut tokio::net::unix::OwnedReadHalf) -> anyhow::Result<HarnessMessage> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).await?;
let len = u32::from_be_bytes(len_buf) as usize;
if len > MAX_MESSAGE_BYTES {
bail!("message too large: {len} bytes");
}
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf).await?;
rmp_serde::from_slice(&buf).map_err(|e| anyhow!("decode: {e}"))
}
async fn write_message(
stream: &mut tokio::net::unix::OwnedWriteHalf,
msg: &InspectorCommand,
) -> anyhow::Result<()> {
let bytes = rmp_serde::to_vec(msg).map_err(|e| anyhow!("encode: {e}"))?;
let len = u32::try_from(bytes.len())?;
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
Ok(())
}

View File

@@ -0,0 +1,45 @@
//! `kittest-mcp` — dual-role binary.
//!
//! Default role: **MCP server**. Speaks MCP JSON-RPC over stdio to an agent. Exposes a
//! `launch` tool that spawns a target egui kittest binary with the inspector protocol
//! pointed back at this same executable in shim mode.
//!
//! Shim role: activated when [`HANDSHAKE_ENV_VAR`] is set. The target binary's
//! [`egui_kittest::InspectorPlugin`] thinks it's talking to the regular `kittest_inspector`
//! over stdio; in reality it's talking to us, and we relay the bytes to the MCP server
//! over a unix domain socket.
mod bridge;
mod server;
mod shim;
mod tools;
mod tree;
/// Env var carrying the unix socket path the shim should connect to.
pub const HANDSHAKE_ENV_VAR: &str = "KITTEST_MCP_HANDSHAKE";
fn main() -> anyhow::Result<()> {
if let Ok(socket_path) = std::env::var(HANDSHAKE_ENV_VAR) {
// Shim role: relay bytes between harness stdio and the MCP server's socket.
// No tokio runtime — keep the dependency surface tiny and the relay deterministic.
shim::run(&socket_path)
} else {
// Server role: MCP over stdio.
init_tracing();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(server::run())
}
}
fn init_tracing() {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_env("KITTEST_MCP_LOG")
.unwrap_or_else(|_| EnvFilter::new("kittest_mcp=info,warn"));
// stderr only — stdout is reserved for MCP JSON-RPC traffic.
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.try_init();
}

View File

@@ -0,0 +1,16 @@
//! MCP server entry point, built on the official `rmcp` SDK over stdio.
//!
//! [`run`] constructs a [`crate::tools::Server`] (which derives its tool router via
//! `#[tool_router]`) and serves it on `(stdin, stdout)`. Returns once the client closes
//! the connection (EOF on stdin) or the runtime is shut down.
use rmcp::{ServiceExt, transport};
use crate::tools::Server;
pub async fn run() -> anyhow::Result<()> {
let server = Server::new();
let running = server.serve(transport::stdio()).await?;
let _reason = running.waiting().await?;
Ok(())
}

View File

@@ -0,0 +1,50 @@
//! Inspector shim role.
//!
//! Connects to the MCP server's unix domain socket and relays bytes in both directions
//! between the harness's stdio and that socket.
//!
//! From the harness's perspective we're an ordinary `kittest_inspector` (msgpack framed
//! messages on stdin/stdout). The MCP server sees the same framed bytes on the other end of
//! the socket. We don't parse or interpret anything here — pure byte relay keeps the shim
//! independent of protocol revisions.
use std::io::{Read as _, Write as _};
use std::os::unix::net::UnixStream;
use std::thread;
pub fn run(socket_path: &str) -> anyhow::Result<()> {
let stream = UnixStream::connect(socket_path)
.map_err(|e| anyhow::anyhow!("connect {socket_path}: {e}"))?;
let stream_to_stdout = stream.try_clone()?;
let mut stdin_to_socket = stream;
// Thread A: stdin (from harness) → socket (to MCP server).
let t_in = thread::Builder::new()
.name("kittest-mcp-shim-stdin".into())
.spawn(move || {
let mut stdin = std::io::stdin().lock();
let _ = std::io::copy(&mut stdin, &mut stdin_to_socket);
// EOF on stdin or write error → shutdown write side so peer sees EOF.
let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
})?;
// Thread B: socket (from MCP server) → stdout (to harness).
// Runs on main thread so the process exits when stdout closes.
let mut stdout = std::io::stdout().lock();
let mut buf = vec![0u8; 64 * 1024];
let mut reader = stream_to_stdout;
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if stdout.write_all(&buf[..n]).is_err() {
break;
}
let _ = stdout.flush();
}
}
}
let _ = t_in.join();
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
//! Helpers that flatten the accesskit tree into MCP-friendly shapes.
//!
//! Note: `accesskit_consumer::NodeId` is a private composite (tree-index + local-id) and
//! can't be constructed from outside the crate. We project everything externally as the
//! original `accesskit::NodeId` (a `pub u64`), and look up by walking the tree.
use accesskit_consumer::{Node, Tree};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct NodeView {
/// Original `accesskit::NodeId` as a decimal string. Emitted as a string so the full
/// u64 round-trips through MCP clients whose JSON parsers go through `f64` (which
/// can't represent integers above 2^53 exactly).
pub id: String,
pub role: String,
pub label: Option<String>,
pub value: Option<String>,
pub bounds: Option<RectF>,
pub focused: bool,
pub disabled: bool,
pub hidden: bool,
pub parent_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
pub struct RectF {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
impl RectF {
fn from_rect(r: accesskit::Rect) -> Self {
Self {
x: r.x0,
y: r.y0,
w: r.x1 - r.x0,
h: r.y1 - r.y0,
}
}
pub fn center(&self) -> (f64, f64) {
(self.x + self.w / 2.0, self.y + self.h / 2.0)
}
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct QueryFilter {
pub role: Option<String>,
pub label_contains: Option<String>,
#[serde(default = "default_true")]
pub visible_only: bool,
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_true() -> bool {
true
}
fn default_limit() -> usize {
200
}
impl Default for QueryFilter {
fn default() -> Self {
Self {
role: None,
label_contains: None,
visible_only: true,
limit: default_limit(),
}
}
}
pub fn query(tree: &Tree, filter: &QueryFilter) -> Vec<NodeView> {
let root = tree.state().root();
let mut out = Vec::new();
walk(&root, filter, &mut out);
if out.len() > filter.limit {
out.truncate(filter.limit);
}
out
}
fn walk(node: &Node<'_>, filter: &QueryFilter, out: &mut Vec<NodeView>) {
if matches(node, filter) {
out.push(node_view(node));
}
for child in node.children() {
walk(&child, filter, out);
}
}
fn matches(node: &Node<'_>, filter: &QueryFilter) -> bool {
if filter.visible_only && node.is_hidden() {
return false;
}
if let Some(role) = &filter.role
&& !role.eq_ignore_ascii_case(&format!("{:?}", node.role()))
{
return false;
}
if let Some(needle) = &filter.label_contains {
let hay = node.label().unwrap_or_default();
if !hay
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
{
return false;
}
}
true
}
pub fn node_view(node: &Node<'_>) -> NodeView {
NodeView {
id: accesskit_id(node).to_string(),
role: format!("{:?}", node.role()),
label: node.label(),
value: node.value(),
bounds: node.bounding_box().map(RectF::from_rect),
focused: node.is_focused_in_tree(),
disabled: node.is_disabled(),
hidden: node.is_hidden(),
parent_id: node.parent().map(|p| accesskit_id(&p).to_string()),
}
}
/// Project a consumer node to its original `accesskit::NodeId` as a `u64`.
fn accesskit_id(node: &Node<'_>) -> u64 {
let (local, _tree) = node.locate();
local.0
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Locator {
Id {
/// Decimal string. Strings preserve the full u64 — JSON numbers above 2^53 lose
/// precision in clients whose parsers go through `f64`, so we don't accept them.
#[serde(deserialize_with = "deserialize_u64_from_string")]
id: u64,
},
Match {
#[serde(default)]
role: Option<String>,
#[serde(default)]
label_contains: Option<String>,
},
}
fn deserialize_u64_from_string<'de, D>(d: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let s = String::deserialize(d)?;
s.trim().parse::<u64>().map_err(D::Error::custom)
}
pub fn resolve_node<'a>(tree: &'a Tree, locator: &Locator) -> Option<Node<'a>> {
match locator {
Locator::Id { id } => find_by_id(&tree.state().root(), *id),
Locator::Match {
role,
label_contains,
} => {
let filter = QueryFilter {
role: role.clone(),
label_contains: label_contains.clone(),
visible_only: true,
limit: 1,
};
let root = tree.state().root();
first_match(&root, &filter)
}
}
}
fn find_by_id<'a>(node: &Node<'a>, target: u64) -> Option<Node<'a>> {
if accesskit_id(node) == target {
return Some(*node);
}
for child in node.children() {
if let Some(found) = find_by_id(&child, target) {
return Some(found);
}
}
None
}
fn first_match<'a>(node: &Node<'a>, filter: &QueryFilter) -> Option<Node<'a>> {
if matches(node, filter) {
return Some(*node);
}
for child in node.children() {
if let Some(found) = first_match(&child, filter) {
return Some(found);
}
}
None
}