Skip to main content

agg_gui/widgets/inspector/
mod.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
13mod widget_impl;
14
15use std::cell::RefCell;
16use std::rc::Rc;
17use std::sync::Arc;
18
19use crate::color::Color;
20use crate::draw_ctx::DrawCtx;
21use crate::event::{Event, EventResult};
22use crate::geometry::{Point, Rect, Size};
23use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
24use crate::text::Font;
25use crate::widget::{InspectorNode, InspectorOverlay, Widget};
26use crate::widgets::tree_view::TreeView;
27
28// ── InternalPresenceNode ──────────────────────────────────────────────────────
29
30/// Transparent placeholder child representing the inspector's internal `TreeView`
31/// in the widget inspector tree.
32///
33/// This makes `InspectorPanel` appear as an expandable node (with one child) in
34/// the inspector rather than a leaf, so the user can see that the panel contains
35/// an internal tree.
36///
37/// Hit-test is always `false` (no event interception).  Paint is a no-op (the
38/// real `TreeView` is painted directly by `InspectorPanel`).
39/// `contributes_children_to_inspector` returns `false` to stop the inspector
40/// from recursing into row_widgets, which would grow the node list exponentially.
41///
42/// Bounds are kept in sync with the real `TreeView` by `InspectorPanel::layout`.
43struct InternalPresenceNode {
44    bounds: Rect,
45    children: Vec<Box<dyn Widget>>,
46    base: WidgetBase,
47    name: &'static str,
48}
49
50impl Widget for InternalPresenceNode {
51    fn type_name(&self) -> &'static str {
52        self.name
53    }
54    fn bounds(&self) -> Rect {
55        self.bounds
56    }
57    fn set_bounds(&mut self, b: Rect) {
58        self.bounds = b;
59    }
60    fn children(&self) -> &[Box<dyn Widget>] {
61        &self.children
62    }
63    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
64        &mut self.children
65    }
66    fn margin(&self) -> Insets {
67        self.base.margin
68    }
69    fn widget_base(&self) -> Option<&WidgetBase> {
70        Some(&self.base)
71    }
72    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
73        Some(&mut self.base)
74    }
75    fn h_anchor(&self) -> HAnchor {
76        self.base.h_anchor
77    }
78    fn v_anchor(&self) -> VAnchor {
79        self.base.v_anchor
80    }
81    fn min_size(&self) -> Size {
82        self.base.min_size
83    }
84    fn max_size(&self) -> Size {
85        self.base.max_size
86    }
87    fn layout(&mut self, _: Size) -> Size {
88        Size::new(self.bounds.width, self.bounds.height)
89    }
90    fn paint(&mut self, _: &mut dyn DrawCtx) {}
91    fn hit_test(&self, _: Point) -> bool {
92        false
93    }
94    fn on_event(&mut self, _: &Event) -> EventResult {
95        EventResult::Ignored
96    }
97    fn contributes_children_to_inspector(&self) -> bool {
98        false
99    }
100}
101
102// ── geometry constants ────────────────────────────────────────────────────────
103const DEFAULT_PROPS_H: f64 = 180.0;
104pub(super) const FONT_SIZE: f64 = 12.0;
105const HEADER_H: f64 = 30.0;
106const SPLIT_HIT: f64 = 5.0;
107const MIN_PROPS_H: f64 = 60.0;
108const MIN_TREE_H: f64 = 60.0;
109
110// ── light theme colors ────────────────────────────────────────────────────────
111// Theme-aware colour helpers — all derive from the active `Visuals` so the
112// inspector follows light / dark mode changes without a restart.
113fn c_panel_bg(v: &crate::theme::Visuals) -> Color {
114    v.panel_fill
115}
116fn c_header_bg(v: &crate::theme::Visuals) -> Color {
117    // Slightly darker than the panel fill.
118    let f = if is_dark(v) { 0.80 } else { 0.94 };
119    Color::rgba(
120        v.panel_fill.r * f,
121        v.panel_fill.g * f,
122        v.panel_fill.b * f,
123        1.0,
124    )
125}
126fn c_props_bg(v: &crate::theme::Visuals) -> Color {
127    v.window_fill
128}
129fn c_split_bg(v: &crate::theme::Visuals) -> Color {
130    let t = if is_dark(v) { 1.0 } else { 0.0 };
131    Color::rgba(t, t, t, 0.10)
132}
133pub(super) fn c_border(v: &crate::theme::Visuals) -> Color {
134    v.separator
135}
136pub(super) fn c_text(v: &crate::theme::Visuals) -> Color {
137    v.text_color
138}
139pub(super) fn c_dim_text(v: &crate::theme::Visuals) -> Color {
140    v.text_dim
141}
142
143fn is_dark(v: &crate::theme::Visuals) -> bool {
144    // Panel fill luminance — below 0.5 means we're in a dark palette.
145    let lum = 0.299 * v.panel_fill.r + 0.587 * v.panel_fill.g + 0.114 * v.panel_fill.b;
146    lum < 0.5
147}
148
149// ── event translation helper ──────────────────────────────────────────────────
150
151/// Translate the Y coordinate of a mouse event by subtracting `offset_y`.
152/// X is unchanged. Non-mouse events pass through unchanged.
153fn translate_event(event: &Event, offset_y: f64) -> Event {
154    match event {
155        Event::MouseDown {
156            pos,
157            button,
158            modifiers,
159        } => Event::MouseDown {
160            pos: Point::new(pos.x, pos.y - offset_y),
161            button: *button,
162            modifiers: *modifiers,
163        },
164        Event::MouseMove { pos } => Event::MouseMove {
165            pos: Point::new(pos.x, pos.y - offset_y),
166        },
167        Event::MouseUp {
168            pos,
169            button,
170            modifiers,
171        } => Event::MouseUp {
172            pos: Point::new(pos.x, pos.y - offset_y),
173            button: *button,
174            modifiers: *modifiers,
175        },
176        Event::MouseWheel {
177            pos,
178            delta_y,
179            delta_x,
180            modifiers,
181        } => Event::MouseWheel {
182            pos: Point::new(pos.x, pos.y - offset_y),
183            delta_y: *delta_y,
184            delta_x: *delta_x,
185            modifiers: *modifiers,
186        },
187        other => other.clone(),
188    }
189}
190
191// ── InspectorPanel ────────────────────────────────────────────────────────────
192
193pub struct InspectorPanel {
194    bounds: Rect,
195    /// Contains exactly one `InternalPresenceNode` (a transparent proxy for the
196    /// internal `TreeView`).  This makes InspectorPanel non-leaf in the inspector
197    /// so the user can see it has internal structure.
198    _children: Vec<Box<dyn Widget>>,
199    base: WidgetBase,
200    font: Arc<Font>,
201    nodes: Rc<RefCell<Vec<InspectorNode>>>,
202    /// Selected: original node index; synced from TreeView selection.
203    selected: Option<usize>,
204    props_h: f64,
205    split_dragging: bool,
206    /// Written by layout(); read by the render loop to draw an overlay.
207    pub hovered_bounds: Rc<RefCell<Option<InspectorOverlay>>>,
208    /// The tree widget, managed directly (not in children).
209    pub(crate) tree_view: TreeView,
210    /// Set by `apply_saved_state`; consumed on the next layout rebuild so
211    /// restored expand / select flags apply even on the very first frame
212    /// (before the user has interacted with the tree).
213    pending_expanded: Option<Vec<bool>>,
214    pending_selected: Option<Option<usize>>,
215    /// When bound, each `layout()` writes the current state into this cell
216    /// so the harness can persist it without needing mutable access to the
217    /// widget tree.
218    snapshot_out: Option<Rc<RefCell<Option<InspectorSavedState>>>>,
219    /// Edit queue — clicks on reflected property rows push
220    /// [`crate::widget::InspectorEdit`] entries here; the host frame loop
221    /// drains and applies them via [`crate::widget::apply_inspector_edit`].
222    /// Holds mouse-down coords so the click handler in `on_event` can locate
223    /// which property row was hit when the layout next paints.
224    #[cfg(feature = "reflect")]
225    pub edits: Option<Rc<RefCell<Vec<crate::widget::InspectorEdit>>>>,
226    /// Queue for WidgetBase live-edits (margin, anchors).  Not gated on
227    /// `reflect` — available on every widget via `widget_base_mut`.
228    pub base_edits: Option<Rc<RefCell<Vec<crate::widget::WidgetBaseEdit>>>>,
229    /// Cached row hit-rectangles built during paint (panel-local bounds);
230    /// each entry is `(rect, field_name, row_kind)`.  Used by `on_event` to
231    /// translate a click to a queued edit.
232    prop_hits: Vec<PropHit>,
233    /// Fingerprint of the `inspector_nodes` Vec we last rebuilt the
234    /// `TreeView` from — `(data ptr as usize, len)`.  When the harness
235    /// skips its snapshot pass (e.g. during a window-resize drag), the
236    /// Vec is reused and the ptr stays the same; we then skip the
237    /// per-frame `tree_view.nodes` rebuild too, so the tree's row
238    /// widgets reuse their backbuffers and the resize stays cheap.
239    last_inspector_nodes_fingerprint: Option<(usize, usize)>,
240}
241
242#[derive(Clone, Debug)]
243#[cfg_attr(not(feature = "reflect"), allow(dead_code))]
244pub(super) struct PropHit {
245    pub(super) rect: Rect,
246    pub(super) field: String,
247    pub(super) kind: PropHitKind,
248}
249
250#[derive(Clone, Debug)]
251pub(super) enum PropHitKind {
252    /// Clicking flips the bool (widget-specific reflected field).
253    #[cfg_attr(not(feature = "reflect"), allow(dead_code))]
254    BoolToggle { current: bool },
255    /// Clicking the left half decrements, right half increments (reflected field).
256    #[cfg_attr(not(feature = "reflect"), allow(dead_code))]
257    NumericStep { current: f64, step: f64 },
258    /// Click left half → subtract step from an Insets side; right half → add.
259    InsetField {
260        target: InsetsTarget,
261        side: InsetsSide,
262        current: f64,
263        step: f64,
264    },
265    /// Click anywhere on the row to advance to the next HAnchor preset.
266    HAnchorCycle { current_bits: u8 },
267    /// Click anywhere on the row to advance to the next VAnchor preset.
268    VAnchorCycle { current_bits: u8 },
269}
270
271/// Which Insets struct the edit targets.
272#[derive(Clone, Copy, Debug)]
273pub(super) enum InsetsTarget {
274    Margin,
275    // Padding editing not yet supported (stored differently per container).
276}
277
278/// Which side of the Insets to update.
279#[derive(Clone, Copy, Debug)]
280pub(super) enum InsetsSide {
281    Left,
282    Right,
283    Top,
284    Bottom,
285}
286
287/// Serializable inspector UI state — apply at startup, snapshot at shutdown.
288#[derive(Clone, Debug, Default)]
289#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
290pub struct InspectorSavedState {
291    pub expanded: Vec<bool>,
292    pub selected: Option<usize>,
293    pub props_h: f64,
294}
295
296impl InspectorPanel {
297    pub fn new(
298        font: Arc<Font>,
299        nodes: Rc<RefCell<Vec<InspectorNode>>>,
300        hovered_bounds: Rc<RefCell<Option<InspectorOverlay>>>,
301    ) -> Self {
302        // `hover_repaint(true)` (the TreeView default) lets a row-hover
303        // change return `Consumed`, which bumps the invalidation epoch
304        // and marks the inspector's parent Window backbuffer dirty so
305        // the new row's hover background actually appears on the next
306        // frame.  Decoupling `hovered_row` from `row_content_signature`
307        // means this no longer costs a row-widget rebuild, so the old
308        // `with_hover_repaint(false)` performance opt-out is obsolete.
309        let tree_view = TreeView::new(Arc::clone(&font))
310            .with_row_height(20.0)
311            .with_font_size(12.0)
312            .with_indent_width(14.0);
313        Self {
314            bounds: Rect::default(),
315            _children: vec![Box::new(InternalPresenceNode {
316                bounds: Rect::default(),
317                children: Vec::new(),
318                base: WidgetBase::new(),
319                name: "TreeView",
320            })],
321            base: WidgetBase::new(),
322            font,
323            nodes,
324            selected: None,
325            props_h: DEFAULT_PROPS_H,
326            split_dragging: false,
327            hovered_bounds,
328            tree_view,
329            #[cfg(feature = "reflect")]
330            edits: None,
331            base_edits: None,
332            prop_hits: Vec::new(),
333            pending_expanded: None,
334            pending_selected: None,
335            snapshot_out: None,
336            last_inspector_nodes_fingerprint: None,
337        }
338    }
339
340    /// Bind an output cell that the inspector updates every layout with
341    /// the current [`InspectorSavedState`] — use the cell from a harness
342    /// that persists app state.
343    pub fn with_snapshot_cell(mut self, cell: Rc<RefCell<Option<InspectorSavedState>>>) -> Self {
344        self.snapshot_out = Some(cell);
345        self
346    }
347
348    /// Bind a queue the inspector pushes [`crate::widget::WidgetBaseEdit`]s
349    /// into when the user edits margin, anchor, or size-constraint fields.
350    /// The host frame loop drains and applies via
351    /// [`crate::widget::apply_widget_base_edit`].
352    pub fn with_base_edit_queue(
353        mut self,
354        cell: Rc<RefCell<Vec<crate::widget::WidgetBaseEdit>>>,
355    ) -> Self {
356        self.base_edits = Some(cell);
357        self
358    }
359
360    /// Bind a queue the inspector pushes [`crate::widget::InspectorEdit`]s
361    /// into when the user clicks an editable property value.  The host frame
362    /// loop is responsible for draining and applying via
363    /// [`crate::widget::apply_inspector_edit`] — doing it inline would
364    /// violate the immutable-tree-during-event contract.
365    #[cfg(feature = "reflect")]
366    pub fn with_edit_queue(mut self, cell: Rc<RefCell<Vec<crate::widget::InspectorEdit>>>) -> Self {
367        self.edits = Some(cell);
368        self
369    }
370
371    // ── Persistence helpers ──────────────────────────────────────────────────
372    //
373    // The platform harness calls `saved_state` at shutdown and
374    // `apply_saved_state` on startup so the inspector's tree expand /
375    // selection / split-bar position survive restarts.  Values are stored
376    // by the position they occupy in the flat DFS tree — if the widget
377    // tree differs across runs the worst case is a few extra collapsed
378    // nodes, never a crash.
379
380    /// Snapshot the current inspector UI state for persistence.
381    pub fn saved_state(&self) -> InspectorSavedState {
382        InspectorSavedState {
383            expanded: self.tree_view.nodes.iter().map(|n| n.is_expanded).collect(),
384            selected: self.tree_view.nodes.iter().position(|n| n.is_selected),
385            props_h: self.props_h,
386        }
387    }
388
389    /// Apply a previously-saved state.  Must be called before the first
390    /// `layout()` runs — the inspector restores the expand / select flags
391    /// from here when it first rebuilds the TreeView, via the `pending_*`
392    /// side channels.
393    pub fn apply_saved_state(&mut self, s: InspectorSavedState) {
394        self.pending_expanded = Some(s.expanded);
395        self.pending_selected = Some(s.selected);
396        self.props_h = s.props_h.clamp(MIN_PROPS_H, 1024.0);
397    }
398
399    // ── geometry helpers ──────────────────────────────────────────────────────
400
401    /// Height of the area below the header (tree + props).
402    fn list_area_h(&self) -> f64 {
403        (self.bounds.height - HEADER_H).max(0.0)
404    }
405
406    /// Y position of the tree/props split line (from panel bottom).
407    fn split_y(&self) -> f64 {
408        self.props_h.clamp(
409            MIN_PROPS_H,
410            (self.list_area_h() - MIN_TREE_H).max(MIN_PROPS_H),
411        )
412    }
413
414    /// Bottom Y of the tree area (just above the split handle).
415    fn tree_origin_y(&self) -> f64 {
416        self.split_y() + 4.0
417    }
418
419    fn on_split_handle(&self, pos: Point) -> bool {
420        let sy = self.split_y();
421        pos.y >= sy - SPLIT_HIT && pos.y <= sy + SPLIT_HIT
422    }
423
424    fn pos_in_tree_area(&self, pos: Point) -> bool {
425        let tree_bot = self.tree_origin_y();
426        let tree_top = self.list_area_h();
427        pos.y >= tree_bot && pos.y <= tree_top
428    }
429
430    /// Forward event to the TreeView, translating Y into tree-local coordinates.
431    fn forward_to_tree(&mut self, event: &Event) -> EventResult {
432        // tree_view.bounds().y is tree_origin_y() in panel-local space — subtracting
433        // it converts panel-local Y to TreeView-local Y (where y=0 is the bottom of
434        // the tree area).
435        let offset_y = self.tree_view.bounds().y;
436        let translated = translate_event(event, offset_y);
437        self.tree_view.on_event(&translated)
438    }
439
440    fn update_hovered_bounds_from_tree(&self) {
441        let nodes = self.nodes.borrow();
442        let next = self
443            .tree_view
444            .hovered_node_idx()
445            .and_then(|i| nodes.get(i))
446            .map(|n| InspectorOverlay {
447                bounds: n.screen_bounds,
448                margin: n.margin,
449                padding: n.padding,
450            });
451        let mut hovered = self.hovered_bounds.borrow_mut();
452        if *hovered != next {
453            *hovered = next;
454            crate::animation::request_draw_without_invalidation();
455        }
456    }
457}
458
459// ── Anchor cycle helpers ──────────────────────────────────────────────────────
460
461fn next_h_anchor(bits: u8) -> HAnchor {
462    // Cycle: FIT → STRETCH → LEFT → CENTER → RIGHT → FIT
463    if bits == HAnchor::FIT.bits() {
464        HAnchor::STRETCH
465    } else if bits == HAnchor::STRETCH.bits() {
466        HAnchor::LEFT
467    } else if bits == HAnchor::LEFT.bits() {
468        HAnchor::CENTER
469    } else if bits == HAnchor::CENTER.bits() {
470        HAnchor::RIGHT
471    } else {
472        HAnchor::FIT
473    }
474}
475
476fn next_v_anchor(bits: u8) -> VAnchor {
477    // Cycle: FIT → STRETCH → BOTTOM → CENTER → TOP → FIT
478    if bits == VAnchor::FIT.bits() {
479        VAnchor::STRETCH
480    } else if bits == VAnchor::STRETCH.bits() {
481        VAnchor::BOTTOM
482    } else if bits == VAnchor::BOTTOM.bits() {
483        VAnchor::CENTER
484    } else if bits == VAnchor::CENTER.bits() {
485        VAnchor::TOP
486    } else {
487        VAnchor::FIT
488    }
489}
490
491// ── Widget impl ───────────────────────────────────────────────────────────────
492
493impl InspectorPanel {
494    pub fn with_margin(mut self, m: Insets) -> Self {
495        self.base.margin = m;
496        self
497    }
498    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
499        self.base.h_anchor = h;
500        self
501    }
502    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
503        self.base.v_anchor = v;
504        self
505    }
506    pub fn with_min_size(mut self, s: Size) -> Self {
507        self.base.min_size = s;
508        self
509    }
510    pub fn with_max_size(mut self, s: Size) -> Self {
511        self.base.max_size = s;
512        self
513    }
514}
515
516// ── properties pane ──────────────────────────────────────────────────────────
517// Implementation lives in `inspector_props.rs` to keep this file under the
518// project line limit.
519
520impl InspectorPanel {
521    fn paint_properties(&mut self, ctx: &mut dyn DrawCtx, available_h: f64) {
522        let panel_y_offset = 0.0; // properties pane sits at panel-local y=0
523        self.prop_hits.clear();
524        super::inspector_props::paint_properties(
525            ctx,
526            available_h,
527            panel_y_offset,
528            self.bounds.width,
529            &self.font,
530            self.selected,
531            &self.nodes.borrow(),
532            &mut self.prop_hits,
533        );
534    }
535
536    /// Test a click against the property rows for WidgetBase fields (margin,
537    /// anchor).  Returns `true` if a `WidgetBaseEdit` was queued.  Not gated
538    /// on `reflect` — uses `widget_base_mut` which is always available.
539    fn try_emit_base_edit_from_click(&self, pos: Point) -> bool {
540        let Some(queue) = &self.base_edits else {
541            return false;
542        };
543        let Some(sel_idx) = self.selected else {
544            return false;
545        };
546        let nodes = self.nodes.borrow();
547        let Some(node) = nodes.get(sel_idx) else {
548            return false;
549        };
550        let Some(hit) = self.prop_hits.iter().find(|h| {
551            pos.x >= h.rect.x
552                && pos.x <= h.rect.x + h.rect.width
553                && pos.y >= h.rect.y
554                && pos.y <= h.rect.y + h.rect.height
555        }) else {
556            return false;
557        };
558        let field = match &hit.kind {
559            PropHitKind::InsetField {
560                target: InsetsTarget::Margin,
561                side,
562                current,
563                step,
564            } => {
565                let mid = hit.rect.x + hit.rect.width * 0.5;
566                let new_v = (if pos.x < mid {
567                    *current - *step
568                } else {
569                    *current + *step
570                })
571                .max(0.0);
572                match side {
573                    InsetsSide::Left => crate::widget::WidgetBaseField::MarginLeft(new_v),
574                    InsetsSide::Right => crate::widget::WidgetBaseField::MarginRight(new_v),
575                    InsetsSide::Top => crate::widget::WidgetBaseField::MarginTop(new_v),
576                    InsetsSide::Bottom => crate::widget::WidgetBaseField::MarginBottom(new_v),
577                }
578            }
579            PropHitKind::HAnchorCycle { current_bits } => {
580                crate::widget::WidgetBaseField::HAnchor(next_h_anchor(*current_bits))
581            }
582            PropHitKind::VAnchorCycle { current_bits } => {
583                crate::widget::WidgetBaseField::VAnchor(next_v_anchor(*current_bits))
584            }
585            _ => return false,
586        };
587        queue.borrow_mut().push(crate::widget::WidgetBaseEdit {
588            path: node.path.clone(),
589            field,
590        });
591        crate::animation::request_draw();
592        true
593    }
594
595    /// Test a panel-local click against the cached property-row rectangles
596    /// painted last frame.  Returns true if the click produced a queued edit.
597    #[cfg(feature = "reflect")]
598    fn try_emit_edit_from_click(&self, pos: Point) -> bool {
599        let Some(queue) = &self.edits else {
600            return false;
601        };
602        let Some(sel_idx) = self.selected else {
603            return false;
604        };
605        let nodes = self.nodes.borrow();
606        let Some(node) = nodes.get(sel_idx) else {
607            return false;
608        };
609        let Some(hit) = self.prop_hits.iter().find(|h| {
610            pos.x >= h.rect.x
611                && pos.x <= h.rect.x + h.rect.width
612                && pos.y >= h.rect.y
613                && pos.y <= h.rect.y + h.rect.height
614        }) else {
615            return false;
616        };
617
618        let edit = match &hit.kind {
619            PropHitKind::BoolToggle { current } => crate::widget::InspectorEdit {
620                path: node.path.clone(),
621                field_path: hit.field.clone(),
622                new_value: Box::new(!*current),
623            },
624            PropHitKind::NumericStep { current, step } => {
625                let mid = hit.rect.x + hit.rect.width * 0.5;
626                let new_v = if pos.x < mid {
627                    *current - *step
628                } else {
629                    *current + *step
630                };
631                crate::widget::InspectorEdit {
632                    path: node.path.clone(),
633                    field_path: hit.field.clone(),
634                    new_value: Box::new(new_v),
635                }
636            }
637            _ => return false,
638        };
639        queue.borrow_mut().push(edit);
640        crate::animation::request_draw();
641        true
642    }
643}