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                    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                    paint_box(ui, brect, row.expanded);
290                    if bresp.clicked() {
291                        toggled = true;
292                    }
293                }
294                ui.add_space(GAP);
295
296                // --- optional per-type glyph ------------------------------------------
297                // Drawn as catalogued artwork, sized to the row's text. Monochrome
298                // artwork is tinted to the row's text colour, so it reads exactly as the
299                // old font glyph did; colour artwork is left alone. Anything
300                // uncatalogued falls back to text.
301                if let Some(glyph) = row.glyph {
302                    match crate::icons::artwork(glyph) {
303                        Some(icon) => {
304                            // A tree row can be the first thing on screen to draw an
305                            // SVG; `install_image_loaders` is idempotent.
306                            egui_extras::install_image_loaders(ui.ctx());
307                            let height = ui.text_style_height(&egui::TextStyle::Body);
308                            let mut art = crate::icon_text::image(icon, height);
309                            if icon.mono {
310                                art = art.tint(ui.visuals().text_color());
311                            }
312                            ui.add(art);
313                        }
314                        None => {
315                            ui.label(egui::RichText::new(glyph).color(ui.visuals().text_color()));
316                        }
317                    }
318                    ui.add_space(GAP);
319                }
320
321                // --- label (the click / drag handle) ----------------------------------
322                let mut text = egui::RichText::new(row.label);
323                if row.selected {
324                    text = text.strong();
325                }
326                if let Some(tint) = row.tint {
327                    text = text.color(tint);
328                }
329                // Click (and, on a reorder handle, drag) but NOT focus: the row's Tab
330                // stop is the band, which covers the whole row rather than just this
331                // text. Two focusable widgets per row would make Tab visit every row
332                // twice and put the keyboard's idea of "the row" on its label.
333                let sense = if row.draggable {
334                    egui::Sense::CLICK | egui::Sense::DRAG
335                } else {
336                    egui::Sense::CLICK
337                };
338                // A DRAGGABLE row is a reorder handle, so its label must NOT be
339                // text-selectable: egui labels are selectable by default, and a press on a
340                // selectable label anchors a text selection that the ensuing reorder drag
341                // then SWEEPS across every row the cursor passes over (the passed-over rows
342                // "light up"). Making only the drag handle non-selectable means the reorder
343                // press never anchors a selection, so no sweep — while every non-draggable
344                // row (fields, groups, scene/settings leaves) keeps default selectable text.
345                // Hover coloring is unaffected: it is computed from the response independent
346                // of `selectable` (see egui `Label`), so ordinary non-drag hover is unchanged.
347                // A label can carry catalogued glyphs of its own — the sketch panel
348                // appends ⛓ / ◐ / ⏚ marks to entity rows — and with no icon font left,
349                // drawing those as text would render a box. `icon_label` draws them from
350                // the same SVG catalog the glyph column uses.
351                //
352                // A DRAGGABLE row keeps the plain `Label`: it is the reorder handle, and
353                // it must stay ONE widget whose response is the drag. Nothing needs
354                // both — a draggable row's glyph goes in the glyph column above, not
355                // inline — and `has_icon` is false for those labels, so this only ever
356                // takes the icon path for the rows that need it.
357                let has_icon = row.label.chars().any(crate::icons::has);
358
359                // --- right-aligned content, THEN the label ----------------------------
360                // The right content is allocated FIRST, from the panel's right edge, and
361                // the label truncates into whatever it leaves — the order the form's
362                // reference lines and the form view's title row already use, for the
363                // same reason. A row's label is not a UI string the app chose: a scene
364                // row is labelled with a SOLID's name, as long as the modelling history
365                // made it. Laid out label-first, at its natural width, a 76-character
366                // name pushed the visibility checkbox 202 pt past the edge of a 320 pt
367                // panel — off the panel, where an ancestor clip eats the click too.
368                //
369                // Both label flavours take a wrap mode, so neither can overflow: the
370                // plain `Label` is the drag handle and keeps its `sense`, and `IconText`
371                // truncates the same way (a row whose label carries ⛓ / ◐ / ⏚ marks).
372                // A label SHORTER than the band still measures its own text, which is
373                // exactly why the row's click target and published rect are the BAND
374                // and not this: a two-word name left four fifths of its row dead.
375                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
376                    ui.add_space(RIGHT_PAD);
377                    add_right(ui);
378                    // What the CALLER put at the row's right edge, as one rect.
379                    // A right-to-left `Ui` grows leftwards from the panel edge,
380                    // so its min_rect here — after the gutter and the caller's
381                    // controls, before the label — is exactly that strip.
382                    controls = ui.min_rect();
383                    ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
384                        label_resp = Some(if has_icon && !row.draggable {
385                            IconText::new(text)
386                                .sense(sense)
387                                .selectable(false)
388                                .truncate()
389                                .show(ui)
390                        } else {
391                            ui.add(
392                                egui::Label::new(text)
393                                    .sense(sense)
394                                    .selectable(!row.draggable)
395                                    .truncate(),
396                            )
397                        });
398                    });
399                });
400            })
401        },
402    );
403    let band = outer.response;
404    if row.highlighted && !row.root {
405        ui.painter().set(
406            highlight,
407            egui::epaint::RectShape::filled(
408                band.rect,
409                0.0,
410                ui.visuals().selection.bg_fill.gamma_multiply(0.35),
411            ),
412        );
413    }
414
415    let row_rect = outer.inner.response.rect;
416    if !row.root {
417        paint_connectors(ui, row_rect, row.guides, connector_col, row.is_last);
418    }
419    // The row is the keyboard stop, so say so when it holds the focus: nothing
420    // else in the row paints one now that the label is not focusable, and a
421    // focus nobody can see is a focus nobody can use.
422    if band.has_focus() {
423        ui.painter().rect_stroke(
424            row_rect,
425            2.0,
426            ui.visuals().selection.stroke,
427            egui::StrokeKind::Inside,
428        );
429    }
430
431    // A band drag whose press STARTED on one of the row's own controls is that
432    // control's press, not a row drag. egui reports both — a click-sensing
433    // button on top and the drag-sensing band beneath are "interested in
434    // different things", so `hit_test` hands back both hits — and without this
435    // the edit button became a reorder handle the moment the hand moved.
436    let press = ui.input(|i| i.pointer.press_origin());
437    let from_a_control = press
438        .is_some_and(|at| box_rect.contains(at) || controls.contains(at));
439    let band_drag_started = band.drag_started() && !from_a_control;
440
441    NodeResponse {
442        row_rect,
443        box_rect,
444        toggled,
445        label: label_resp.expect("label always drawn"),
446        band,
447        band_drag_started,
448    }
449}
450
451/// Draw a LEAF whose content is a multi-line, WRAPPING colored message — e.g. a
452/// feature's error text shown under its header. Unlike [`node`] (a single
453/// non-wrapping row), the label WRAPS to the available width and the row grows as
454/// tall as it needs; the `└`/`├` connector anchors to the FIRST line so it still
455/// reads as a normal child leaf under its parent. Returns the full row rect.
456pub fn message_leaf(
457    ui: &mut egui::Ui,
458    guides: &[bool],
459    is_last: bool,
460    text: &str,
461    color: egui::Color32,
462) -> egui::Rect {
463    let connector_col = guides.len();
464    // Align the wrapped text with where a LEAF's label starts: indent to the box
465    // slot column, then clear the (empty) box slot + gap.
466    let indent = (connector_col as f32 + 1.0) * INDENT + BOX + GAP;
467    let line_h = ui.text_style_height(&egui::TextStyle::Body);
468    let inner = ui.horizontal(|ui| {
469        ui.spacing_mut().item_spacing.x = 0.0;
470        ui.add_space(indent);
471        // A vertical child claims the remaining row; the wrapping label wraps to its
472        // width (minus the right gutter so it clears the sidebar scroll bar).
473        ui.vertical(|ui| {
474            ui.set_max_width((ui.available_width() - RIGHT_PAD).max(40.0));
475            ui.add(
476                egui::Label::new(egui::RichText::new(text).color(color))
477                    .wrap_mode(egui::TextWrapMode::Wrap),
478            );
479        });
480    });
481    let row_rect = inner.response.rect;
482    let tick_y = row_rect.top() + line_h * 0.5;
483    paint_connectors_at(ui, row_rect, guides, connector_col, is_last, tick_y);
484    row_rect
485}
486
487/// Paint a `[+]`/`[-]` collapse box: a rounded stroked square with a minus bar
488/// (expanded) plus a vertical bar (collapsed → a plus). Theme-aware.
489fn paint_box(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
490    let painter = ui.painter();
491    let stroke = egui::Stroke::new(1.0, line_color(ui));
492    painter.rect(
493        rect,
494        egui::CornerRadius::same(2),
495        egui::Color32::TRANSPARENT,
496        stroke,
497        egui::StrokeKind::Inside,
498    );
499    let c = rect.center();
500    let arm = rect.width() * 0.28;
501    let sign = egui::Stroke::new(1.4, ui.visuals().text_color());
502    // horizontal bar (always → the minus of a `[-]`)
503    painter.line_segment(
504        [egui::pos2(c.x - arm, c.y), egui::pos2(c.x + arm, c.y)],
505        sign,
506    );
507    // vertical bar (only when collapsed → completes the plus of a `[+]`)
508    if !expanded {
509        painter.line_segment(
510            [egui::pos2(c.x, c.y - arm), egui::pos2(c.x, c.y + arm)],
511            sign,
512        );
513    }
514}
515
516/// Paint the ancestor guide lines + this node's `├`/`└` connector into the row's
517/// left gutter (over-drawn by the inter-row spacing so verticals join seamlessly
518/// across rows). The `├`/`└` tick lands at the row's vertical center.
519fn paint_connectors(
520    ui: &egui::Ui,
521    row: egui::Rect,
522    guides: &[bool],
523    connector_col: usize,
524    is_last: bool,
525) {
526    paint_connectors_at(ui, row, guides, connector_col, is_last, row.center().y);
527}
528
529/// Like [`paint_connectors`] but with an explicit `tick_y` for the `├`/`└` join —
530/// so a MULTI-LINE row (a wrapping message leaf) can anchor its connector to its
531/// FIRST line instead of the block's vertical center.
532fn paint_connectors_at(
533    ui: &egui::Ui,
534    row: egui::Rect,
535    guides: &[bool],
536    connector_col: usize,
537    is_last: bool,
538    mid: f32,
539) {
540    let painter = ui.painter();
541    let stroke = egui::Stroke::new(1.0, line_color(ui));
542    let sp = ui.spacing().item_spacing.y + 1.0;
543    let left = row.left();
544    let top = row.top() - sp;
545    let bottom = row.bottom() + sp;
546    let col_x = |c: usize| left + c as f32 * INDENT + INDENT * 0.5;
547
548    // Ancestor sibling lines passing through this row.
549    for (i, on) in guides.iter().enumerate() {
550        if *on {
551            let x = col_x(i);
552            painter.line_segment([egui::pos2(x, top), egui::pos2(x, bottom)], stroke);
553        }
554    }
555    // This node's own connector: vertical down to mid (└) or through (├), plus a
556    // horizontal tick reaching the box slot.
557    let x = col_x(connector_col);
558    let v_bottom = if is_last { mid } else { bottom };
559    painter.line_segment([egui::pos2(x, top), egui::pos2(x, v_bottom)], stroke);
560    painter.line_segment(
561        [egui::pos2(x, mid), egui::pos2(x + INDENT * 0.5, mid)],
562        stroke,
563    );
564}
565
566/// The subtle connector / box stroke color — the theme's non-interactive
567/// foreground, dimmed. Theme-aware (light + dark) and the seam the later theme
568/// pass tunes in one place.
569fn line_color(ui: &egui::Ui) -> egui::Color32 {
570    ui.visuals()
571        .widgets
572        .noninteractive
573        .fg_stroke
574        .color
575        .gamma_multiply(0.7)
576}
577