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//! ## Select and open
27//!
28//! Every tree reads a row the same way: a single click SELECTS the row's object
29//! ([`NodeResponse::clicked`]) and a double click OPENS its dialog
30//! ([`NodeResponse::double_clicked`]). A selected row is drawn with the
31//! selection band behind its whole width ([`TreeRow::highlighted`]), the look
32//! the column trees give theirs.
33//!
34//! This module has NO engine dependency — it is pure egui + geometry, so a later
35//! `brep-ui` crate (and the theme pass, #49) can reuse it unchanged.
36
37use crate::icon_text::IconText;
38use eframe::egui;
39
40/// One tree indentation level, in egui points.
41pub const INDENT: f32 = 16.0;
42/// The collapse box's side length.
43const BOX: f32 = 13.0;
44/// Gap between the box slot, an optional glyph, and the label.
45const GAP: f32 = 4.0;
46/// Right-side gutter reserved before the right-aligned row content (timing,
47/// delete X, field inputs, a Select button) so those controls clear the sidebar
48/// scroll bar instead of underlapping it — an underlapping X eats the click as a
49/// scroll drag rather than firing.
50const RIGHT_PAD: f32 = 10.0;
51
52/// The spec for one tree row. Borrows its strings; holds no state (expansion is
53/// the caller's — see the module docs).
54pub struct TreeRow<'a> {
55    /// Ancestor vertical-guide flags, one per level strictly above this node
56    /// (`true` = a sibling line passes through this row at that level). The
57    /// node's own connector column index is `guides.len()`.
58    pub guides: &'a [bool],
59    /// This node is the LAST among its siblings (`└` vs `├`). Ignored when `root`.
60    pub is_last: bool,
61    /// Draw a `[+]`/`[-]` collapse box (an expandable node) instead of an empty
62    /// box slot (a leaf). A leaf keeps the same label indent so labels align.
63    pub expandable: bool,
64    /// Current expand state — only meaningful when `expandable`.
65    pub expanded: bool,
66    /// The far-left ROOT row (e.g. `Features`): no connector, box at column 0.
67    pub root: bool,
68    /// An optional per-type glyph drawn between the box slot and the label.
69    pub glyph: Option<&'a str>,
70    /// The row's text label.
71    pub label: &'a str,
72    /// Emphasize the label (the selected node).
73    pub selected: bool,
74    /// Paint the selection band behind the whole row — the selected LINE.
75    /// [`Self::selected`] sets it with the emphasis; a column tree, which paints
76    /// its own band across every column, draws its tree cell without it.
77    pub highlighted: bool,
78    /// Make the label a drag handle (`Sense::click_and_drag`) — drag-reorder.
79    pub draggable: bool,
80    /// Override the label's text color (the sketch panel paints a CONFLICTING
81    /// constraint's row red). `None` keeps the theme's text color, including its
82    /// hover/selection behavior.
83    pub tint: Option<egui::Color32>,
84}
85
86impl<'a> TreeRow<'a> {
87    /// A plain expandable branch node.
88    pub fn branch(guides: &'a [bool], is_last: bool, expanded: bool, label: &'a str) -> Self {
89        Self {
90            guides,
91            is_last,
92            expandable: true,
93            expanded,
94            root: false,
95            glyph: None,
96            label,
97            selected: false,
98            highlighted: false,
99            draggable: false,
100            tint: None,
101        }
102    }
103
104    /// A plain leaf node (no collapse box).
105    pub fn leaf(guides: &'a [bool], is_last: bool, label: &'a str) -> Self {
106        Self {
107            guides,
108            is_last,
109            expandable: false,
110            expanded: false,
111            root: false,
112            glyph: None,
113            label,
114            selected: false,
115            highlighted: false,
116            draggable: false,
117            tint: None,
118        }
119    }
120
121    pub fn glyph(mut self, glyph: Option<&'a str>) -> Self {
122        self.glyph = glyph;
123        self
124    }
125    /// Mark the row selected: its label emphasized and its line highlighted.
126    pub fn selected(mut self, selected: bool) -> Self {
127        self.selected = selected;
128        self.highlighted = selected;
129        self
130    }
131    pub fn draggable(mut self, draggable: bool) -> Self {
132        self.draggable = draggable;
133        self
134    }
135    pub fn tint(mut self, tint: Option<egui::Color32>) -> Self {
136        self.tint = tint;
137        self
138    }
139}
140
141/// What happened to a drawn tree row.
142pub struct NodeResponse {
143    /// The whole row rect (for hit publishing / drag-target hit-testing).
144    pub row_rect: egui::Rect,
145    /// The collapse-box rect (for hit publishing).
146    pub box_rect: egui::Rect,
147    /// The `[+]`/`[-]` collapse box was clicked (a pure toggle intent).
148    pub toggled: bool,
149    /// The label response — the DRAG handle and the hover source. Use
150    /// `.drag_started()` / `.drag_stopped()` to reorder and `.hovered()` for a
151    /// text-keyed highlight. For "the row was clicked" use [`Self::clicked`],
152    /// not this: the label's rect is only its own text.
153    pub label: egui::Response,
154    /// The whole ROW BAND, sensing clicks BELOW everything drawn in the row.
155    /// egui hands an overlapped point to the last widget registered there and
156    /// this one is registered first (`UiBuilder::sense`), so the collapse box,
157    /// the label and every control the caller put in `add_right` still take
158    /// their own clicks — the band only ever receives what none of them wanted,
159    /// which is exactly the blank space between them.
160    ///
161    /// It is also the row's KEYBOARD stop: the label is deliberately not
162    /// focusable, so a row is one Tab stop and Enter/Space on it reports
163    /// `clicked()` here.
164    ///
165    /// It senses DRAG as well as click, and it has to: egui withdraws the click
166    /// from a press that outlives `max_click_duration` (0.8 s) or drifts past
167    /// `max_click_dist` (6 pt) whether or not the widget senses drag, so a
168    /// click-only band would go dead under exactly the slow, wobbly press an
169    /// ordinary hand makes. A caller resolves that press through
170    /// [`Self::drag_started`], as the history and constraint trees do.
171    pub band: egui::Response,
172    /// The BAND's drag started, and its press did not begin on one of the row's
173    /// own controls (the collapse box, anything `add_right` drew). Read through
174    /// [`Self::drag_started`].
175    pub band_drag_started: bool,
176}
177
178impl NodeResponse {
179    /// The ROW was clicked — on its text or anywhere else in its band that no
180    /// control of its own claimed. This, not `label.clicked()`, is what
181    /// "the user picked this row" means: a row is a click target across its
182    /// whole width, and a label's rect is only as wide as its text. A click
183    /// SELECTS the row's object.
184    pub fn clicked(&self) -> bool {
185        self.label.clicked() || self.band.clicked()
186    }
187
188    /// The ROW was double-clicked, over the same area as [`Self::clicked`]. A
189    /// double click OPENS the row's dialog. egui reports the second press as a
190    /// click as well, so on this frame [`Self::clicked`] is true too: a
191    /// consumer checks this first.
192    ///
193    /// egui's TRIPLE click counts. egui numbers a click 3 when the click two
194    /// before it is under 0.6 s old, and a 3 is not a 2 — so a row clicked once
195    /// and then double-clicked a moment later would otherwise never open.
196    pub fn double_clicked(&self) -> bool {
197        is_double_click(&self.label) || is_double_click(&self.band)
198    }
199}
200
201/// A response's double click, egui's triple included — see
202/// [`NodeResponse::double_clicked`]. The column trees read their row bands
203/// through this too.
204pub fn is_double_click(response: &egui::Response) -> bool {
205    response.double_clicked() || response.triple_clicked()
206}
207
208impl NodeResponse {
209
210    /// A press on the row turned into a DRAG — on the label or anywhere else
211    /// along the band. A pane that reorders by drag arms itself from this; a
212    /// pane that does not still wants it, because a press egui reclassified as
213    /// a drag is the click it refused to report, and resolving it is the only
214    /// way a slow click on a row still opens that row.
215    pub fn drag_started(&self) -> bool {
216        self.label.drag_started() || self.band_drag_started
217    }
218}
219
220/// Extend a guide stack for a node's children: the node's own connector column
221/// keeps drawing a vertical line past the children ONLY if the node has more
222/// siblings below it (`!is_last`).
223pub fn child_guides(guides: &[bool], is_last: bool) -> Vec<bool> {
224    let mut next = guides.to_vec();
225    next.push(!is_last);
226    next
227}
228
229/// Draw ONE tree row and return what was interacted with. `add_right` fills the
230/// right-aligned content area (timing + delete, field inputs, a Select button…).
231///
232/// The whole row is a click target: read [`NodeResponse::clicked`], which is the
233/// band and the label together. [`NodeResponse::label`] remains the reorder
234/// drag handle and the source of a text-keyed hover.
235pub fn node(
236    ui: &mut egui::Ui,
237    row: TreeRow,
238    add_right: impl FnOnce(&mut egui::Ui),
239) -> NodeResponse {
240    let connector_col = if row.root { 0 } else { row.guides.len() };
241    // The box slot (and thus the label) begins one column right of the connector.
242    let indent = if row.root {
243        0.0
244    } else {
245        (connector_col as f32 + 1.0) * INDENT
246    };
247
248    let mut box_rect = egui::Rect::NOTHING;
249    let mut toggled = false;
250    let mut label_resp: Option<egui::Response> = None;
251    // The selection band's slot, reserved BEFORE the row so the row's contents
252    // paint over it; filled once the row's rect is known.
253    let highlight = ui.painter().add(egui::Shape::Noop);
254    // The row's OWN controls — the collapse box and whatever `add_right` drew.
255    // A press that starts on one of those belongs to it, never to the band.
256    let mut controls = egui::Rect::NOTHING;
257
258    // THE ROW BAND. A row is a click target across its whole width, not only
259    // across its text — so the row is drawn inside a `Ui` that senses clicks
260    // itself. `UiBuilder::sense` registers that rect BEFORE the row's contents
261    // and updates it in place when the scope closes, and egui's hit test hands
262    // an overlapped point to the LAST widget registered there, so every control
263    // inside the row (the collapse box, the label, whatever `add_right` draws)
264    // still takes its own clicks and the band receives only what none of them
265    // wanted. Allocating it AFTER the row instead would invert that and the
266    // band would eat the delete button.
267    let outer = ui.scope_builder(
268        egui::UiBuilder::new().sense(egui::Sense::click_and_drag()),
269        |ui| {
270            ui.horizontal(|ui| {
271                // Zero the inter-item spacing so the geometry math is exact; gaps are
272                // added explicitly below.
273                ui.spacing_mut().item_spacing.x = 0.0;
274                if indent > 0.0 {
275                    ui.add_space(indent);
276                }
277
278                // --- collapse box (expandable) or an equally-wide empty slot (leaf) ----
279                let (brect, bresp) = ui.allocate_exact_size(
280                    crate::viewport::touch::target_size(ui.ctx(), egui::vec2(BOX, BOX)),
281                    if row.expandable {
282                        egui::Sense::click()
283                    } else {
284                        egui::Sense::hover()
285                    },
286                );
287                box_rect = brect;
288                if row.expandable {
289                    let painted = egui::Rect::from_center_size(brect.center(), egui::Vec2::splat(BOX));
290                    paint_box(ui, painted, row.expanded);
291                    if bresp.clicked() {
292                        toggled = true;
293                    }
294                }
295                ui.add_space(GAP);
296
297                // --- optional per-type glyph ------------------------------------------
298                // Drawn as catalogued artwork, sized to the row's text. Monochrome
299                // artwork is tinted to the row's text colour, so it reads exactly as the
300                // old font glyph did; colour artwork is left alone. Anything
301                // uncatalogued falls back to text.
302                if let Some(glyph) = row.glyph {
303                    match crate::icons::artwork(glyph) {
304                        Some(icon) => {
305                            // A tree row can be the first thing on screen to draw an
306                            // SVG; `install_image_loaders` is idempotent.
307                            egui_extras::install_image_loaders(ui.ctx());
308                            let height = ui.text_style_height(&egui::TextStyle::Body);
309                            let mut art = crate::icon_text::image(icon, height);
310                            if icon.mono {
311                                art = art.tint(ui.visuals().text_color());
312                            }
313                            ui.add(art);
314                        }
315                        None => {
316                            ui.label(egui::RichText::new(glyph).color(ui.visuals().text_color()));
317                        }
318                    }
319                    ui.add_space(GAP);
320                }
321
322                // --- label (the click / drag handle) ----------------------------------
323                let mut text = egui::RichText::new(row.label);
324                if row.selected {
325                    text = text.strong();
326                }
327                if let Some(tint) = row.tint {
328                    text = text.color(tint);
329                }
330                // Click (and, on a reorder handle, drag) but NOT focus: the row's Tab
331                // stop is the band, which covers the whole row rather than just this
332                // text. Two focusable widgets per row would make Tab visit every row
333                // twice and put the keyboard's idea of "the row" on its label.
334                let sense = if row.draggable {
335                    egui::Sense::CLICK | egui::Sense::DRAG
336                } else {
337                    egui::Sense::CLICK
338                };
339                // A DRAGGABLE row is a reorder handle, so its label must NOT be
340                // text-selectable: egui labels are selectable by default, and a press on a
341                // selectable label anchors a text selection that the ensuing reorder drag
342                // then SWEEPS across every row the cursor passes over (the passed-over rows
343                // "light up"). Making only the drag handle non-selectable means the reorder
344                // press never anchors a selection, so no sweep — while every non-draggable
345                // row (fields, groups, scene/settings leaves) keeps default selectable text.
346                // Hover coloring is unaffected: it is computed from the response independent
347                // of `selectable` (see egui `Label`), so ordinary non-drag hover is unchanged.
348                // A label can carry catalogued glyphs of its own — the sketch panel
349                // appends ⛓ / ◐ / ⏚ marks to entity rows — and with no icon font left,
350                // drawing those as text would render a box. `icon_label` draws them from
351                // the same SVG catalog the glyph column uses.
352                //
353                // A DRAGGABLE row keeps the plain `Label`: it is the reorder handle, and
354                // it must stay ONE widget whose response is the drag. Nothing needs
355                // both — a draggable row's glyph goes in the glyph column above, not
356                // inline — and `has_icon` is false for those labels, so this only ever
357                // takes the icon path for the rows that need it.
358                let has_icon = row.label.chars().any(crate::icons::has);
359
360                // --- right-aligned content, THEN the label ----------------------------
361                // The right content is allocated FIRST, from the panel's right edge, and
362                // the label truncates into whatever it leaves — the order the form's
363                // reference lines and the form view's title row already use, for the
364                // same reason. A row's label is not a UI string the app chose: a scene
365                // row is labelled with a SOLID's name, as long as the modelling history
366                // made it. Laid out label-first, at its natural width, a 76-character
367                // name pushed the visibility checkbox 202 pt past the edge of a 320 pt
368                // panel — off the panel, where an ancestor clip eats the click too.
369                //
370                // Both label flavours take a wrap mode, so neither can overflow: the
371                // plain `Label` is the drag handle and keeps its `sense`, and `IconText`
372                // truncates the same way (a row whose label carries ⛓ / ◐ / ⏚ marks).
373                // A label SHORTER than the band still measures its own text, which is
374                // exactly why the row's click target and published rect are the BAND
375                // and not this: a two-word name left four fifths of its row dead.
376                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
377                    ui.add_space(RIGHT_PAD);
378                    add_right(ui);
379                    // What the CALLER put at the row's right edge, as one rect.
380                    // A right-to-left `Ui` grows leftwards from the panel edge,
381                    // so its min_rect here — after the gutter and the caller's
382                    // controls, before the label — is exactly that strip.
383                    controls = ui.min_rect();
384                    ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
385                        label_resp = Some(if has_icon && !row.draggable {
386                            IconText::new(text)
387                                .sense(sense)
388                                .selectable(false)
389                                .truncate()
390                                .show(ui)
391                        } else {
392                            ui.add(
393                                egui::Label::new(text)
394                                    .sense(sense)
395                                    .selectable(!row.draggable)
396                                    .truncate(),
397                            )
398                        });
399                    });
400                });
401            })
402        },
403    );
404    let band = outer.response;
405    if row.highlighted && !row.root {
406        ui.painter().set(
407            highlight,
408            egui::epaint::RectShape::filled(
409                band.rect,
410                0.0,
411                ui.visuals().selection.bg_fill.gamma_multiply(0.35),
412            ),
413        );
414    }
415
416    let row_rect = outer.inner.response.rect;
417    if !row.root {
418        paint_connectors(ui, row_rect, row.guides, connector_col, row.is_last);
419    }
420    // The row is the keyboard stop, so say so when it holds the focus: nothing
421    // else in the row paints one now that the label is not focusable, and a
422    // focus nobody can see is a focus nobody can use.
423    if band.has_focus() {
424        ui.painter().rect_stroke(
425            row_rect,
426            2.0,
427            ui.visuals().selection.stroke,
428            egui::StrokeKind::Inside,
429        );
430    }
431
432    // A band drag whose press STARTED on one of the row's own controls is that
433    // control's press, not a row drag. egui reports both — a click-sensing
434    // button on top and the drag-sensing band beneath are "interested in
435    // different things", so `hit_test` hands back both hits — and without this
436    // the edit button became a reorder handle the moment the hand moved.
437    let press = ui.input(|i| i.pointer.press_origin());
438    let from_a_control = press
439        .is_some_and(|at| box_rect.contains(at) || controls.contains(at));
440    let band_drag_started = band.drag_started() && !from_a_control;
441
442    NodeResponse {
443        row_rect,
444        box_rect,
445        toggled,
446        label: label_resp.expect("label always drawn"),
447        band,
448        band_drag_started,
449    }
450}
451
452/// Draw a LEAF whose content is a multi-line, WRAPPING colored message — e.g. a
453/// feature's error text shown under its header. Unlike [`node`] (a single
454/// non-wrapping row), the label WRAPS to the available width and the row grows as
455/// tall as it needs; the `└`/`├` connector anchors to the FIRST line so it still
456/// reads as a normal child leaf under its parent. Returns the full row rect.
457pub fn message_leaf(
458    ui: &mut egui::Ui,
459    guides: &[bool],
460    is_last: bool,
461    text: &str,
462    color: egui::Color32,
463) -> egui::Rect {
464    let connector_col = guides.len();
465    // Align the wrapped text with where a LEAF's label starts: indent to the box
466    // slot column, then clear the (empty) box slot + gap.
467    let indent = (connector_col as f32 + 1.0) * INDENT + BOX + GAP;
468    let line_h = ui.text_style_height(&egui::TextStyle::Body);
469    let inner = ui.horizontal(|ui| {
470        ui.spacing_mut().item_spacing.x = 0.0;
471        ui.add_space(indent);
472        // A vertical child claims the remaining row; the wrapping label wraps to its
473        // width (minus the right gutter so it clears the sidebar scroll bar).
474        ui.vertical(|ui| {
475            ui.set_max_width((ui.available_width() - RIGHT_PAD).max(40.0));
476            ui.add(
477                egui::Label::new(egui::RichText::new(text).color(color))
478                    .wrap_mode(egui::TextWrapMode::Wrap),
479            );
480        });
481    });
482    let row_rect = inner.response.rect;
483    let tick_y = row_rect.top() + line_h * 0.5;
484    paint_connectors_at(ui, row_rect, guides, connector_col, is_last, tick_y);
485    row_rect
486}
487
488/// Paint a `[+]`/`[-]` collapse box: a rounded stroked square with a minus bar
489/// (expanded) plus a vertical bar (collapsed → a plus). Theme-aware.
490fn paint_box(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
491    let painter = ui.painter();
492    let stroke = egui::Stroke::new(1.0, line_color(ui));
493    painter.rect(
494        rect,
495        egui::CornerRadius::same(2),
496        egui::Color32::TRANSPARENT,
497        stroke,
498        egui::StrokeKind::Inside,
499    );
500    let c = rect.center();
501    let arm = rect.width() * 0.28;
502    let sign = egui::Stroke::new(1.4, ui.visuals().text_color());
503    // horizontal bar (always → the minus of a `[-]`)
504    painter.line_segment(
505        [egui::pos2(c.x - arm, c.y), egui::pos2(c.x + arm, c.y)],
506        sign,
507    );
508    // vertical bar (only when collapsed → completes the plus of a `[+]`)
509    if !expanded {
510        painter.line_segment(
511            [egui::pos2(c.x, c.y - arm), egui::pos2(c.x, c.y + arm)],
512            sign,
513        );
514    }
515}
516
517/// Paint the ancestor guide lines + this node's `├`/`└` connector into the row's
518/// left gutter (over-drawn by the inter-row spacing so verticals join seamlessly
519/// across rows). The `├`/`└` tick lands at the row's vertical center.
520fn paint_connectors(
521    ui: &egui::Ui,
522    row: egui::Rect,
523    guides: &[bool],
524    connector_col: usize,
525    is_last: bool,
526) {
527    paint_connectors_at(ui, row, guides, connector_col, is_last, row.center().y);
528}
529
530/// Like [`paint_connectors`] but with an explicit `tick_y` for the `├`/`└` join —
531/// so a MULTI-LINE row (a wrapping message leaf) can anchor its connector to its
532/// FIRST line instead of the block's vertical center.
533fn paint_connectors_at(
534    ui: &egui::Ui,
535    row: egui::Rect,
536    guides: &[bool],
537    connector_col: usize,
538    is_last: bool,
539    mid: f32,
540) {
541    let painter = ui.painter();
542    let stroke = egui::Stroke::new(1.0, line_color(ui));
543    let sp = ui.spacing().item_spacing.y + 1.0;
544    let left = row.left();
545    let top = row.top() - sp;
546    let bottom = row.bottom() + sp;
547    let col_x = |c: usize| left + c as f32 * INDENT + INDENT * 0.5;
548
549    // Ancestor sibling lines passing through this row.
550    for (i, on) in guides.iter().enumerate() {
551        if *on {
552            let x = col_x(i);
553            painter.line_segment([egui::pos2(x, top), egui::pos2(x, bottom)], stroke);
554        }
555    }
556    // This node's own connector: vertical down to mid (└) or through (├), plus a
557    // horizontal tick reaching the box slot.
558    let x = col_x(connector_col);
559    let v_bottom = if is_last { mid } else { bottom };
560    painter.line_segment([egui::pos2(x, top), egui::pos2(x, v_bottom)], stroke);
561    painter.line_segment(
562        [egui::pos2(x, mid), egui::pos2(x + INDENT * 0.5, mid)],
563        stroke,
564    );
565}
566
567/// The subtle connector / box stroke color — the theme's non-interactive
568/// foreground, dimmed. Theme-aware (light + dark) and the seam the later theme
569/// pass tunes in one place.
570fn line_color(ui: &egui::Ui) -> egui::Color32 {
571    ui.visuals()
572        .widgets
573        .noninteractive
574        .fg_stroke
575        .color
576        .gamma_multiply(0.7)
577}
578