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        let tree_view = TreeView::new(Arc::clone(&font))
303            .with_row_height(20.0)
304            .with_font_size(12.0)
305            .with_indent_width(14.0)
306            .with_hover_repaint(false);
307        Self {
308            bounds: Rect::default(),
309            _children: vec![Box::new(InternalPresenceNode {
310                bounds: Rect::default(),
311                children: Vec::new(),
312                base: WidgetBase::new(),
313                name: "TreeView",
314            })],
315            base: WidgetBase::new(),
316            font,
317            nodes,
318            selected: None,
319            props_h: DEFAULT_PROPS_H,
320            split_dragging: false,
321            hovered_bounds,
322            tree_view,
323            #[cfg(feature = "reflect")]
324            edits: None,
325            base_edits: None,
326            prop_hits: Vec::new(),
327            pending_expanded: None,
328            pending_selected: None,
329            snapshot_out: None,
330            last_inspector_nodes_fingerprint: None,
331        }
332    }
333
334    /// Bind an output cell that the inspector updates every layout with
335    /// the current [`InspectorSavedState`] — use the cell from a harness
336    /// that persists app state.
337    pub fn with_snapshot_cell(mut self, cell: Rc<RefCell<Option<InspectorSavedState>>>) -> Self {
338        self.snapshot_out = Some(cell);
339        self
340    }
341
342    /// Bind a queue the inspector pushes [`crate::widget::WidgetBaseEdit`]s
343    /// into when the user edits margin, anchor, or size-constraint fields.
344    /// The host frame loop drains and applies via
345    /// [`crate::widget::apply_widget_base_edit`].
346    pub fn with_base_edit_queue(
347        mut self,
348        cell: Rc<RefCell<Vec<crate::widget::WidgetBaseEdit>>>,
349    ) -> Self {
350        self.base_edits = Some(cell);
351        self
352    }
353
354    /// Bind a queue the inspector pushes [`crate::widget::InspectorEdit`]s
355    /// into when the user clicks an editable property value.  The host frame
356    /// loop is responsible for draining and applying via
357    /// [`crate::widget::apply_inspector_edit`] — doing it inline would
358    /// violate the immutable-tree-during-event contract.
359    #[cfg(feature = "reflect")]
360    pub fn with_edit_queue(
361        mut self,
362        cell: Rc<RefCell<Vec<crate::widget::InspectorEdit>>>,
363    ) -> Self {
364        self.edits = Some(cell);
365        self
366    }
367
368    // ── Persistence helpers ──────────────────────────────────────────────────
369    //
370    // The platform harness calls `saved_state` at shutdown and
371    // `apply_saved_state` on startup so the inspector's tree expand /
372    // selection / split-bar position survive restarts.  Values are stored
373    // by the position they occupy in the flat DFS tree — if the widget
374    // tree differs across runs the worst case is a few extra collapsed
375    // nodes, never a crash.
376
377    /// Snapshot the current inspector UI state for persistence.
378    pub fn saved_state(&self) -> InspectorSavedState {
379        InspectorSavedState {
380            expanded: self.tree_view.nodes.iter().map(|n| n.is_expanded).collect(),
381            selected: self.tree_view.nodes.iter().position(|n| n.is_selected),
382            props_h: self.props_h,
383        }
384    }
385
386    /// Apply a previously-saved state.  Must be called before the first
387    /// `layout()` runs — the inspector restores the expand / select flags
388    /// from here when it first rebuilds the TreeView, via the `pending_*`
389    /// side channels.
390    pub fn apply_saved_state(&mut self, s: InspectorSavedState) {
391        self.pending_expanded = Some(s.expanded);
392        self.pending_selected = Some(s.selected);
393        self.props_h = s.props_h.clamp(MIN_PROPS_H, 1024.0);
394    }
395
396    // ── geometry helpers ──────────────────────────────────────────────────────
397
398    /// Height of the area below the header (tree + props).
399    fn list_area_h(&self) -> f64 {
400        (self.bounds.height - HEADER_H).max(0.0)
401    }
402
403    /// Y position of the tree/props split line (from panel bottom).
404    fn split_y(&self) -> f64 {
405        self.props_h.clamp(
406            MIN_PROPS_H,
407            (self.list_area_h() - MIN_TREE_H).max(MIN_PROPS_H),
408        )
409    }
410
411    /// Bottom Y of the tree area (just above the split handle).
412    fn tree_origin_y(&self) -> f64 {
413        self.split_y() + 4.0
414    }
415
416    fn on_split_handle(&self, pos: Point) -> bool {
417        let sy = self.split_y();
418        pos.y >= sy - SPLIT_HIT && pos.y <= sy + SPLIT_HIT
419    }
420
421    fn pos_in_tree_area(&self, pos: Point) -> bool {
422        let tree_bot = self.tree_origin_y();
423        let tree_top = self.list_area_h();
424        pos.y >= tree_bot && pos.y <= tree_top
425    }
426
427    /// Forward event to the TreeView, translating Y into tree-local coordinates.
428    fn forward_to_tree(&mut self, event: &Event) -> EventResult {
429        // tree_view.bounds().y is tree_origin_y() in panel-local space — subtracting
430        // it converts panel-local Y to TreeView-local Y (where y=0 is the bottom of
431        // the tree area).
432        let offset_y = self.tree_view.bounds().y;
433        let translated = translate_event(event, offset_y);
434        self.tree_view.on_event(&translated)
435    }
436
437    fn update_hovered_bounds_from_tree(&self) {
438        let nodes = self.nodes.borrow();
439        let next = self
440            .tree_view
441            .hovered_node_idx()
442            .and_then(|i| nodes.get(i))
443            .map(|n| InspectorOverlay {
444                bounds: n.screen_bounds,
445                margin: n.margin,
446                padding: n.padding,
447            });
448        let mut hovered = self.hovered_bounds.borrow_mut();
449        if *hovered != next {
450            *hovered = next;
451            crate::animation::request_draw_without_invalidation();
452        }
453    }
454}
455
456// ── Anchor cycle helpers ──────────────────────────────────────────────────────
457
458fn next_h_anchor(bits: u8) -> HAnchor {
459    // Cycle: FIT → STRETCH → LEFT → CENTER → RIGHT → FIT
460    if bits == HAnchor::FIT.bits()     { HAnchor::STRETCH }
461    else if bits == HAnchor::STRETCH.bits() { HAnchor::LEFT }
462    else if bits == HAnchor::LEFT.bits()    { HAnchor::CENTER }
463    else if bits == HAnchor::CENTER.bits()  { HAnchor::RIGHT }
464    else                                    { HAnchor::FIT }
465}
466
467fn next_v_anchor(bits: u8) -> VAnchor {
468    // Cycle: FIT → STRETCH → BOTTOM → CENTER → TOP → FIT
469    if bits == VAnchor::FIT.bits()     { VAnchor::STRETCH }
470    else if bits == VAnchor::STRETCH.bits() { VAnchor::BOTTOM }
471    else if bits == VAnchor::BOTTOM.bits()  { VAnchor::CENTER }
472    else if bits == VAnchor::CENTER.bits()  { VAnchor::TOP }
473    else                                    { VAnchor::FIT }
474}
475
476// ── Widget impl ───────────────────────────────────────────────────────────────
477
478impl InspectorPanel {
479    pub fn with_margin(mut self, m: Insets) -> Self {
480        self.base.margin = m;
481        self
482    }
483    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
484        self.base.h_anchor = h;
485        self
486    }
487    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
488        self.base.v_anchor = v;
489        self
490    }
491    pub fn with_min_size(mut self, s: Size) -> Self {
492        self.base.min_size = s;
493        self
494    }
495    pub fn with_max_size(mut self, s: Size) -> Self {
496        self.base.max_size = s;
497        self
498    }
499}
500
501// ── properties pane ──────────────────────────────────────────────────────────
502// Implementation lives in `inspector_props.rs` to keep this file under the
503// project line limit.
504
505impl InspectorPanel {
506    fn paint_properties(&mut self, ctx: &mut dyn DrawCtx, available_h: f64) {
507        let panel_y_offset = 0.0; // properties pane sits at panel-local y=0
508        self.prop_hits.clear();
509        super::inspector_props::paint_properties(
510            ctx,
511            available_h,
512            panel_y_offset,
513            self.bounds.width,
514            &self.font,
515            self.selected,
516            &self.nodes.borrow(),
517            &mut self.prop_hits,
518        );
519    }
520
521    /// Test a click against the property rows for WidgetBase fields (margin,
522    /// anchor).  Returns `true` if a `WidgetBaseEdit` was queued.  Not gated
523    /// on `reflect` — uses `widget_base_mut` which is always available.
524    fn try_emit_base_edit_from_click(&self, pos: Point) -> bool {
525        let Some(queue) = &self.base_edits else {
526            return false;
527        };
528        let Some(sel_idx) = self.selected else {
529            return false;
530        };
531        let nodes = self.nodes.borrow();
532        let Some(node) = nodes.get(sel_idx) else {
533            return false;
534        };
535        let Some(hit) = self.prop_hits.iter().find(|h| {
536            pos.x >= h.rect.x
537                && pos.x <= h.rect.x + h.rect.width
538                && pos.y >= h.rect.y
539                && pos.y <= h.rect.y + h.rect.height
540        }) else {
541            return false;
542        };
543        let field = match &hit.kind {
544            PropHitKind::InsetField {
545                target: InsetsTarget::Margin,
546                side,
547                current,
548                step,
549            } => {
550                let mid = hit.rect.x + hit.rect.width * 0.5;
551                let new_v = (if pos.x < mid {
552                    *current - *step
553                } else {
554                    *current + *step
555                })
556                .max(0.0);
557                match side {
558                    InsetsSide::Left => crate::widget::WidgetBaseField::MarginLeft(new_v),
559                    InsetsSide::Right => crate::widget::WidgetBaseField::MarginRight(new_v),
560                    InsetsSide::Top => crate::widget::WidgetBaseField::MarginTop(new_v),
561                    InsetsSide::Bottom => crate::widget::WidgetBaseField::MarginBottom(new_v),
562                }
563            }
564            PropHitKind::HAnchorCycle { current_bits } => {
565                crate::widget::WidgetBaseField::HAnchor(next_h_anchor(*current_bits))
566            }
567            PropHitKind::VAnchorCycle { current_bits } => {
568                crate::widget::WidgetBaseField::VAnchor(next_v_anchor(*current_bits))
569            }
570            _ => return false,
571        };
572        queue.borrow_mut().push(crate::widget::WidgetBaseEdit {
573            path: node.path.clone(),
574            field,
575        });
576        crate::animation::request_draw();
577        true
578    }
579
580    /// Test a panel-local click against the cached property-row rectangles
581    /// painted last frame.  Returns true if the click produced a queued edit.
582    #[cfg(feature = "reflect")]
583    fn try_emit_edit_from_click(&self, pos: Point) -> bool {
584        let Some(queue) = &self.edits else { return false };
585        let Some(sel_idx) = self.selected else {
586            return false;
587        };
588        let nodes = self.nodes.borrow();
589        let Some(node) = nodes.get(sel_idx) else {
590            return false;
591        };
592        let Some(hit) = self
593            .prop_hits
594            .iter()
595            .find(|h| pos.x >= h.rect.x
596                && pos.x <= h.rect.x + h.rect.width
597                && pos.y >= h.rect.y
598                && pos.y <= h.rect.y + h.rect.height)
599        else {
600            return false;
601        };
602
603        let edit = match &hit.kind {
604            PropHitKind::BoolToggle { current } => crate::widget::InspectorEdit {
605                path: node.path.clone(),
606                field_path: hit.field.clone(),
607                new_value: Box::new(!*current),
608            },
609            PropHitKind::NumericStep { current, step } => {
610                let mid = hit.rect.x + hit.rect.width * 0.5;
611                let new_v = if pos.x < mid {
612                    *current - *step
613                } else {
614                    *current + *step
615                };
616                crate::widget::InspectorEdit {
617                    path: node.path.clone(),
618                    field_path: hit.field.clone(),
619                    new_value: Box::new(new_v),
620                }
621            }
622            _ => return false,
623        };
624        queue.borrow_mut().push(edit);
625        crate::animation::request_draw();
626        true
627    }
628}
629