From e07a169ee329639e851e66966755b7af427566fd Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Mon, 20 Apr 2026 14:01:30 +0200 Subject: [PATCH] Show source code location in inspector --- Cargo.lock | 1 + crates/egui_kittest/Cargo.toml | 3 +- crates/egui_kittest/src/inspector.rs | 126 ++++++++++++++++++++++++++- crates/egui_kittest/src/lib.rs | 87 ++++++++++++++++-- crates/egui_kittest/src/node.rs | 53 ++++++++++- crates/kittest_inspector/src/lib.rs | 22 ++++- crates/kittest_inspector/src/main.rs | 93 +++++++++++++++++++- 7 files changed, 367 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ef47732b..c80c700a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,6 +1392,7 @@ dependencies = [ name = "egui_kittest" version = "0.34.1" dependencies = [ + "backtrace", "dify", "document-features", "eframe", diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 37322a2b5..14f1d87e9 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -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 } diff --git a/crates/egui_kittest/src/inspector.rs b/crates/egui_kittest/src/inspector.rs index 01a3d001b..5ca591935 100644 --- a/crates/egui_kittest/src/inspector.rs +++ b/crates/egui_kittest/src/inspector.rs @@ -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, + call_site: &EventSite, + event_sites: &[EventSite], ) -> Vec { 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 { + let call_frames = call_site.as_deref().map(user_frames); + let event_frames: Vec> = 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> = + 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 = 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 { + 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]) -> Option { + // 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 { + 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 { + static CACHE: LazyLock>>> = + 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 = std::sync::OnceLock::new(); diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index f6e5d1774..82a7f1f5f 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -105,6 +105,13 @@ pub struct Harness<'a, State = ()> { inspector: Option, #[cfg(feature = "inspector")] last_accesskit_update: Option, + /// 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, } impl 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 { + #[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 { + #[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 { + #[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); diff --git a/crates/egui_kittest/src/node.rs b/crates/egui_kittest/src/node.rs index 7e3161c09..ff799dc77 100644 --- a/crates/egui_kittest/src/node.rs +++ b/crates/egui_kittest/src/node.rs @@ -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>; +#[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>; @@ -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 { diff --git a/crates/kittest_inspector/src/lib.rs b/crates/kittest_inspector/src/lib.rs index af0ba7138..361c59020 100644 --- a/crates/kittest_inspector/src/lib.rs +++ b/crates/kittest_inspector/src/lib.rs @@ -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, + /// Line number of the `.run()` / `.step()` call that produced this frame. + pub call_site_line: Option, + /// Line numbers of events consumed by this frame's step, in queue order. + pub event_lines: Vec, +} + /// 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, /// Optional human-readable label (e.g. test name). pub label: Option, + /// The test source file associated with this frame + the lines to highlight inside it. + pub source: Option, } /// Sent harness → inspector after every step, and once when the harness disconnects. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum HarnessMessage { /// A new frame is available. - Frame(Frame), + Frame(Box), /// The harness is shutting down (e.g. `Drop`). Goodbye, } diff --git a/crates/kittest_inspector/src/main.rs b/crates/kittest_inspector/src/main.rs index b035c2672..90d92ef29 100644 --- a/crates/kittest_inspector/src/main.rs +++ b/crates/kittest_inspector/src/main.rs @@ -19,7 +19,7 @@ use accesskit::{Node, NodeId, Rect as AkRect}; /// Internal worker → UI message. enum WorkerEvent { - Frame(Frame), + Frame(Box), 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 = 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) { + 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| {