Skip to main content

frus_widgets/
inspector.rs

1//! The **runtime inspector** (§13), tier 1: outlines around every widget, a
2//! highlight plus an info card for the widget under the cursor, and an
3//! **indented text dump** of the tree (the `dump_deep` of §2).
4//!
5//! The material comes from [`crate::build_ui_inspected`]: one [`InspectorNode`]
6//! per painted widget, in paint (prefix) order. The layer paints **on top of**
7//! the app's scene ([`paint_overlay`]) — the shell turns it on on demand (F12)
8//! without touching the app.
9
10use frus_core::{Color, Point, Rect, Scene, Size, TextStyle};
11
12use crate::interaction::WidgetId;
13use crate::theme::Theme;
14
15/// One observed widget: its identity, its painted box, its short name
16/// (`debug_name`) and its depth in the tree.
17#[derive(Clone, Copy, Debug)]
18pub struct InspectorNode {
19    /// The widget's identity.
20    pub id: WidgetId,
21    /// The box it painted, in absolute coordinates.
22    pub rect: Rect,
23    /// Its short name, from [`Widget::debug_name`](crate::Widget::debug_name).
24    pub name: &'static str,
25    /// How deep it sits in the tree; the outermost widget is at 0.
26    pub depth: usize,
27}
28
29/// The node the hover designates: the **deepest** one containing `point`
30/// (at equal depth, the last painted — the one on top).
31pub fn node_at(nodes: &[InspectorNode], point: Point) -> Option<&InspectorNode> {
32    nodes
33        .iter()
34        .filter(|n| n.rect.contains(point))
35        .max_by_key(|n| n.depth)
36}
37
38/// An **abbreviated** identity (low 32 bits, 8 hex) — enough to correlate the
39/// card with the dump without drowning the display under 16 characters.
40fn short_id(id: WidgetId) -> String {
41    format!("#{:08x}", id.as_u64() as u32)
42}
43
44/// An **indented** text dump of the observed tree: one line per widget —
45/// `Name  x,y  w×h  #id`. The first tool to reach for when debugging identity
46/// or reordering.
47pub fn dump_tree(nodes: &[InspectorNode]) -> String {
48    let mut out = String::new();
49    for node in nodes {
50        let r = node.rect;
51        out.push_str(&format!(
52            "{:indent$}{}  {:.0},{:.0}  {:.0}×{:.0}  {}\n",
53            "",
54            node.name,
55            r.x,
56            r.y,
57            r.width,
58            r.height,
59            short_id(node.id),
60            indent = node.depth * 2,
61        ));
62    }
63    out
64}
65
66/// The outline palette, by depth (it cycles).
67const OUTLINE_COLORS: [Color; 4] = [
68    Color {
69        r: 0.35,
70        g: 0.65,
71        b: 1.0,
72        a: 0.55,
73    }, // bleu
74    Color {
75        r: 0.45,
76        g: 0.85,
77        b: 0.55,
78        a: 0.55,
79    }, // vert
80    Color {
81        r: 1.0,
82        g: 0.75,
83        b: 0.35,
84        a: 0.55,
85    }, // orange
86    Color {
87        r: 0.85,
88        g: 0.55,
89        b: 0.95,
90        a: 0.55,
91    }, // violet
92];
93
94/// Paints the inspector layer **on top of** an already built scene: an outline
95/// around every widget (tinted by depth) and, if `hover` designates a widget, a
96/// highlight plus a card (name, size, position, id) near the cursor, kept inside
97/// the window.
98pub fn paint_overlay(
99    nodes: &[InspectorNode],
100    hover: Option<Point>,
101    window: Size,
102    theme: &Theme,
103    scene: &mut Scene,
104) {
105    scene.set_clip(Rect::UNBOUNDED);
106    scene.set_owner(0);
107
108    for node in nodes {
109        let color = OUTLINE_COLORS[node.depth % OUTLINE_COLORS.len()];
110        scene.draw_rect(node.rect, Color::TRANSPARENT, 0.0, 1.0, color);
111    }
112
113    let Some(target) = hover.and_then(|point| node_at(nodes, point)) else {
114        return;
115    };
116
117    // Highlight for the designated widget: a primary scrim + a heavier outline.
118    scene.draw_rect(
119        target.rect,
120        theme.primary.with_alpha(0.18),
121        0.0,
122        2.0,
123        theme.primary,
124    );
125
126    // The info card, near the widget, clamped to the window.
127    let r = target.rect;
128    let title = target.name.to_string();
129    let details = format!(
130        "{:.0}×{:.0} @ {:.0},{:.0}  {}",
131        r.width,
132        r.height,
133        r.x,
134        r.y,
135        short_id(target.id)
136    );
137    let title_style = TextStyle::new(13.0).weight(frus_core::FontWeight::Bold);
138    let detail_style = TextStyle::new(12.0);
139    let title_size = frus_text::measure_styled(&title, 13.0, title_style.resolved().weight, false);
140    let detail_size =
141        frus_text::measure_styled(&details, 12.0, detail_style.resolved().weight, false);
142
143    const PAD: f32 = 8.0;
144    let card_w = title_size.width.max(detail_size.width) + PAD * 2.0;
145    let card_h = title_size.height + detail_size.height + PAD * 2.0;
146    // Below the widget if there is room, otherwise above; clamped in x.
147    let x = r.x.clamp(0.0, (window.width - card_w).max(0.0));
148    let below = r.y + r.height + 4.0;
149    let y = if below + card_h <= window.height {
150        below
151    } else {
152        (r.y - card_h - 4.0).max(0.0)
153    };
154
155    let card = Rect::new(x, y, card_w, card_h);
156    scene.draw_rect(
157        card,
158        theme.scheme.inverse_surface,
159        6.0,
160        0.0,
161        Color::TRANSPARENT,
162    );
163    let on = theme.scheme.on_inverse_surface;
164    scene.text(
165        Point::new(x + PAD, y + PAD),
166        title,
167        &title_style.resolved(),
168        on,
169    );
170    scene.text(
171        Point::new(x + PAD, y + PAD + title_size.height),
172        details,
173        &detail_style.resolved(),
174        on,
175    );
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::widget::Widget;
182    use crate::{build_ui_inspected, Container, Flex, Runtime, Text};
183
184    /// A small tree: collection covers every widget, with the right names
185    /// (concrete types, transparent wrappers) and the right depths.
186    #[test]
187    fn collects_names_rects_and_depths() {
188        let root: Container<()> = Container::new().width(200.0).height(100.0).child(
189            Flex::column()
190                .child(Text::new("a"))
191                .child(crate::Keyed::new(7, Text::new("b"))),
192        );
193        let runtime = Runtime::default();
194        let theme = Theme::default();
195        let (_, nodes) = build_ui_inspected(&root, Size::new(200.0, 100.0), &runtime, &theme);
196
197        let names: Vec<&str> = nodes.iter().map(|n| n.name).collect();
198        assert_eq!(
199            names,
200            ["Container", "Flex", "Text", "Text"],
201            "Keyed is transparent"
202        );
203        let depths: Vec<usize> = nodes.iter().map(|n| n.depth).collect();
204        assert_eq!(depths, [0, 1, 2, 2]);
205        assert_eq!(
206            nodes[0].rect,
207            Rect::new(0.0, 0.0, 200.0, 100.0),
208            "the root = its box"
209        );
210        // Without inspection: build_ui collects nothing (the normal path is unchanged).
211        let ui = crate::build_ui(&root, Size::new(200.0, 100.0), &runtime, &theme);
212        assert!(!ui.scene().is_empty());
213    }
214
215    /// Hovering designates the **deepest** widget under the point.
216    #[test]
217    fn node_at_picks_the_deepest() {
218        let root: Container<()> = Container::new()
219            .width(200.0)
220            .height(100.0)
221            .child(Container::new().width(50.0).height(40.0));
222        let runtime = Runtime::default();
223        let theme = Theme::default();
224        let (_, nodes) = build_ui_inspected(&root, Size::new(200.0, 100.0), &runtime, &theme);
225
226        let inner = node_at(&nodes, Point::new(10.0, 10.0)).expect("a widget under the point");
227        assert_eq!(inner.depth, 1, "the child, not the root");
228        let outer = node_at(&nodes, Point::new(150.0, 80.0)).expect("the root alone here");
229        assert_eq!(outer.depth, 0);
230        assert!(
231            node_at(&nodes, Point::new(500.0, 500.0)).is_none(),
232            "outside everything"
233        );
234    }
235
236    /// The dump is indented by depth and names every widget.
237    #[test]
238    fn dump_tree_indents_by_depth() {
239        let root: Container<()> = Container::new()
240            .width(80.0)
241            .height(40.0)
242            .child(Text::new("x"));
243        let runtime = Runtime::default();
244        let theme = Theme::default();
245        let (_, nodes) = build_ui_inspected(&root, Size::new(80.0, 40.0), &runtime, &theme);
246        let dump = dump_tree(&nodes);
247        let lines: Vec<&str> = dump.lines().collect();
248        assert!(
249            lines[0].starts_with("Container"),
250            "the root is not indented: {dump}"
251        );
252        assert!(
253            lines[1].starts_with("  Text"),
254            "child indented by 2: {dump}"
255        );
256        assert!(lines[0].contains("80×40"), "geometry in the dump: {dump}");
257    }
258
259    /// The layer paints on top of a scene: outlines for every widget, plus a
260    /// highlight and a card when a point is designated.
261    #[test]
262    fn overlay_paints_outlines_and_hover_card() {
263        let root: Container<()> = Container::new()
264            .width(120.0)
265            .height(60.0)
266            .child(Text::new("x"));
267        let runtime = Runtime::default();
268        let theme = Theme::default();
269        let (ui, nodes) = build_ui_inspected(&root, Size::new(120.0, 60.0), &runtime, &theme);
270
271        let base = ui.scene().len();
272        let mut scene = ui.scene().clone();
273        paint_overlay(&nodes, None, Size::new(120.0, 60.0), &theme, &mut scene);
274        assert_eq!(scene.len(), base + nodes.len(), "one outline per widget");
275
276        let mut scene = ui.scene().clone();
277        paint_overlay(
278            &nodes,
279            Some(Point::new(5.0, 5.0)),
280            Size::new(120.0, 60.0),
281            &theme,
282            &mut scene,
283        );
284        assert!(
285            scene.len() > base + nodes.len() + 2,
286            "highlight + card + 2 texts on top of the outlines"
287        );
288    }
289
290    /// `debug_name`: the concrete type, with no path and no generics; `Box` delegates.
291    #[test]
292    fn debug_names_are_short_and_delegated() {
293        let text = Text::new("x");
294        assert_eq!(Widget::<()>::debug_name(&text), "Text");
295        let boxed: Box<dyn Widget<()>> = Box::new(Text::new("x"));
296        assert_eq!(boxed.debug_name(), "Text", "Box delegates to its content");
297        let container: Container<()> = Container::new();
298        assert_eq!(
299            Widget::<()>::debug_name(&container),
300            "Container",
301            "generics stripped"
302        );
303    }
304}