Skip to main content

brep_app/panels/
tree.rs

1//! A reusable, custom-painted TREE-NODE widget — the shared building block for
2//! the engine-native side panels (the history feature tree here; the Scene tree
3//! and others reuse it verbatim). egui's default `CollapsingHeader` draws a
4//! disclosure TRIANGLE and no connector rules; the design reference is a classic
5//! file-tree with **`[+]`/`[-]` collapse boxes + connector lines**, so this
6//! module paints those itself.
7//!
8//! # The node model (immutable-mode friendly)
9//!
10//! A node is ONE row: `⟨connectors⟩ [+/-] ⟨glyph⟩ ⟨label⟩ … ⟨right content⟩`.
11//! Expansion state is OWNED BY THE CALLER (the panels need exclusive-expand +
12//! roll-to side effects), so [`node`] just draws a row and returns what was
13//! clicked; the caller decides whether to recurse into children.
14//!
15//! ## Connector geometry
16//!
17//! Indentation is one [`INDENT`] column per tree level. A node at connector
18//! column `d = guides.len()` draws its `├`/`└` connector at column `d` and its
19//! box slot at column `d + 1`. `guides[i]` (`i < d`) says whether an ANCESTOR's
20//! sibling line passes vertically through this row at column `i`. Descending into
21//! a node's children extends the guide stack by [`child_guides`] with
22//! `!this_node_is_last` — the node's own connector line continues down past its
23//! children only if it has following siblings. This is the standard file-tree
24//! rule and yields the reference's exact rules.
25//!
26//! This module has NO engine dependency — it is pure egui + geometry, so a later
27//! `brep-ui` crate (and the theme pass, #49) can reuse it unchanged.
28
29use crate::icon_text::IconText;
30use eframe::egui;
31
32/// One tree indentation level, in egui points.
33pub const INDENT: f32 = 16.0;
34/// The collapse box's side length.
35const BOX: f32 = 13.0;
36/// Gap between the box slot, an optional glyph, and the label.
37const GAP: f32 = 4.0;
38/// Right-side gutter reserved before the right-aligned row content (timing,
39/// delete X, field inputs, a Select button) so those controls clear the sidebar
40/// scroll bar instead of underlapping it — an underlapping X eats the click as a
41/// scroll drag rather than firing.
42const RIGHT_PAD: f32 = 10.0;
43
44/// The spec for one tree row. Borrows its strings; holds no state (expansion is
45/// the caller's — see the module docs).
46pub struct TreeRow<'a> {
47    /// Ancestor vertical-guide flags, one per level strictly above this node
48    /// (`true` = a sibling line passes through this row at that level). The
49    /// node's own connector column index is `guides.len()`.
50    pub guides: &'a [bool],
51    /// This node is the LAST among its siblings (`└` vs `├`). Ignored when `root`.
52    pub is_last: bool,
53    /// Draw a `[+]`/`[-]` collapse box (an expandable node) instead of an empty
54    /// box slot (a leaf). A leaf keeps the same label indent so labels align.
55    pub expandable: bool,
56    /// Current expand state — only meaningful when `expandable`.
57    pub expanded: bool,
58    /// The far-left ROOT row (e.g. `Features`): no connector, box at column 0.
59    pub root: bool,
60    /// An optional per-type glyph drawn between the box slot and the label.
61    pub glyph: Option<&'a str>,
62    /// The row's text label.
63    pub label: &'a str,
64    /// Emphasize the label (the rolled-to / selected node).
65    pub selected: bool,
66    /// Make the label a drag handle (`Sense::click_and_drag`) — drag-reorder.
67    pub draggable: bool,
68}
69
70impl<'a> TreeRow<'a> {
71    /// A plain expandable branch node.
72    pub fn branch(guides: &'a [bool], is_last: bool, expanded: bool, label: &'a str) -> Self {
73        Self {
74            guides,
75            is_last,
76            expandable: true,
77            expanded,
78            root: false,
79            glyph: None,
80            label,
81            selected: false,
82            draggable: false,
83        }
84    }
85
86    /// A plain leaf node (no collapse box).
87    pub fn leaf(guides: &'a [bool], is_last: bool, label: &'a str) -> Self {
88        Self {
89            guides,
90            is_last,
91            expandable: false,
92            expanded: false,
93            root: false,
94            glyph: None,
95            label,
96            selected: false,
97            draggable: false,
98        }
99    }
100
101    pub fn glyph(mut self, glyph: Option<&'a str>) -> Self {
102        self.glyph = glyph;
103        self
104    }
105    pub fn selected(mut self, selected: bool) -> Self {
106        self.selected = selected;
107        self
108    }
109    pub fn draggable(mut self, draggable: bool) -> Self {
110        self.draggable = draggable;
111        self
112    }
113}
114
115/// What happened to a drawn tree row.
116pub struct NodeResponse {
117    /// The whole row rect (for hit publishing / drag-target hit-testing).
118    pub row_rect: egui::Rect,
119    /// The collapse-box rect (for hit publishing).
120    pub box_rect: egui::Rect,
121    /// The `[+]`/`[-]` collapse box was clicked (a pure toggle intent).
122    pub toggled: bool,
123    /// The label response — the click/drag handle. Use `.clicked()` to select,
124    /// `.drag_started()` / `.drag_stopped()` to reorder.
125    pub label: egui::Response,
126}
127
128/// Extend a guide stack for a node's children: the node's own connector column
129/// keeps drawing a vertical line past the children ONLY if the node has more
130/// siblings below it (`!is_last`).
131pub fn child_guides(guides: &[bool], is_last: bool) -> Vec<bool> {
132    let mut next = guides.to_vec();
133    next.push(!is_last);
134    next
135}
136
137/// Draw ONE tree row and return what was interacted with. `add_right` fills the
138/// right-aligned content area (timing + delete, field inputs, a Select button…).
139pub fn node(
140    ui: &mut egui::Ui,
141    row: TreeRow,
142    add_right: impl FnOnce(&mut egui::Ui),
143) -> NodeResponse {
144    let connector_col = if row.root { 0 } else { row.guides.len() };
145    // The box slot (and thus the label) begins one column right of the connector.
146    let indent = if row.root {
147        0.0
148    } else {
149        (connector_col as f32 + 1.0) * INDENT
150    };
151
152    let mut box_rect = egui::Rect::NOTHING;
153    let mut toggled = false;
154    let mut label_resp: Option<egui::Response> = None;
155
156    let inner = ui.horizontal(|ui| {
157        // Zero the inter-item spacing so the geometry math is exact; gaps are
158        // added explicitly below.
159        ui.spacing_mut().item_spacing.x = 0.0;
160        if indent > 0.0 {
161            ui.add_space(indent);
162        }
163
164        // --- collapse box (expandable) or an equally-wide empty slot (leaf) ----
165        let (brect, bresp) = ui.allocate_exact_size(
166            egui::vec2(BOX, BOX),
167            if row.expandable {
168                egui::Sense::click()
169            } else {
170                egui::Sense::hover()
171            },
172        );
173        box_rect = brect;
174        if row.expandable {
175            paint_box(ui, brect, row.expanded);
176            if bresp.clicked() {
177                toggled = true;
178            }
179        }
180        ui.add_space(GAP);
181
182        // --- optional per-type glyph ------------------------------------------
183        // Drawn as catalogued artwork, sized to the row's text. Monochrome
184        // artwork is tinted to the row's text colour, so it reads exactly as the
185        // old font glyph did; colour artwork is left alone. Anything
186        // uncatalogued falls back to text.
187        if let Some(glyph) = row.glyph {
188            match crate::icons::artwork(glyph) {
189                Some(icon) => {
190                    // A tree row can be the first thing on screen to draw an
191                    // SVG; `install_image_loaders` is idempotent.
192                    egui_extras::install_image_loaders(ui.ctx());
193                    let height = ui.text_style_height(&egui::TextStyle::Body);
194                    let mut art = crate::icon_text::image(icon, height);
195                    if icon.mono {
196                        art = art.tint(ui.visuals().text_color());
197                    }
198                    ui.add(art);
199                }
200                None => {
201                    ui.label(egui::RichText::new(glyph).color(ui.visuals().text_color()));
202                }
203            }
204            ui.add_space(GAP);
205        }
206
207        // --- label (the click / drag handle) ----------------------------------
208        let mut text = egui::RichText::new(row.label);
209        if row.selected {
210            text = text.strong();
211        }
212        let sense = if row.draggable {
213            egui::Sense::click_and_drag()
214        } else {
215            egui::Sense::click()
216        };
217        // A DRAGGABLE row is a reorder handle, so its label must NOT be
218        // text-selectable: egui labels are selectable by default, and a press on a
219        // selectable label anchors a text selection that the ensuing reorder drag
220        // then SWEEPS across every row the cursor passes over (the passed-over rows
221        // "light up"). Making only the drag handle non-selectable means the reorder
222        // press never anchors a selection, so no sweep — while every non-draggable
223        // row (fields, groups, scene/settings leaves) keeps default selectable text.
224        // Hover coloring is unaffected: it is computed from the response independent
225        // of `selectable` (see egui `Label`), so ordinary non-drag hover is unchanged.
226        // A label can carry catalogued glyphs of its own — the sketch panel
227        // appends ⛓ / ◐ / ⏚ marks to entity rows — and with no icon font left,
228        // drawing those as text would render a box. `icon_label` draws them from
229        // the same SVG catalog the glyph column uses.
230        //
231        // A DRAGGABLE row keeps the plain `Label`: it is the reorder handle, and
232        // it must stay ONE widget whose response is the drag. Nothing needs
233        // both — a draggable row's glyph goes in the glyph column above, not
234        // inline — and `has_icon` is false for those labels, so this only ever
235        // takes the icon path for the rows that need it.
236        let has_icon = row.label.chars().any(crate::icons::has);
237        label_resp = Some(if has_icon && !row.draggable {
238            IconText::new(text).sense(sense).selectable(false).show(ui)
239        } else {
240            ui.add(egui::Label::new(text).sense(sense).selectable(!row.draggable))
241        });
242
243        // --- right-aligned content --------------------------------------------
244        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
245            ui.add_space(RIGHT_PAD);
246            add_right(ui);
247        });
248    });
249
250    let row_rect = inner.response.rect;
251    if !row.root {
252        paint_connectors(ui, row_rect, row.guides, connector_col, row.is_last);
253    }
254
255    NodeResponse {
256        row_rect,
257        box_rect,
258        toggled,
259        label: label_resp.expect("label always drawn"),
260    }
261}
262
263/// Draw a LEAF whose content is a multi-line, WRAPPING colored message — e.g. a
264/// feature's error text shown under its header. Unlike [`node`] (a single
265/// non-wrapping row), the label WRAPS to the available width and the row grows as
266/// tall as it needs; the `└`/`├` connector anchors to the FIRST line so it still
267/// reads as a normal child leaf under its parent. Returns the full row rect.
268pub fn message_leaf(
269    ui: &mut egui::Ui,
270    guides: &[bool],
271    is_last: bool,
272    text: &str,
273    color: egui::Color32,
274) -> egui::Rect {
275    let connector_col = guides.len();
276    // Align the wrapped text with where a LEAF's label starts: indent to the box
277    // slot column, then clear the (empty) box slot + gap.
278    let indent = (connector_col as f32 + 1.0) * INDENT + BOX + GAP;
279    let line_h = ui.text_style_height(&egui::TextStyle::Body);
280    let inner = ui.horizontal(|ui| {
281        ui.spacing_mut().item_spacing.x = 0.0;
282        ui.add_space(indent);
283        // A vertical child claims the remaining row; the wrapping label wraps to its
284        // width (minus the right gutter so it clears the sidebar scroll bar).
285        ui.vertical(|ui| {
286            ui.set_max_width((ui.available_width() - RIGHT_PAD).max(40.0));
287            ui.add(
288                egui::Label::new(egui::RichText::new(text).color(color))
289                    .wrap_mode(egui::TextWrapMode::Wrap),
290            );
291        });
292    });
293    let row_rect = inner.response.rect;
294    let tick_y = row_rect.top() + line_h * 0.5;
295    paint_connectors_at(ui, row_rect, guides, connector_col, is_last, tick_y);
296    row_rect
297}
298
299/// Paint a `[+]`/`[-]` collapse box: a rounded stroked square with a minus bar
300/// (expanded) plus a vertical bar (collapsed → a plus). Theme-aware.
301fn paint_box(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
302    let painter = ui.painter();
303    let stroke = egui::Stroke::new(1.0, line_color(ui));
304    painter.rect(
305        rect,
306        egui::CornerRadius::same(2),
307        egui::Color32::TRANSPARENT,
308        stroke,
309        egui::StrokeKind::Inside,
310    );
311    let c = rect.center();
312    let arm = rect.width() * 0.28;
313    let sign = egui::Stroke::new(1.4, ui.visuals().text_color());
314    // horizontal bar (always → the minus of a `[-]`)
315    painter.line_segment(
316        [egui::pos2(c.x - arm, c.y), egui::pos2(c.x + arm, c.y)],
317        sign,
318    );
319    // vertical bar (only when collapsed → completes the plus of a `[+]`)
320    if !expanded {
321        painter.line_segment(
322            [egui::pos2(c.x, c.y - arm), egui::pos2(c.x, c.y + arm)],
323            sign,
324        );
325    }
326}
327
328/// Paint the ancestor guide lines + this node's `├`/`└` connector into the row's
329/// left gutter (over-drawn by the inter-row spacing so verticals join seamlessly
330/// across rows). The `├`/`└` tick lands at the row's vertical center.
331fn paint_connectors(
332    ui: &egui::Ui,
333    row: egui::Rect,
334    guides: &[bool],
335    connector_col: usize,
336    is_last: bool,
337) {
338    paint_connectors_at(ui, row, guides, connector_col, is_last, row.center().y);
339}
340
341/// Like [`paint_connectors`] but with an explicit `tick_y` for the `├`/`└` join —
342/// so a MULTI-LINE row (a wrapping message leaf) can anchor its connector to its
343/// FIRST line instead of the block's vertical center.
344fn paint_connectors_at(
345    ui: &egui::Ui,
346    row: egui::Rect,
347    guides: &[bool],
348    connector_col: usize,
349    is_last: bool,
350    mid: f32,
351) {
352    let painter = ui.painter();
353    let stroke = egui::Stroke::new(1.0, line_color(ui));
354    let sp = ui.spacing().item_spacing.y + 1.0;
355    let left = row.left();
356    let top = row.top() - sp;
357    let bottom = row.bottom() + sp;
358    let col_x = |c: usize| left + c as f32 * INDENT + INDENT * 0.5;
359
360    // Ancestor sibling lines passing through this row.
361    for (i, on) in guides.iter().enumerate() {
362        if *on {
363            let x = col_x(i);
364            painter.line_segment([egui::pos2(x, top), egui::pos2(x, bottom)], stroke);
365        }
366    }
367    // This node's own connector: vertical down to mid (└) or through (├), plus a
368    // horizontal tick reaching the box slot.
369    let x = col_x(connector_col);
370    let v_bottom = if is_last { mid } else { bottom };
371    painter.line_segment([egui::pos2(x, top), egui::pos2(x, v_bottom)], stroke);
372    painter.line_segment(
373        [egui::pos2(x, mid), egui::pos2(x + INDENT * 0.5, mid)],
374        stroke,
375    );
376}
377
378/// The subtle connector / box stroke color — the theme's non-interactive
379/// foreground, dimmed. Theme-aware (light + dark) and the seam the later theme
380/// pass tunes in one place.
381fn line_color(ui: &egui::Ui) -> egui::Color32 {
382    ui.visuals()
383        .widgets
384        .noninteractive
385        .fg_stroke
386        .color
387        .gamma_multiply(0.7)
388}
389
390#[cfg(test)]
391mod tests {
392    //! Guard the drag-reorder polish: a DRAGGABLE row is a reorder handle, so its
393    //! label must not be text-selectable — otherwise the press that begins a
394    //! reorder also anchors an egui label text-selection that the ensuing drag
395    //! SWEEPS across every passed-over row (the reported "rows I mouse over get
396    //! highlighted" bug). These drive the REAL `node` widget through raw egui
397    //! frames (like the scene/settings panel tests) and read egui's own
398    //! `LabelSelectionState` — the exact signal, no pixels (the wgpu canvas
399    //! screenshots black headless, so a visual assert is impossible).
400    use super::*;
401    use eframe::egui;
402    use std::cell::RefCell;
403    use std::rc::Rc;
404
405    /// Draw `rows` (label, draggable) as tree nodes for one frame, feeding
406    /// `events`; capture each row's label rect so the caller can aim pointer
407    /// events at the label text.
408    fn frame(
409        ctx: &egui::Context,
410        rows: &[(&str, bool)],
411        events: Vec<egui::Event>,
412        rects: &Rc<RefCell<Vec<egui::Rect>>>,
413    ) {
414        let raw = egui::RawInput {
415            screen_rect: Some(egui::Rect::from_min_size(
416                egui::pos2(0.0, 0.0),
417                egui::vec2(400.0, 300.0),
418            )),
419            events,
420            ..Default::default()
421        };
422        let _ = ctx.run_ui(raw, |ui| {
423            ui.spacing_mut().item_spacing.y = 2.0;
424            let mut rs = rects.borrow_mut();
425            rs.clear();
426            let n = rows.len();
427            for (i, (label, draggable)) in rows.iter().enumerate() {
428                let resp = node(
429                    ui,
430                    TreeRow::branch(&[], i + 1 == n, false, label).draggable(*draggable),
431                    |_| {},
432                );
433                rs.push(resp.label.rect);
434            }
435        });
436    }
437
438    /// Is any egui label text-selection currently active?
439    fn has_label_selection(ctx: &egui::Context) -> bool {
440        ctx.plugin::<egui::text_selection::LabelSelectionState>()
441            .lock()
442            .has_selection()
443    }
444
445    /// Press on the first row's label and drag DOWN across the rows below WITHOUT
446    /// releasing (far enough to cross egui's decidedly-dragging threshold), then
447    /// report whether a label text-selection formed.
448    fn press_drag_from_row0(ctx: &egui::Context, rows: &[(&str, bool)]) -> bool {
449        let rects = Rc::new(RefCell::new(Vec::new()));
450        frame(ctx, rows, vec![], &rects); // learn the rects
451        let (start, end_y) = {
452            let rs = rects.borrow();
453            (rs[0].center(), rs[rs.len() - 1].center().y)
454        };
455        frame(
456            ctx,
457            rows,
458            vec![
459                egui::Event::PointerMoved(start),
460                egui::Event::PointerButton {
461                    pos: start,
462                    button: egui::PointerButton::Primary,
463                    pressed: true,
464                    modifiers: egui::Modifiers::default(),
465                },
466            ],
467            &rects,
468        );
469        for k in 1..=8 {
470            let y = start.y + (end_y - start.y) * (k as f32 / 8.0);
471            frame(ctx, rows, vec![egui::Event::PointerMoved(egui::pos2(start.x, y))], &rects);
472        }
473        has_label_selection(ctx)
474    }
475
476    #[test]
477    fn reorder_handle_press_drag_does_not_text_select_passed_over_rows() {
478        // Real history layout: a draggable feature handle above selectable field
479        // leaves. Dragging the handle must anchor NO selection to sweep.
480        let ctx = egui::Context::default();
481        let rows = [("FeatureA", true), ("field one", false), ("field two", false)];
482        assert!(
483            !press_drag_from_row0(&ctx, &rows),
484            "dragging a reorder handle must not create/ sweep a label text selection"
485        );
486    }
487
488    #[test]
489    fn selectable_non_drag_row_still_text_selects_on_press_drag() {
490        // Positive control / shared-helper no-regression: a NON-draggable row is
491        // still selectable, so the same press-drag DOES text-select — proving the
492        // suppression is scoped to drag handles, not a global kill of selection.
493        let ctx = egui::Context::default();
494        let rows = [("field one", false), ("field two", false), ("field three", false)];
495        assert!(
496            press_drag_from_row0(&ctx, &rows),
497            "a normal selectable tree row must still support text selection"
498        );
499    }
500}