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