1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-03 15:20:05 -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

@@ -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| {