mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Ui improvements
This commit is contained in:
@@ -138,6 +138,12 @@ struct InspectorApp {
|
||||
/// While `UntilNewCallLine`, auto-release every incoming frame until we see one with a
|
||||
/// different `call_site_line` — i.e. until the test moves past the current runner call.
|
||||
skip: SkipState,
|
||||
/// Screen rect of the rendered image from the previous frame. We hit-test against this
|
||||
/// at the start of the next `ui()` (before panels render) so the details tree can see
|
||||
/// `hovered_node` in the same frame as the image highlight.
|
||||
last_image_rect: Option<egui::Rect>,
|
||||
/// Display-pixel-per-physical-pixel ratio from the previous frame.
|
||||
last_image_scale: f32,
|
||||
}
|
||||
|
||||
impl InspectorApp {
|
||||
@@ -183,9 +189,46 @@ impl InspectorApp {
|
||||
queued_events: Vec::new(),
|
||||
scroll_pending: false,
|
||||
skip: SkipState::Inactive,
|
||||
last_image_rect: None,
|
||||
last_image_scale: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit-test the current cursor position against the cached image rect + the viewed
|
||||
/// frame's accesskit bounds and set `hovered_node`. Called at the top of `ui()` so the
|
||||
/// tree (rendered before the image) picks up the same hover state in this frame.
|
||||
fn hit_test_pointer(&mut self, ctx: &egui::Context) {
|
||||
if self.control_enabled {
|
||||
return; // In control mode we forward events, we don't inspect on hover.
|
||||
}
|
||||
let (Some(image_rect), Some(frame)) = (self.last_image_rect, self.view_frame()) else {
|
||||
return;
|
||||
};
|
||||
let Some(update) = frame.accesskit.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(pos) = ctx.input(|i| i.pointer.hover_pos()) else {
|
||||
return;
|
||||
};
|
||||
if !image_rect.contains(pos) {
|
||||
return;
|
||||
}
|
||||
let f = (frame.pixels_per_point * self.last_image_scale) as f64;
|
||||
let lx = ((pos.x - image_rect.min.x) as f64) / f;
|
||||
let ly = ((pos.y - image_rect.min.y) as f64) / f;
|
||||
let mut best: Option<(NodeId, f64)> = None;
|
||||
for (id, node) in &update.nodes {
|
||||
let Some(b) = node.bounds() else { continue };
|
||||
if lx >= b.x0 && lx <= b.x1 && ly >= b.y0 && ly <= b.y1 {
|
||||
let area = (b.x1 - b.x0).max(0.0) * (b.y1 - b.y0).max(0.0);
|
||||
if best.is_none_or(|(_, a)| area < a) {
|
||||
best = Some((*id, area));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hovered_node = best.map(|(id, _)| id);
|
||||
}
|
||||
|
||||
fn pump_worker(&mut self) {
|
||||
while let Ok(event) = self.worker_rx.try_recv() {
|
||||
match event {
|
||||
@@ -257,8 +300,11 @@ impl eframe::App for InspectorApp {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.pump_worker();
|
||||
self.ensure_texture_uploaded(&ctx);
|
||||
// Reset hover each frame — central panel will set it again if mouse is over the image.
|
||||
// Reset hover each frame — either the pre-hit-test below (using the cached image
|
||||
// rect from the previous frame) or the tree's own hover detection, or the central
|
||||
// panel's live hit-test will set it again.
|
||||
self.hovered_node = None;
|
||||
self.hit_test_pointer(&ctx);
|
||||
|
||||
controls_panel(self, ui);
|
||||
details_panel(self, ui);
|
||||
@@ -408,19 +454,23 @@ fn details_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
.resizable(true)
|
||||
.default_size(380.0)
|
||||
.show_inside(ui, |ui| {
|
||||
let Some(frame) = app.view_frame().cloned() else {
|
||||
ui.weak("Waiting for frames...");
|
||||
return;
|
||||
};
|
||||
|
||||
// The Source view sits in its own resizable top panel so the user can drop it out
|
||||
// of the way when they want more room for the widget / AccessKit sections below.
|
||||
egui::Panel::top("details_source")
|
||||
.resizable(true)
|
||||
.default_size(280.0)
|
||||
.show_inside(ui, |ui| {
|
||||
ui.heading("Source");
|
||||
let scroll_pending = std::mem::take(&mut app.scroll_pending);
|
||||
source_section(ui, &frame, scroll_pending);
|
||||
});
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
let Some(frame) = app.view_frame().cloned() else {
|
||||
ui.weak("Waiting for frames...");
|
||||
return;
|
||||
};
|
||||
|
||||
let scroll_pending = std::mem::take(&mut app.scroll_pending);
|
||||
egui::CollapsingHeader::new("Source")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
source_section(ui, &frame, scroll_pending);
|
||||
});
|
||||
|
||||
egui::CollapsingHeader::new("Frame")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
@@ -479,28 +529,16 @@ fn details_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
app.selected_node = None;
|
||||
}
|
||||
|
||||
egui::CollapsingHeader::new("All AccessKit nodes")
|
||||
egui::CollapsingHeader::new("AccessKit tree")
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
if let Some(update) = &frame.accesskit {
|
||||
for (id, node) in &update.nodes {
|
||||
let role = format!("{:?}", node.role());
|
||||
let label = node
|
||||
.label()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| node.value().map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
let selected = Some(*id) == app.selected_node;
|
||||
if ui
|
||||
.selectable_label(
|
||||
selected,
|
||||
format!("{:?} {role} {label:?}", id.0),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.selected_node = Some(*id);
|
||||
}
|
||||
}
|
||||
accesskit_tree(
|
||||
ui,
|
||||
update,
|
||||
&mut app.selected_node,
|
||||
&mut app.hovered_node,
|
||||
);
|
||||
} else {
|
||||
ui.weak("(no accesskit tree)");
|
||||
}
|
||||
@@ -538,6 +576,10 @@ fn central_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
// Cache the image placement so the next frame's `hit_test_pointer` can run before
|
||||
// the tree is rendered and keep the two in sync.
|
||||
app.last_image_rect = Some(image_rect);
|
||||
app.last_image_scale = scale;
|
||||
|
||||
// logical_point → screen_position:
|
||||
// screen = image_rect.min + ak_rect * pixels_per_point * scale
|
||||
@@ -548,13 +590,6 @@ fn central_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
image_rect.min + egui::vec2(r.x1 as f32 * f, r.y1 as f32 * f),
|
||||
)
|
||||
};
|
||||
let screen_to_logical = |p: egui::Pos2| -> (f64, f64) {
|
||||
let f = (frame.pixels_per_point * scale) as f64;
|
||||
(
|
||||
((p.x - image_rect.min.x) as f64) / f,
|
||||
((p.y - image_rect.min.y) as f64) / f,
|
||||
)
|
||||
};
|
||||
|
||||
if app.control_enabled {
|
||||
// In Control mode clicks/hovers drive the harness, not the inspector.
|
||||
@@ -567,21 +602,9 @@ fn central_panel(app: &mut InspectorApp, ui: &mut egui::Ui) {
|
||||
&response,
|
||||
);
|
||||
} else {
|
||||
// Inspection mode: hit test (smallest containing widget wins) + draw overlays.
|
||||
if let (Some(pos), Some(update)) = (response.hover_pos(), &frame.accesskit) {
|
||||
let (lx, ly) = screen_to_logical(pos);
|
||||
let mut best: Option<(NodeId, f64)> = None;
|
||||
for (id, node) in &update.nodes {
|
||||
let Some(b) = node.bounds() else { continue };
|
||||
if lx >= b.x0 && lx <= b.x1 && ly >= b.y0 && ly <= b.y1 {
|
||||
let area = (b.x1 - b.x0).max(0.0) * (b.y1 - b.y0).max(0.0);
|
||||
if best.is_none_or(|(_, a)| area < a) {
|
||||
best = Some((*id, area));
|
||||
}
|
||||
}
|
||||
}
|
||||
app.hovered_node = best.map(|(id, _)| id);
|
||||
}
|
||||
// Inspection mode: hover was already resolved in `hit_test_pointer` at the top
|
||||
// of `ui()` so the tree and the image stay in sync — we only need to handle the
|
||||
// click here.
|
||||
if response.clicked() {
|
||||
app.selected_node = app.hovered_node;
|
||||
}
|
||||
@@ -713,9 +736,9 @@ fn source_section(ui: &mut egui::Ui, frame: &kittest_inspector::Frame, scroll_pe
|
||||
let lines: Vec<&str> = contents.lines().collect();
|
||||
let total_height = lines.len() as f32 * row_height;
|
||||
|
||||
let scroll_area = egui::ScrollArea::both()
|
||||
.auto_shrink([false, false])
|
||||
.max_height(320.0);
|
||||
// Expand to fill the enclosing (resizable) panel — the user's drag on the panel handle
|
||||
// determines how tall the source view is.
|
||||
let scroll_area = egui::ScrollArea::both().auto_shrink([false, false]);
|
||||
// `show_viewport` lets us decide ourselves which rows to render + lets us reason in the
|
||||
// content's *virtual* coordinate space. That means we can build a target rect for the
|
||||
// focus line whether or not it's currently visible, and `scroll_to_rect` will animate
|
||||
@@ -797,6 +820,91 @@ fn shorten_path(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the accesskit tree recursively, similar in style to the egui demo's `inspection_ui`
|
||||
/// — collapsible parents with their children indented below, leaves as selectable labels.
|
||||
fn accesskit_tree(
|
||||
ui: &mut egui::Ui,
|
||||
update: &accesskit::TreeUpdate,
|
||||
selected: &mut Option<NodeId>,
|
||||
hovered: &mut Option<NodeId>,
|
||||
) {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
let nodes: HashMap<NodeId, &Node> = update.nodes.iter().map(|(id, n)| (*id, n)).collect();
|
||||
|
||||
// Prefer the tree's declared root. If this update doesn't carry tree-level info (diff-only
|
||||
// updates can omit it), fall back to any node that no other node lists as a child.
|
||||
let root = update.tree.as_ref().map(|t| t.root).or_else(|| {
|
||||
let mut children: HashSet<NodeId> = HashSet::new();
|
||||
for (_, node) in &update.nodes {
|
||||
for c in node.children() {
|
||||
children.insert(*c);
|
||||
}
|
||||
}
|
||||
update.nodes.iter().map(|(id, _)| *id).find(|id| !children.contains(id))
|
||||
});
|
||||
|
||||
match root {
|
||||
Some(root_id) => render_ak_node(ui, root_id, &nodes, selected, hovered),
|
||||
None => {
|
||||
// Shouldn't normally happen; degrade to a flat list.
|
||||
for (id, _) in &update.nodes {
|
||||
render_ak_node(ui, *id, &nodes, selected, hovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_ak_node(
|
||||
ui: &mut egui::Ui,
|
||||
id: NodeId,
|
||||
nodes: &std::collections::HashMap<NodeId, &Node>,
|
||||
selected: &mut Option<NodeId>,
|
||||
hovered: &mut Option<NodeId>,
|
||||
) {
|
||||
let Some(node) = nodes.get(&id).copied() else {
|
||||
ui.weak(format!("(missing {:?})", id.0));
|
||||
return;
|
||||
};
|
||||
let role = format!("{:?}", node.role());
|
||||
let text = match node.label().or_else(|| node.value()) {
|
||||
Some(label) if !label.is_empty() => format!("{role} {label:?}"),
|
||||
_ => role,
|
||||
};
|
||||
// Both the image's hovered state and the tree's selection light up the same row — a row
|
||||
// shown highlighted in the tree corresponds to the rect drawn on the image.
|
||||
let highlight = *selected == Some(id) || *hovered == Some(id);
|
||||
let children = node.children();
|
||||
|
||||
if children.is_empty() {
|
||||
let response = ui.selectable_label(highlight, text);
|
||||
if response.clicked() {
|
||||
*selected = Some(id);
|
||||
}
|
||||
if response.hovered() {
|
||||
*hovered = Some(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let header_id = ui.make_persistent_id(("ak_node", id.0));
|
||||
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), header_id, true)
|
||||
.show_header(ui, |ui| {
|
||||
let response = ui.selectable_label(highlight, text);
|
||||
if response.clicked() {
|
||||
*selected = Some(id);
|
||||
}
|
||||
if response.hovered() {
|
||||
*hovered = Some(id);
|
||||
}
|
||||
})
|
||||
.body(|ui| {
|
||||
for child_id in children {
|
||||
render_ak_node(ui, *child_id, nodes, selected, hovered);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
|
||||
Reference in New Issue
Block a user