1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Show source code location in inspector

This commit is contained in:
lucasmerlin
2026-04-20 14:01:30 +02:00
parent b7da254b16
commit e07a169ee3
7 changed files with 367 additions and 18 deletions

View File

@@ -1392,6 +1392,7 @@ dependencies = [
name = "egui_kittest"
version = "0.34.1"
dependencies = [
"backtrace",
"dify",
"document-features",
"eframe",

View File

@@ -30,7 +30,7 @@ snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"]
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"]
inspector = ["dep:image", "dep:kittest_inspector", "dep:backtrace"]
## Allows testing eframe::App
eframe = ["dep:eframe", "eframe/accesskit"]
@@ -58,6 +58,7 @@ dify = { workspace = true, optional = true }
# inspector dependencies
kittest_inspector = { workspace = true, default-features = false, optional = true }
backtrace = { workspace = true, optional = true }
# Enable this when generating docs.
document-features = { workspace = true, optional = true }

View File

@@ -4,15 +4,20 @@
//! 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::collections::HashMap;
use std::io::{BufReader, BufWriter, Write as _};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::LazyLock;
use egui::accesskit;
use egui::mutex::Mutex;
use kittest_inspector::{
Frame, HarnessMessage, InspectorReply, read_message, write_message,
Frame, HarnessMessage, InspectorReply, SourceView, read_message, write_message,
};
use crate::node::EventSite;
/// Environment variable: when set to a truthy value, every harness auto-launches an inspector.
pub const INSPECTOR_ENV_VAR: &str = "KITTEST_INSPECTOR";
@@ -98,6 +103,8 @@ impl Inspector {
image: &image::RgbaImage,
pixels_per_point: f32,
accesskit: Option<accesskit::TreeUpdate>,
call_site: &EventSite,
event_sites: &[EventSite],
) -> Vec<egui::Event> {
if self.broken {
return Vec::new();
@@ -111,8 +118,11 @@ impl Inspector {
rgba: image.as_raw().clone(),
accesskit,
label: self.label.clone(),
source: build_source_view(call_site, event_sites),
};
if let Err(err) = write_message(&mut self.writer, &HarnessMessage::Frame(frame)) {
if let Err(err) =
write_message(&mut self.writer, &HarnessMessage::Frame(Box::new(frame)))
{
#[expect(clippy::print_stderr)]
{
eprintln!("egui_kittest inspector: send failed: {err}");
@@ -148,6 +158,118 @@ impl Drop for Inspector {
}
}
/// Build the [`SourceView`] payload for a frame: find the topmost test-source file common to
/// the runner call (`call_site`) and all consumed events, then read that file once and record
/// each event's line inside it.
fn build_source_view(call_site: &EventSite, event_sites: &[EventSite]) -> Option<SourceView> {
let call_frames = call_site.as_deref().map(user_frames);
let event_frames: Vec<Vec<UserFrame>> = event_sites
.iter()
.map(|s| s.as_deref().map(user_frames).unwrap_or_default())
.collect();
// Build the list of candidate-source frame vecs. The call site is required — without it
// there's nothing anchoring the "frame producer".
let call_frames = call_frames?;
if call_frames.is_empty() {
return None;
}
// Pick the topmost file (latest in outer-most order) that appears in every non-empty
// event stack as well as the call-site stack. Ignore completely-empty event stacks
// (e.g. events driven by the inspector itself).
let non_empty_events: Vec<&Vec<UserFrame>> =
event_frames.iter().filter(|v| !v.is_empty()).collect();
let path = pick_common_file(&call_frames, &non_empty_events)?;
let call_site_line = innermost_line_for(&call_frames, &path);
let event_lines: Vec<u32> = event_frames
.iter()
.filter_map(|frames| innermost_line_for(frames, &path))
.collect();
Some(SourceView {
path: path.clone(),
contents: read_source_file(&path),
call_site_line,
event_lines,
})
}
/// One resolved user-code frame (file + line).
#[derive(Debug, Clone)]
struct UserFrame {
file: String,
line: u32,
}
/// Resolve a backtrace and return its user-code frames, innermost first.
fn user_frames(bt: &backtrace::Backtrace) -> Vec<UserFrame> {
let mut bt = bt.clone();
bt.resolve();
let mut out = Vec::new();
for frame in bt.frames() {
for symbol in frame.symbols() {
let Some(path) = symbol.filename() else { continue };
let Some(line) = symbol.lineno() else { continue };
let path = path.to_string_lossy().into_owned();
if !is_user_code(&path) {
continue;
}
out.push(UserFrame { file: path, line });
break;
}
}
out
}
/// A frame's file is "user code" if it isn't inside the Rust toolchain, a cargo registry
/// dependency, or the `egui_kittest` / `kittest_inspector` crates themselves. This keeps the
/// common-file search honest: we skip past the harness's own plumbing.
fn is_user_code(path: &str) -> bool {
const EXCLUDE: &[&str] = &[
"/rustc/",
"/toolchains/",
"/.cargo/registry/",
"/.cargo/git/",
"egui_kittest/src/",
"kittest_inspector/src/",
];
!EXCLUDE.iter().any(|needle| path.contains(needle))
}
/// Among files common to `call_frames` and every stack in `event_frames`, pick the one that
/// is **outermost** (furthest from the event origin) in the call-site stack. Intuition: the
/// outermost common file is the test function itself; inner ones are helpers.
fn pick_common_file(call_frames: &[UserFrame], event_frames: &[&Vec<UserFrame>]) -> Option<String> {
// Walk the call-site stack outermost-first.
for frame in call_frames.iter().rev() {
if event_frames
.iter()
.all(|frames| frames.iter().any(|f| f.file == frame.file))
{
return Some(frame.file.clone());
}
}
None
}
/// Return the line number of the innermost frame in `frames` whose file matches `path`.
fn innermost_line_for(frames: &[UserFrame], path: &str) -> Option<u32> {
frames.iter().find(|f| f.file == path).map(|f| f.line)
}
/// Read the full contents of a source file, cached per path (including negative results).
fn read_source_file(path: &str) -> Option<String> {
static CACHE: LazyLock<Mutex<HashMap<String, Option<String>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
let mut cache = CACHE.lock();
cache
.entry(path.to_owned())
.or_insert_with(|| std::fs::read_to_string(path).ok())
.clone()
}
/// Read [`INSPECTOR_ENV_VAR`] once and cache.
pub(crate) fn env_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

View File

@@ -105,6 +105,13 @@ pub struct Harness<'a, State = ()> {
inspector: Option<inspector::Inspector>,
#[cfg(feature = "inspector")]
last_accesskit_update: Option<egui::accesskit::TreeUpdate>,
/// Backtrace captured at the most recent public runner call (e.g. `.run()` / `.step()`).
/// Used to find the topmost common test-source file across the call and its events.
#[cfg(feature = "inspector")]
current_call_site: node::EventSite,
/// Backtraces of events consumed in the step that produced the current frame.
#[cfg(feature = "inspector")]
consumed_event_sites: Vec<node::EventSite>,
}
impl<State> Debug for Harness<'_, State> {
@@ -200,6 +207,10 @@ impl<'a, State> Harness<'a, State> {
inspector: None,
#[cfg(feature = "inspector")]
last_accesskit_update: None,
#[cfg(feature = "inspector")]
current_call_site: node::empty_site(),
#[cfg(feature = "inspector")]
consumed_event_sites: Vec::new(),
};
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
harness.run_ok();
@@ -296,17 +307,30 @@ impl<'a, State> Harness<'a, State> {
/// Run a frame for each queued event (or a single frame if there are no events).
/// This will call the app closure with each queued event and
/// update the Harness.
#[track_caller]
pub fn step(&mut self) {
#[cfg(feature = "inspector")]
{
self.current_call_site = node::capture_site();
}
let events = std::mem::take(&mut *self.queued_events.lock());
if events.is_empty() {
#[cfg(feature = "inspector")]
self.consumed_event_sites.clear();
self._step(false);
}
for event in events {
#[cfg(feature = "inspector")]
self.consumed_event_sites.clear();
match event {
EventType::Event(event) => {
EventType::Event(event, _site) => {
#[cfg(feature = "inspector")]
self.consumed_event_sites.push(_site);
self.input.events.push(event);
}
EventType::Modifiers(modifiers) => {
EventType::Modifiers(modifiers, _site) => {
#[cfg(feature = "inspector")]
self.consumed_event_sites.push(_site);
self.input.modifiers = modifiers;
}
}
@@ -376,7 +400,12 @@ impl<'a, State> Harness<'a, State> {
/// Resize the test harness to fit the contents. This only works when creating the Harness via
/// [`Harness::new_ui`] / [`Harness::new_ui_state`] or
/// [`HarnessBuilder::build_ui`] / [`HarnessBuilder::build_ui_state`].
#[track_caller]
pub fn fit_contents(&mut self) {
#[cfg(feature = "inspector")]
{
self.current_call_site = node::capture_site();
}
self._step(true);
// Calculate size including all content (main UI + popups + tooltips)
@@ -405,6 +434,10 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::run_steps`].
#[track_caller]
pub fn run(&mut self) -> u64 {
#[cfg(feature = "inspector")]
{
self.current_call_site = node::capture_site();
}
match self.try_run() {
Ok(steps) => steps,
Err(err) => {
@@ -457,7 +490,12 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::step`].
/// - [`Harness::run_steps`].
/// - [`Harness::try_run_realtime`].
#[track_caller]
pub fn try_run(&mut self) -> Result<u64, ExceededMaxStepsError> {
#[cfg(feature = "inspector")]
{
self.current_call_site = node::capture_site();
}
self._try_run(false)
}
@@ -474,7 +512,12 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::step`].
/// - [`Harness::run_steps`].
/// - [`Harness::try_run_realtime`].
#[track_caller]
pub fn run_ok(&mut self) -> Option<u64> {
#[cfg(feature = "inspector")]
{
self.current_call_site = node::capture_site();
}
self.try_run().ok()
}
@@ -497,13 +540,23 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::step`].
/// - [`Harness::run_steps`].
/// - [`Harness::try_run`].
#[track_caller]
pub fn try_run_realtime(&mut self) -> Result<u64, ExceededMaxStepsError> {
#[cfg(feature = "inspector")]
{
self.current_call_site = node::capture_site();
}
self._try_run(true)
}
/// Run a number of steps.
/// Equivalent to calling [`Harness::step`] x times.
#[track_caller]
pub fn run_steps(&mut self, steps: usize) {
#[cfg(feature = "inspector")]
{
self.current_call_site = node::capture_site();
}
for _ in 0..steps {
self.step();
}
@@ -541,7 +594,9 @@ impl<'a, State> Harness<'a, State> {
/// Queue an event to be processed in the next frame.
pub fn event(&self, event: egui::Event) {
self.queued_events.lock().push(EventType::Event(event));
self.queued_events
.lock()
.push(EventType::Event(event, node::capture_site()));
}
/// Queue an event with modifiers.
@@ -549,17 +604,18 @@ impl<'a, State> Harness<'a, State> {
/// Queues the modifiers to be pressed, then the event, then the modifiers to be released.
pub fn event_modifiers(&self, event: egui::Event, modifiers: Modifiers) {
let mut queue = self.queued_events.lock();
queue.push(EventType::Modifiers(modifiers));
queue.push(EventType::Event(event));
queue.push(EventType::Modifiers(Modifiers::default()));
queue.push(EventType::Modifiers(modifiers, node::capture_site()));
queue.push(EventType::Event(event, node::capture_site()));
queue.push(EventType::Modifiers(Modifiers::default(), node::capture_site()));
}
fn modifiers(&self, modifiers: Modifiers) {
self.queued_events
.lock()
.push(EventType::Modifiers(modifiers));
.push(EventType::Modifiers(modifiers, node::capture_site()));
}
#[track_caller]
pub fn key_down(&self, key: egui::Key) {
self.event(egui::Event::Key {
key,
@@ -570,6 +626,7 @@ impl<'a, State> Harness<'a, State> {
});
}
#[track_caller]
pub fn key_down_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
self.event_modifiers(
egui::Event::Key {
@@ -583,6 +640,7 @@ impl<'a, State> Harness<'a, State> {
);
}
#[track_caller]
pub fn key_up(&self, key: egui::Key) {
self.event(egui::Event::Key {
key,
@@ -593,6 +651,7 @@ impl<'a, State> Harness<'a, State> {
});
}
#[track_caller]
pub fn key_up_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
self.event_modifiers(
egui::Event::Key {
@@ -613,6 +672,7 @@ impl<'a, State> Harness<'a, State> {
/// - Press [`Key::B`]
/// - Release [`Key::B`]
/// - Release [`Key::A`]
#[track_caller]
pub fn key_combination(&self, keys: &[Key]) {
for key in keys {
self.key_down(*key);
@@ -631,6 +691,7 @@ impl<'a, State> Harness<'a, State> {
/// - Release [`Key::B`]
/// - Release [`Key::A`]
/// - Release [`Modifiers::COMMAND`]
#[track_caller]
pub fn key_combination_modifiers(&self, modifiers: Modifiers, keys: &[Key]) {
self.modifiers(modifiers);
@@ -652,6 +713,7 @@ impl<'a, State> Harness<'a, State> {
/// Press a key.
///
/// This will create a key down event and a key up event.
#[track_caller]
pub fn key_press(&self, key: egui::Key) {
self.key_combination(&[key]);
}
@@ -663,16 +725,19 @@ impl<'a, State> Harness<'a, State> {
/// - create a key down event
/// - create a key up event
/// - reset the modifiers
#[track_caller]
pub fn key_press_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
self.key_combination_modifiers(modifiers, &[key]);
}
/// Move mouse cursor to this position.
#[track_caller]
pub fn hover_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerMoved(pos));
}
/// Start dragging from a position.
#[track_caller]
pub fn drag_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerButton {
pos,
@@ -683,6 +748,7 @@ impl<'a, State> Harness<'a, State> {
}
/// Stop dragging and remove cursor.
#[track_caller]
pub fn drop_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerButton {
pos,
@@ -699,6 +765,7 @@ impl<'a, State> Harness<'a, State> {
///
/// If you click a button and then take a snapshot, the button will be shown as hovered.
/// If you don't want that, you can call this method after clicking.
#[track_caller]
pub fn remove_cursor(&self) {
self.event(egui::Event::PointerGone);
}
@@ -848,8 +915,10 @@ impl<'a, State> Harness<'a, State> {
};
let tree = self.last_accesskit_update.clone();
let ppp = self.ctx.pixels_per_point();
let call_site = self.current_call_site.clone();
let event_sites: Vec<_> = self.consumed_event_sites.clone();
let events = if let Some(inspector) = self.inspector.as_mut() {
inspector.send_step(&image, ppp, tree)
inspector.send_step(&image, ppp, tree, &call_site, &event_sites)
} else {
return;
};
@@ -860,6 +929,8 @@ impl<'a, State> Harness<'a, State> {
for event in events {
self.input.events.push(event);
}
// Events driven by the inspector itself don't have a test-source location.
self.consumed_event_sites.clear();
self._step_inner(false);
#[cfg(feature = "recording")]
self.capture_frame_if_recording(false);

View File

@@ -4,9 +4,36 @@ use egui::{Modifiers, PointerButton, Pos2, accesskit};
use kittest::{AccessKitNode, NodeT, debug_fmt_node};
use std::fmt::{Debug, Formatter};
/// Source-location info stashed alongside queued events. We store a runtime backtrace so the
/// inspector can walk *past* non-`#[track_caller]` helper functions and find the common
/// test-source file that all events came from. Zero-cost when the `inspector` feature is off.
#[cfg(feature = "inspector")]
pub(crate) type EventSite = Option<Box<backtrace::Backtrace>>;
#[cfg(not(feature = "inspector"))]
pub(crate) type EventSite = ();
/// Capture a backtrace at the call site. Unresolved so capture is cheap (~microseconds);
/// resolution happens lazily when we actually ship the frame to the inspector.
#[cfg(feature = "inspector")]
#[expect(clippy::unnecessary_wraps)] // Option<_> is the shape of EventSite by design.
pub(crate) fn capture_site() -> EventSite {
Some(Box::new(backtrace::Backtrace::new_unresolved()))
}
#[cfg(not(feature = "inspector"))]
pub(crate) fn capture_site() -> EventSite {}
/// The "empty" value for an [`EventSite`] — used as a default when no location has been
/// captured yet (e.g. `Harness` construction). Zero-cost when the feature is off.
#[cfg(feature = "inspector")]
pub(crate) fn empty_site() -> EventSite {
None
}
#[cfg(not(feature = "inspector"))]
pub(crate) fn empty_site() -> EventSite {}
pub(crate) enum EventType {
Event(egui::Event),
Modifiers(Modifiers),
Event(egui::Event, EventSite),
Modifiers(Modifiers, EventSite),
}
pub(crate) type EventQueue = Mutex<Vec<EventType>>;
@@ -38,26 +65,34 @@ impl<'tree> NodeT<'tree> for Node<'tree> {
impl Node<'_> {
fn event(&self, event: egui::Event) {
self.queue.lock().push(EventType::Event(event));
self.queue
.lock()
.push(EventType::Event(event, capture_site()));
}
fn modifiers(&self, modifiers: Modifiers) {
self.queue.lock().push(EventType::Modifiers(modifiers));
self.queue
.lock()
.push(EventType::Modifiers(modifiers, capture_site()));
}
#[track_caller]
pub fn hover(&self) {
self.event(egui::Event::PointerMoved(self.rect().center()));
}
/// Click at the node center with the primary button.
#[track_caller]
pub fn click(&self) {
self.click_button(PointerButton::Primary);
}
#[track_caller]
pub fn click_secondary(&self) {
self.click_button(PointerButton::Secondary);
}
#[track_caller]
pub fn click_button(&self, button: PointerButton) {
self.hover();
for pressed in [true, false] {
@@ -70,10 +105,12 @@ impl Node<'_> {
}
}
#[track_caller]
pub fn click_modifiers(&self, modifiers: Modifiers) {
self.click_button_modifiers(PointerButton::Primary, modifiers);
}
#[track_caller]
pub fn click_button_modifiers(&self, button: PointerButton, modifiers: Modifiers) {
self.hover();
self.modifiers(modifiers);
@@ -92,6 +129,7 @@ impl Node<'_> {
///
/// This will trigger a [`accesskit::Action::Click`] action.
/// In contrast to `click()`, this can also click widgets that are not currently visible.
#[track_caller]
pub fn click_accesskit(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(
@@ -115,6 +153,7 @@ impl Node<'_> {
}
}
#[track_caller]
pub fn focus(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -125,6 +164,7 @@ impl Node<'_> {
}));
}
#[track_caller]
pub fn type_text(&self, text: &str) {
self.event(egui::Event::Text(text.to_owned()));
}
@@ -138,6 +178,7 @@ impl Node<'_> {
}
/// Scroll the node into view.
#[track_caller]
pub fn scroll_to_me(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -149,6 +190,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node down (100px).
#[track_caller]
pub fn scroll_down(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -160,6 +202,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node up (100px).
#[track_caller]
pub fn scroll_up(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -171,6 +214,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node left (100px).
#[track_caller]
pub fn scroll_left(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -182,6 +226,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node right (100px).
#[track_caller]
pub fn scroll_right(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {

View File

@@ -11,6 +11,24 @@
use std::io::{self, Read, Write};
/// One source file plus the test-source lines the inspector should highlight inside it.
///
/// The harness walks each captured backtrace (for the `.run()` call that produced the frame
/// and each event consumed by it), finds the topmost common test-source file across all of
/// them, reads that file, and emits its contents here. Highlights are line numbers within
/// that file: [`call_site_line`] for the runner call, [`event_lines`] for each event.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SourceView {
/// Absolute or crate-relative path as reported by the backtrace resolver.
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>,
}
/// A single rendered frame plus the accesskit tree update produced by the harness step.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Frame {
@@ -29,13 +47,15 @@ pub struct Frame {
pub accesskit: Option<accesskit::TreeUpdate>,
/// Optional human-readable label (e.g. test name).
pub label: Option<String>,
/// The test source file associated with this frame + the lines to highlight inside it.
pub source: Option<SourceView>,
}
/// 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),
Frame(Box<Frame>),
/// The harness is shutting down (e.g. `Drop`).
Goodbye,
}

View File

@@ -19,7 +19,7 @@ use accesskit::{Node, NodeId, Rect as AkRect};
/// Internal worker → UI message.
enum WorkerEvent {
Frame(Frame),
Frame(Box<Frame>),
Disconnected,
}
@@ -144,7 +144,7 @@ impl InspectorApp {
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.current_frame = Some(*frame);
self.worker_waiting = true;
}
WorkerEvent::Disconnected => {
@@ -272,6 +272,12 @@ fn details_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
return;
};
egui::CollapsingHeader::new("Source")
.default_open(true)
.show(ui, |ui| {
source_section(ui, &frame);
});
egui::CollapsingHeader::new("Frame")
.default_open(true)
.show(ui, |ui| {
@@ -524,6 +530,89 @@ fn kv_grid(ui: &mut egui::Ui, id: &str, body: impl FnOnce(&mut egui::Ui)) {
.show(ui, body);
}
/// Render the "Source" section: the test file (topmost common ancestor across the call and
/// its events), with the relevant lines highlighted and the view scrolled to them.
fn source_section(ui: &mut egui::Ui, frame: &kittest_inspector::Frame) {
let Some(source) = &frame.source else {
ui.weak("No source location for this frame.");
return;
};
ui.horizontal(|ui| {
ui.monospace(shorten_path(&source.path));
if let Some(line) = source.call_site_line {
ui.weak(format!("(producer: line {line})"));
}
});
let Some(contents) = source.contents.as_deref() else {
ui.weak(format!("(couldn't read {})", source.path));
return;
};
let call_site_line = source.call_site_line;
let event_lines: std::collections::HashSet<u32> = source.event_lines.iter().copied().collect();
let focus_line = call_site_line.or_else(|| source.event_lines.first().copied());
// Fixed-height viewport with auto-scroll to the focused line.
let row_height = ui.text_style_height(&egui::TextStyle::Monospace);
let scroll_area = egui::ScrollArea::both()
.auto_shrink([false, false])
.max_height(320.0);
let output = scroll_area.show_rows(ui, row_height, contents.lines().count(), |ui, range| {
for (idx, line) in contents.lines().enumerate().skip(range.start).take(range.len()) {
let line_no = idx as u32 + 1;
let is_call = Some(line_no) == call_site_line;
let is_event = event_lines.contains(&line_no);
let bg = if is_call {
Some(egui::Color32::from_rgb(30, 70, 120))
} else if is_event {
Some(egui::Color32::from_rgb(90, 60, 20))
} else {
None
};
source_line_row(ui, line_no, line, bg);
}
});
// Scroll the focused line into view on the first render of each new frame.
if let Some(focus) = focus_line {
let target_y = output.inner_rect.min.y + (focus.saturating_sub(1) as f32) * row_height;
let target = egui::Rect::from_min_size(
egui::pos2(output.inner_rect.min.x, target_y),
egui::vec2(1.0, row_height),
);
ui.scroll_to_rect(target, Some(egui::Align::Center));
}
}
fn source_line_row(ui: &mut egui::Ui, line_no: u32, text: &str, bg: Option<egui::Color32>) {
let row = ui.horizontal(|ui| {
ui.set_min_width(ui.available_width());
ui.add(egui::Label::new(
egui::RichText::new(format!("{line_no:>4} "))
.monospace()
.weak(),
));
ui.add(egui::Label::new(egui::RichText::new(text).monospace()).wrap_mode(egui::TextWrapMode::Extend));
});
if let Some(color) = bg {
ui.painter().rect_filled(row.response.rect, 2.0, color);
}
}
/// Shorten a `rustc`-reported path for display — keep the last two components so we show
/// `tests/menu.rs` instead of a long absolute path, while still disambiguating.
fn shorten_path(path: &str) -> String {
let components: Vec<&str> = path.split(['/', '\\']).collect();
if components.len() <= 2 {
path.to_owned()
} else {
let n = components.len();
format!("{}/{}", components[n - 2], components[n - 1])
}
}
/// 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| {