Skip to main content

agg_gui/widgets/
inspector.rs

1//! Widget inspector panel — uses the system `TreeView` for tree display.
2//!
3//! Layout (Y-up, panel fills its full bounds):
4//! ```text
5//! ┌─────────────────────┐ ← top (HEADER_H)   header (painted monolithically)
6//! ├─────────────────────┤
7//! │   TreeView          │ ← tree area (TreeView painted here)
8//! ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ ← draggable h-split
9//! │   Properties        │ ← props area (painted monolithically)
10//! └─────────────────────┘ ← bottom (y=0)
11//! ```
12
13use std::cell::RefCell;
14use std::rc::Rc;
15use std::sync::Arc;
16
17use crate::color::Color;
18use crate::draw_ctx::DrawCtx;
19use crate::event::{Event, EventResult, MouseButton};
20use crate::geometry::{Point, Rect, Size};
21use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
22use crate::text::Font;
23use crate::widget::{InspectorNode, Widget};
24use crate::widgets::tree_view::{NodeIcon, TreeNode, TreeView};
25
26// ── InternalPresenceNode ──────────────────────────────────────────────────────
27
28/// Transparent placeholder child representing the inspector's internal `TreeView`
29/// in the widget inspector tree.
30///
31/// This makes `InspectorPanel` appear as an expandable node (with one child) in
32/// the inspector rather than a leaf, so the user can see that the panel contains
33/// an internal tree.
34///
35/// Hit-test is always `false` (no event interception).  Paint is a no-op (the
36/// real `TreeView` is painted directly by `InspectorPanel`).
37/// `contributes_children_to_inspector` returns `false` to stop the inspector
38/// from recursing into row_widgets, which would grow the node list exponentially.
39///
40/// Bounds are kept in sync with the real `TreeView` by `InspectorPanel::layout`.
41struct InternalPresenceNode {
42    bounds: Rect,
43    children: Vec<Box<dyn Widget>>,
44    base: WidgetBase,
45    name: &'static str,
46}
47
48impl Widget for InternalPresenceNode {
49    fn type_name(&self) -> &'static str {
50        self.name
51    }
52    fn bounds(&self) -> Rect {
53        self.bounds
54    }
55    fn set_bounds(&mut self, b: Rect) {
56        self.bounds = b;
57    }
58    fn children(&self) -> &[Box<dyn Widget>] {
59        &self.children
60    }
61    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
62        &mut self.children
63    }
64    fn margin(&self) -> Insets {
65        self.base.margin
66    }
67    fn h_anchor(&self) -> HAnchor {
68        self.base.h_anchor
69    }
70    fn v_anchor(&self) -> VAnchor {
71        self.base.v_anchor
72    }
73    fn min_size(&self) -> Size {
74        self.base.min_size
75    }
76    fn max_size(&self) -> Size {
77        self.base.max_size
78    }
79    fn layout(&mut self, _: Size) -> Size {
80        Size::new(self.bounds.width, self.bounds.height)
81    }
82    fn paint(&mut self, _: &mut dyn DrawCtx) {}
83    fn hit_test(&self, _: Point) -> bool {
84        false
85    }
86    fn on_event(&mut self, _: &Event) -> EventResult {
87        EventResult::Ignored
88    }
89    fn contributes_children_to_inspector(&self) -> bool {
90        false
91    }
92}
93
94// ── geometry constants ────────────────────────────────────────────────────────
95const DEFAULT_PROPS_H: f64 = 180.0;
96const FONT_SIZE: f64 = 12.0;
97const HEADER_H: f64 = 30.0;
98const SPLIT_HIT: f64 = 5.0;
99const MIN_PROPS_H: f64 = 60.0;
100const MIN_TREE_H: f64 = 60.0;
101
102// ── light theme colors ────────────────────────────────────────────────────────
103// Theme-aware colour helpers — all derive from the active `Visuals` so the
104// inspector follows light / dark mode changes without a restart.
105fn c_panel_bg(v: &crate::theme::Visuals) -> Color {
106    v.panel_fill
107}
108fn c_header_bg(v: &crate::theme::Visuals) -> Color {
109    // Slightly darker than the panel fill.
110    let f = if is_dark(v) { 0.80 } else { 0.94 };
111    Color::rgba(
112        v.panel_fill.r * f,
113        v.panel_fill.g * f,
114        v.panel_fill.b * f,
115        1.0,
116    )
117}
118fn c_props_bg(v: &crate::theme::Visuals) -> Color {
119    v.window_fill
120}
121fn c_split_bg(v: &crate::theme::Visuals) -> Color {
122    let t = if is_dark(v) { 1.0 } else { 0.0 };
123    Color::rgba(t, t, t, 0.10)
124}
125fn c_border(v: &crate::theme::Visuals) -> Color {
126    v.separator
127}
128fn c_text(v: &crate::theme::Visuals) -> Color {
129    v.text_color
130}
131fn c_dim_text(v: &crate::theme::Visuals) -> Color {
132    v.text_dim
133}
134
135fn is_dark(v: &crate::theme::Visuals) -> bool {
136    // Panel fill luminance — below 0.5 means we're in a dark palette.
137    let lum = 0.299 * v.panel_fill.r + 0.587 * v.panel_fill.g + 0.114 * v.panel_fill.b;
138    lum < 0.5
139}
140
141// ── event translation helper ──────────────────────────────────────────────────
142
143/// Translate the Y coordinate of a mouse event by subtracting `offset_y`.
144/// X is unchanged. Non-mouse events pass through unchanged.
145fn translate_event(event: &Event, offset_y: f64) -> Event {
146    match event {
147        Event::MouseDown {
148            pos,
149            button,
150            modifiers,
151        } => Event::MouseDown {
152            pos: Point::new(pos.x, pos.y - offset_y),
153            button: *button,
154            modifiers: *modifiers,
155        },
156        Event::MouseMove { pos } => Event::MouseMove {
157            pos: Point::new(pos.x, pos.y - offset_y),
158        },
159        Event::MouseUp {
160            pos,
161            button,
162            modifiers,
163        } => Event::MouseUp {
164            pos: Point::new(pos.x, pos.y - offset_y),
165            button: *button,
166            modifiers: *modifiers,
167        },
168        Event::MouseWheel {
169            pos,
170            delta_y,
171            delta_x,
172            modifiers,
173        } => Event::MouseWheel {
174            pos: Point::new(pos.x, pos.y - offset_y),
175            delta_y: *delta_y,
176            delta_x: *delta_x,
177            modifiers: *modifiers,
178        },
179        other => other.clone(),
180    }
181}
182
183// ── InspectorPanel ────────────────────────────────────────────────────────────
184
185pub struct InspectorPanel {
186    bounds: Rect,
187    /// Contains exactly one `InternalPresenceNode` (a transparent proxy for the
188    /// internal `TreeView`).  This makes InspectorPanel non-leaf in the inspector
189    /// so the user can see it has internal structure.
190    _children: Vec<Box<dyn Widget>>,
191    base: WidgetBase,
192    font: Arc<Font>,
193    nodes: Rc<RefCell<Vec<InspectorNode>>>,
194    /// Selected: original node index; synced from TreeView selection.
195    selected: Option<usize>,
196    props_h: f64,
197    split_dragging: bool,
198    /// Written by layout(); read by the render loop to draw an overlay.
199    pub hovered_bounds: Rc<RefCell<Option<Rect>>>,
200    /// The tree widget, managed directly (not in children).
201    pub(crate) tree_view: TreeView,
202    /// Set by `apply_saved_state`; consumed on the next layout rebuild so
203    /// restored expand / select flags apply even on the very first frame
204    /// (before the user has interacted with the tree).
205    pending_expanded: Option<Vec<bool>>,
206    pending_selected: Option<Option<usize>>,
207    /// When bound, each `layout()` writes the current state into this cell
208    /// so the harness can persist it without needing mutable access to the
209    /// widget tree.
210    snapshot_out: Option<Rc<RefCell<Option<InspectorSavedState>>>>,
211}
212
213/// Serializable inspector UI state — apply at startup, snapshot at shutdown.
214#[derive(Clone, Debug, Default)]
215pub struct InspectorSavedState {
216    pub expanded: Vec<bool>,
217    pub selected: Option<usize>,
218    pub props_h: f64,
219}
220
221impl InspectorPanel {
222    pub fn new(
223        font: Arc<Font>,
224        nodes: Rc<RefCell<Vec<InspectorNode>>>,
225        hovered_bounds: Rc<RefCell<Option<Rect>>>,
226    ) -> Self {
227        let tree_view = TreeView::new(Arc::clone(&font))
228            .with_row_height(20.0)
229            .with_font_size(12.0)
230            .with_indent_width(14.0)
231            .with_hover_repaint(false);
232        Self {
233            bounds: Rect::default(),
234            _children: vec![Box::new(InternalPresenceNode {
235                bounds: Rect::default(),
236                children: Vec::new(),
237                base: WidgetBase::new(),
238                name: "TreeView",
239            })],
240            base: WidgetBase::new(),
241            font,
242            nodes,
243            selected: None,
244            props_h: DEFAULT_PROPS_H,
245            split_dragging: false,
246            hovered_bounds,
247            tree_view,
248            pending_expanded: None,
249            pending_selected: None,
250            snapshot_out: None,
251        }
252    }
253
254    /// Bind an output cell that the inspector updates every layout with
255    /// the current [`InspectorSavedState`] — use the cell from a harness
256    /// that persists app state.
257    pub fn with_snapshot_cell(mut self, cell: Rc<RefCell<Option<InspectorSavedState>>>) -> Self {
258        self.snapshot_out = Some(cell);
259        self
260    }
261
262    // ── Persistence helpers ──────────────────────────────────────────────────
263    //
264    // The platform harness calls `saved_state` at shutdown and
265    // `apply_saved_state` on startup so the inspector's tree expand /
266    // selection / split-bar position survive restarts.  Values are stored
267    // by the position they occupy in the flat DFS tree — if the widget
268    // tree differs across runs the worst case is a few extra collapsed
269    // nodes, never a crash.
270
271    /// Snapshot the current inspector UI state for persistence.
272    pub fn saved_state(&self) -> InspectorSavedState {
273        InspectorSavedState {
274            expanded: self.tree_view.nodes.iter().map(|n| n.is_expanded).collect(),
275            selected: self.tree_view.nodes.iter().position(|n| n.is_selected),
276            props_h: self.props_h,
277        }
278    }
279
280    /// Apply a previously-saved state.  Must be called before the first
281    /// `layout()` runs — the inspector restores the expand / select flags
282    /// from here when it first rebuilds the TreeView, via the `pending_*`
283    /// side channels.
284    pub fn apply_saved_state(&mut self, s: InspectorSavedState) {
285        self.pending_expanded = Some(s.expanded);
286        self.pending_selected = Some(s.selected);
287        self.props_h = s.props_h.clamp(MIN_PROPS_H, 1024.0);
288    }
289
290    // ── geometry helpers ──────────────────────────────────────────────────────
291
292    /// Height of the area below the header (tree + props).
293    fn list_area_h(&self) -> f64 {
294        (self.bounds.height - HEADER_H).max(0.0)
295    }
296
297    /// Y position of the tree/props split line (from panel bottom).
298    fn split_y(&self) -> f64 {
299        self.props_h.clamp(
300            MIN_PROPS_H,
301            (self.list_area_h() - MIN_TREE_H).max(MIN_PROPS_H),
302        )
303    }
304
305    /// Bottom Y of the tree area (just above the split handle).
306    fn tree_origin_y(&self) -> f64 {
307        self.split_y() + 4.0
308    }
309
310    fn on_split_handle(&self, pos: Point) -> bool {
311        let sy = self.split_y();
312        pos.y >= sy - SPLIT_HIT && pos.y <= sy + SPLIT_HIT
313    }
314
315    fn pos_in_tree_area(&self, pos: Point) -> bool {
316        let tree_bot = self.tree_origin_y();
317        let tree_top = self.list_area_h();
318        pos.y >= tree_bot && pos.y <= tree_top
319    }
320
321    /// Forward event to the TreeView, translating Y into tree-local coordinates.
322    fn forward_to_tree(&mut self, event: &Event) -> EventResult {
323        // tree_view.bounds().y is tree_origin_y() in panel-local space — subtracting
324        // it converts panel-local Y to TreeView-local Y (where y=0 is the bottom of
325        // the tree area).
326        let offset_y = self.tree_view.bounds().y;
327        let translated = translate_event(event, offset_y);
328        self.tree_view.on_event(&translated)
329    }
330
331    fn update_hovered_bounds_from_tree(&self) {
332        let nodes = self.nodes.borrow();
333        let next = self
334            .tree_view
335            .hovered_node_idx()
336            .and_then(|i| nodes.get(i))
337            .map(|n| n.screen_bounds);
338        let mut hovered = self.hovered_bounds.borrow_mut();
339        if *hovered != next {
340            *hovered = next;
341            crate::animation::request_draw_without_invalidation();
342        }
343    }
344}
345
346// ── Widget impl ───────────────────────────────────────────────────────────────
347
348impl InspectorPanel {
349    pub fn with_margin(mut self, m: Insets) -> Self {
350        self.base.margin = m;
351        self
352    }
353    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
354        self.base.h_anchor = h;
355        self
356    }
357    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
358        self.base.v_anchor = v;
359        self
360    }
361    pub fn with_min_size(mut self, s: Size) -> Self {
362        self.base.min_size = s;
363        self
364    }
365    pub fn with_max_size(mut self, s: Size) -> Self {
366        self.base.max_size = s;
367        self
368    }
369}
370
371impl Widget for InspectorPanel {
372    fn type_name(&self) -> &'static str {
373        "InspectorPanel"
374    }
375    fn bounds(&self) -> Rect {
376        self.bounds
377    }
378    fn set_bounds(&mut self, b: Rect) {
379        self.bounds = b;
380    }
381    fn children(&self) -> &[Box<dyn Widget>] {
382        &self._children
383    }
384    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
385        &mut self._children
386    }
387
388    fn margin(&self) -> Insets {
389        self.base.margin
390    }
391    fn h_anchor(&self) -> HAnchor {
392        self.base.h_anchor
393    }
394    fn v_anchor(&self) -> VAnchor {
395        self.base.v_anchor
396    }
397    fn min_size(&self) -> Size {
398        self.base.min_size
399    }
400    fn max_size(&self) -> Size {
401        self.base.max_size
402    }
403
404    fn layout(&mut self, available: Size) -> Size {
405        self.bounds.width = available.width;
406        self.bounds.height = available.height;
407
408        let nodes = self.nodes.borrow();
409
410        // Preserve expansion/selection state by index before rebuilding.
411        // On the very first layout after startup `pending_expanded` /
412        // `pending_selected` (set by `apply_saved_state`) seed the vectors
413        // so restored state takes effect without an extra click.
414        let mut old_expanded: Vec<bool> =
415            self.tree_view.nodes.iter().map(|n| n.is_expanded).collect();
416        let mut old_selected: Vec<bool> =
417            self.tree_view.nodes.iter().map(|n| n.is_selected).collect();
418        if let Some(pe) = self.pending_expanded.take() {
419            old_expanded = pe;
420        }
421        if let Some(ps) = self.pending_selected.take() {
422            old_selected = vec![false; old_expanded.len().max(ps.map(|i| i + 1).unwrap_or(0))];
423            if let Some(i) = ps {
424                if i < old_selected.len() {
425                    old_selected[i] = true;
426                }
427            }
428        }
429
430        self.tree_view.nodes.clear();
431
432        // Convert flat InspectorNode list (with depths) to parent-child TreeNode
433        // structure. Uses a depth stack: depth_stack[d] = tree node index of the
434        // last node placed at depth d.
435        let mut depth_stack: Vec<usize> = Vec::new();
436        let mut per_parent_counts: std::collections::HashMap<Option<usize>, u32> =
437            std::collections::HashMap::new();
438
439        for (orig_idx, node) in nodes.iter().enumerate() {
440            let parent = if node.depth == 0 {
441                None
442            } else {
443                depth_stack.get(node.depth.saturating_sub(1)).copied()
444            };
445
446            let order = {
447                let cnt = per_parent_counts.entry(parent).or_insert(0);
448                let o = *cnt;
449                *cnt += 1;
450                o
451            };
452
453            // Label: "TypeName  width×height"
454            let b = &node.screen_bounds;
455            let label = format!("{}  {:.0}×{:.0}", node.type_name, b.width, b.height);
456
457            let tv_idx = self.tree_view.nodes.len();
458            self.tree_view
459                .nodes
460                .push(TreeNode::new(label, NodeIcon::Package, parent, order));
461
462            // Restore or default expansion (default: expanded so tree is open).
463            self.tree_view.nodes[tv_idx].is_expanded =
464                old_expanded.get(orig_idx).copied().unwrap_or(true);
465            self.tree_view.nodes[tv_idx].is_selected =
466                old_selected.get(orig_idx).copied().unwrap_or(false);
467
468            // Update depth stack.
469            if depth_stack.len() <= node.depth {
470                depth_stack.resize(node.depth + 1, 0);
471            }
472            depth_stack[node.depth] = tv_idx;
473        }
474
475        // Sync selected field from TreeView selection.
476        self.selected = self.tree_view.nodes.iter().position(|n| n.is_selected);
477
478        // Update hovered_bounds for the render-loop overlay.
479        *self.hovered_bounds.borrow_mut() = self
480            .tree_view
481            .hovered_node_idx()
482            .and_then(|i| nodes.get(i))
483            .map(|n| n.screen_bounds);
484
485        // Layout the TreeView inside the tree area.
486        let tree_w = available.width;
487        let tree_bot = self.tree_origin_y();
488        let tree_top = self.list_area_h();
489        let tree_h = (tree_top - tree_bot).max(0.0);
490        self.tree_view
491            .set_bounds(Rect::new(0.0, tree_bot, tree_w, tree_h));
492        self.tree_view.layout(Size::new(tree_w, tree_h));
493
494        // Keep the presence node's bounds in sync with the real TreeView so the
495        // inspector displays accurate bounds for this proxy entry.
496        self._children[0].set_bounds(self.tree_view.bounds());
497
498        // Publish a snapshot for the harness to persist.
499        if let Some(cell) = &self.snapshot_out {
500            *cell.borrow_mut() = Some(self.saved_state());
501        }
502
503        available
504    }
505
506    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
507        let w = self.bounds.width;
508        let h = self.bounds.height;
509        let sy = self.split_y();
510        let hdr_y = h - HEADER_H;
511        let v = ctx.visuals().clone();
512
513        // ── panel background ─────────────────────────────────────────────────
514        ctx.set_fill_color(c_panel_bg(&v));
515        ctx.begin_path();
516        ctx.rect(0.0, 0.0, w, h);
517        ctx.fill();
518
519        // Left border
520        ctx.set_stroke_color(c_border(&v));
521        ctx.set_line_width(1.0);
522        ctx.begin_path();
523        ctx.move_to(0.0, 0.0);
524        ctx.line_to(0.0, h);
525        ctx.stroke();
526
527        // ── header ──────────────────────────────────────────────────────────
528        ctx.set_fill_color(c_header_bg(&v));
529        ctx.begin_path();
530        ctx.rect(0.0, hdr_y, w, HEADER_H);
531        ctx.fill();
532
533        ctx.set_stroke_color(c_border(&v));
534        ctx.set_line_width(1.0);
535        ctx.begin_path();
536        ctx.move_to(0.0, hdr_y);
537        ctx.line_to(w, hdr_y);
538        ctx.stroke();
539
540        ctx.set_font(Arc::clone(&self.font));
541        ctx.set_font_size(13.0);
542        ctx.set_fill_color(c_text(&v));
543        let title = "Widget Inspector";
544        if let Some(m) = ctx.measure_text(title) {
545            ctx.fill_text(
546                title,
547                12.0,
548                hdr_y + (HEADER_H - m.ascent - m.descent) * 0.5 + m.descent,
549            );
550        }
551
552        let count_txt = format!("{} widgets", self.nodes.borrow().len());
553        ctx.set_font_size(11.0);
554        ctx.set_fill_color(c_dim_text(&v));
555        if let Some(m) = ctx.measure_text(&count_txt) {
556            ctx.fill_text(
557                &count_txt,
558                w - m.width - 10.0,
559                hdr_y + (HEADER_H - m.ascent - m.descent) * 0.5 + m.descent,
560            );
561        }
562
563        // ── properties pane ──────────────────────────────────────────────────
564        ctx.set_fill_color(c_props_bg(&v));
565        ctx.begin_path();
566        ctx.rect(0.0, 0.0, w, sy - 2.0);
567        ctx.fill();
568        self.paint_properties(ctx, sy - 2.0);
569
570        // ── split handle ─────────────────────────────────────────────────────
571        ctx.set_fill_color(c_split_bg(&v));
572        ctx.begin_path();
573        ctx.rect(0.0, sy - 2.0, w, 4.0);
574        ctx.fill();
575        ctx.set_stroke_color(c_border(&v));
576        ctx.set_line_width(1.0);
577        ctx.begin_path();
578        ctx.move_to(0.0, sy);
579        ctx.line_to(w, sy);
580        ctx.stroke();
581
582        // ── tree area: clip then paint TreeView ──────────────────────────────
583        let tree_bot = self.tree_origin_y();
584        let tree_top = self.list_area_h();
585        let tree_h = (tree_top - tree_bot).max(0.0);
586        if tree_h > 0.0 {
587            ctx.save();
588            ctx.translate(0.0, tree_bot);
589            // clip_rect is called AFTER translate so coordinates are in
590            // tree-local space (0,0 = tree area bottom-left). The implementation
591            // maps these through the CTM to screen space before intersecting.
592            ctx.clip_rect(0.0, 0.0, w, tree_h);
593            // Use paint_subtree so the framework recurses into TreeRow children.
594            crate::widget::paint_subtree(&mut self.tree_view, ctx);
595            ctx.restore();
596        }
597    }
598
599    fn on_event(&mut self, event: &Event) -> EventResult {
600        match event {
601            Event::MouseDown {
602                pos,
603                button: MouseButton::Left,
604                ..
605            } => {
606                if self.on_split_handle(*pos) {
607                    self.split_dragging = true;
608                    // No tick: grabbing the split handle produces no visual
609                    // change until the cursor moves.  The follow-up
610                    // MouseMove handler ticks as the split actually shifts.
611                    return EventResult::Consumed;
612                }
613                if self.pos_in_tree_area(*pos) {
614                    return self.forward_to_tree(event);
615                }
616                EventResult::Ignored
617            }
618            Event::MouseMove { pos } => {
619                if self.split_dragging {
620                    self.props_h = pos.y.clamp(
621                        MIN_PROPS_H,
622                        (self.list_area_h() - MIN_TREE_H).max(MIN_PROPS_H),
623                    );
624                    crate::animation::request_draw();
625                    return EventResult::Consumed;
626                }
627                if self.pos_in_tree_area(*pos) {
628                    let _ = self.forward_to_tree(event);
629                    self.update_hovered_bounds_from_tree();
630                } else if self.hovered_bounds.borrow().is_some() {
631                    *self.hovered_bounds.borrow_mut() = None;
632                    crate::animation::request_draw_without_invalidation();
633                }
634                EventResult::Ignored
635            }
636            Event::MouseUp {
637                button: MouseButton::Left,
638                pos,
639                ..
640            } => {
641                if self.split_dragging {
642                    self.split_dragging = false;
643                    crate::animation::request_draw();
644                    return EventResult::Consumed;
645                }
646                if self.pos_in_tree_area(*pos) {
647                    return self.forward_to_tree(event);
648                }
649                EventResult::Ignored
650            }
651            Event::MouseWheel { pos, .. } if self.pos_in_tree_area(*pos) => {
652                self.forward_to_tree(event)
653            }
654            _ => EventResult::Ignored,
655        }
656    }
657}
658
659// ── properties pane (monolithic) ─────────────────────────────────────────────
660
661impl InspectorPanel {
662    fn paint_properties(&self, ctx: &mut dyn DrawCtx, available_h: f64) {
663        if available_h < 4.0 {
664            return;
665        }
666        let w = self.bounds.width;
667        let v = ctx.visuals().clone();
668
669        ctx.set_font(Arc::clone(&self.font));
670        ctx.set_font_size(10.0);
671        ctx.set_fill_color(c_dim_text(&v));
672        ctx.fill_text("PROPERTIES", 10.0, available_h - 14.0);
673
674        ctx.set_stroke_color(c_border(&v));
675        ctx.set_line_width(1.0);
676        ctx.begin_path();
677        ctx.move_to(10.0 + 70.0, available_h - 10.0);
678        ctx.line_to(w - 8.0, available_h - 10.0);
679        ctx.stroke();
680
681        let Some(sel_idx) = self.selected else {
682            ctx.set_font_size(FONT_SIZE);
683            ctx.set_fill_color(c_dim_text(&v));
684            ctx.fill_text("(select a widget)", 10.0, available_h - 36.0);
685            return;
686        };
687
688        let nodes = self.nodes.borrow();
689        let Some(node) = nodes.get(sel_idx) else {
690            return;
691        };
692
693        ctx.set_font_size(14.0);
694        ctx.set_fill_color(c_text(&v));
695        ctx.fill_text(node.type_name, 10.0, available_h - 36.0);
696
697        let b = &node.screen_bounds;
698        let rows: &[(&str, String)] = &[
699            ("x", format!("{:.1}", b.x)),
700            ("y", format!("{:.1}", b.y)),
701            ("width", format!("{:.1}", b.width)),
702            ("height", format!("{:.1}", b.height)),
703            ("depth", format!("{}", node.depth)),
704        ];
705
706        ctx.set_font_size(FONT_SIZE);
707        let row_start_y = available_h - 56.0;
708        for (i, (label, value)) in rows.iter().enumerate() {
709            let ry = row_start_y - i as f64 * 18.0;
710            if ry < 4.0 {
711                break;
712            }
713            ctx.set_fill_color(c_dim_text(&v));
714            ctx.fill_text(label, 12.0, ry);
715            ctx.set_fill_color(c_text(&v));
716            if let Some(m) = ctx.measure_text(value) {
717                ctx.fill_text(value, w - m.width - 10.0, ry);
718            }
719            ctx.set_stroke_color(c_border(&v));
720            ctx.set_line_width(0.5);
721            ctx.begin_path();
722            ctx.move_to(8.0, ry - 4.0);
723            ctx.line_to(w - 8.0, ry - 4.0);
724            ctx.stroke();
725        }
726
727        // Type-specific widget properties (from Widget::properties()).
728        let prop_start_y = row_start_y - rows.len() as f64 * 18.0 - 4.0;
729        for (j, (prop_label, prop_value)) in node.properties.iter().enumerate() {
730            let ry = prop_start_y - j as f64 * 18.0;
731            if ry < 4.0 {
732                break;
733            }
734            ctx.set_fill_color(c_dim_text(&v));
735            ctx.fill_text(prop_label, 12.0, ry);
736            // Bool properties: green=true, red=false; others use normal text color.
737            let is_bool = prop_value == "true" || prop_value == "false";
738            if is_bool {
739                let bool_color = if prop_value == "true" {
740                    Color::rgb(0.10, 0.52, 0.10)
741                } else {
742                    Color::rgb(0.65, 0.18, 0.18)
743                };
744                ctx.set_fill_color(bool_color);
745            } else {
746                ctx.set_fill_color(c_text(&v));
747            }
748            if let Some(m) = ctx.measure_text(prop_value) {
749                ctx.fill_text(prop_value, w - m.width - 10.0, ry);
750            }
751            ctx.set_stroke_color(c_border(&v));
752            ctx.set_line_width(0.5);
753            ctx.begin_path();
754            ctx.move_to(8.0, ry - 4.0);
755            ctx.line_to(w - 8.0, ry - 4.0);
756            ctx.stroke();
757        }
758
759        // Box-model mini diagram
760        let total_rows = rows.len() + node.properties.len();
761        let diag_h = (row_start_y - total_rows as f64 * 18.0 - 12.0).min(80.0);
762        if diag_h > 30.0 {
763            let diag_y_top = diag_h - 4.0;
764            let diag_w = w - 20.0;
765            let aspect = if b.height > 0.0 {
766                b.width / b.height
767            } else {
768                1.0
769            };
770            let box_h = (diag_h * 0.6).min(50.0);
771            let box_w = (box_h * aspect).min(diag_w * 0.8);
772            let box_x = 10.0 + (diag_w - box_w) * 0.5;
773            let box_y = diag_y_top - (diag_h + box_h) * 0.5;
774
775            ctx.set_fill_color(Color::rgba(0.10, 0.50, 1.0, 0.10));
776            ctx.begin_path();
777            ctx.rect(box_x, box_y, box_w, box_h);
778            ctx.fill();
779            ctx.set_stroke_color(Color::rgba(0.10, 0.50, 1.0, 0.50));
780            ctx.set_line_width(1.0);
781            ctx.begin_path();
782            ctx.rect(box_x, box_y, box_w, box_h);
783            ctx.stroke();
784
785            let dim = format!("{:.0} × {:.0}", b.width, b.height);
786            ctx.set_font_size(10.0);
787            ctx.set_fill_color(Color::rgba(0.10, 0.40, 0.90, 0.80));
788            if let Some(m) = ctx.measure_text(&dim) {
789                if m.width < box_w - 4.0 {
790                    ctx.fill_text(
791                        &dim,
792                        box_x + (box_w - m.width) * 0.5,
793                        box_y + (box_h - m.ascent - m.descent) * 0.5 + m.descent,
794                    );
795                }
796            }
797        }
798    }
799}