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    /// Override the label's text color (the sketch panel paints a CONFLICTING
69    /// constraint's row red). `None` keeps the theme's text color, including its
70    /// hover/selection behavior.
71    pub tint: Option<egui::Color32>,
72}
73
74impl<'a> TreeRow<'a> {
75    /// A plain expandable branch node.
76    pub fn branch(guides: &'a [bool], is_last: bool, expanded: bool, label: &'a str) -> Self {
77        Self {
78            guides,
79            is_last,
80            expandable: true,
81            expanded,
82            root: false,
83            glyph: None,
84            label,
85            selected: false,
86            draggable: false,
87            tint: None,
88        }
89    }
90
91    /// A plain leaf node (no collapse box).
92    pub fn leaf(guides: &'a [bool], is_last: bool, label: &'a str) -> Self {
93        Self {
94            guides,
95            is_last,
96            expandable: false,
97            expanded: false,
98            root: false,
99            glyph: None,
100            label,
101            selected: false,
102            draggable: false,
103            tint: None,
104        }
105    }
106
107    pub fn glyph(mut self, glyph: Option<&'a str>) -> Self {
108        self.glyph = glyph;
109        self
110    }
111    pub fn selected(mut self, selected: bool) -> Self {
112        self.selected = selected;
113        self
114    }
115    pub fn draggable(mut self, draggable: bool) -> Self {
116        self.draggable = draggable;
117        self
118    }
119    pub fn tint(mut self, tint: Option<egui::Color32>) -> Self {
120        self.tint = tint;
121        self
122    }
123}
124
125/// What happened to a drawn tree row.
126pub struct NodeResponse {
127    /// The whole row rect (for hit publishing / drag-target hit-testing).
128    pub row_rect: egui::Rect,
129    /// The collapse-box rect (for hit publishing).
130    pub box_rect: egui::Rect,
131    /// The `[+]`/`[-]` collapse box was clicked (a pure toggle intent).
132    pub toggled: bool,
133    /// The label response — the click/drag handle. Use `.clicked()` to select,
134    /// `.drag_started()` / `.drag_stopped()` to reorder.
135    pub label: egui::Response,
136}
137
138/// Extend a guide stack for a node's children: the node's own connector column
139/// keeps drawing a vertical line past the children ONLY if the node has more
140/// siblings below it (`!is_last`).
141pub fn child_guides(guides: &[bool], is_last: bool) -> Vec<bool> {
142    let mut next = guides.to_vec();
143    next.push(!is_last);
144    next
145}
146
147/// Draw ONE tree row and return what was interacted with. `add_right` fills the
148/// right-aligned content area (timing + delete, field inputs, a Select button…).
149pub fn node(
150    ui: &mut egui::Ui,
151    row: TreeRow,
152    add_right: impl FnOnce(&mut egui::Ui),
153) -> NodeResponse {
154    let connector_col = if row.root { 0 } else { row.guides.len() };
155    // The box slot (and thus the label) begins one column right of the connector.
156    let indent = if row.root {
157        0.0
158    } else {
159        (connector_col as f32 + 1.0) * INDENT
160    };
161
162    let mut box_rect = egui::Rect::NOTHING;
163    let mut toggled = false;
164    let mut label_resp: Option<egui::Response> = None;
165
166    let inner = ui.horizontal(|ui| {
167        // Zero the inter-item spacing so the geometry math is exact; gaps are
168        // added explicitly below.
169        ui.spacing_mut().item_spacing.x = 0.0;
170        if indent > 0.0 {
171            ui.add_space(indent);
172        }
173
174        // --- collapse box (expandable) or an equally-wide empty slot (leaf) ----
175        let (brect, bresp) = ui.allocate_exact_size(
176            egui::vec2(BOX, BOX),
177            if row.expandable {
178                egui::Sense::click()
179            } else {
180                egui::Sense::hover()
181            },
182        );
183        box_rect = brect;
184        if row.expandable {
185            paint_box(ui, brect, row.expanded);
186            if bresp.clicked() {
187                toggled = true;
188            }
189        }
190        ui.add_space(GAP);
191
192        // --- optional per-type glyph ------------------------------------------
193        // Drawn as catalogued artwork, sized to the row's text. Monochrome
194        // artwork is tinted to the row's text colour, so it reads exactly as the
195        // old font glyph did; colour artwork is left alone. Anything
196        // uncatalogued falls back to text.
197        if let Some(glyph) = row.glyph {
198            match crate::icons::artwork(glyph) {
199                Some(icon) => {
200                    // A tree row can be the first thing on screen to draw an
201                    // SVG; `install_image_loaders` is idempotent.
202                    egui_extras::install_image_loaders(ui.ctx());
203                    let height = ui.text_style_height(&egui::TextStyle::Body);
204                    let mut art = crate::icon_text::image(icon, height);
205                    if icon.mono {
206                        art = art.tint(ui.visuals().text_color());
207                    }
208                    ui.add(art);
209                }
210                None => {
211                    ui.label(egui::RichText::new(glyph).color(ui.visuals().text_color()));
212                }
213            }
214            ui.add_space(GAP);
215        }
216
217        // --- label (the click / drag handle) ----------------------------------
218        let mut text = egui::RichText::new(row.label);
219        if row.selected {
220            text = text.strong();
221        }
222        if let Some(tint) = row.tint {
223            text = text.color(tint);
224        }
225        let sense = if row.draggable {
226            egui::Sense::click_and_drag()
227        } else {
228            egui::Sense::click()
229        };
230        // A DRAGGABLE row is a reorder handle, so its label must NOT be
231        // text-selectable: egui labels are selectable by default, and a press on a
232        // selectable label anchors a text selection that the ensuing reorder drag
233        // then SWEEPS across every row the cursor passes over (the passed-over rows
234        // "light up"). Making only the drag handle non-selectable means the reorder
235        // press never anchors a selection, so no sweep — while every non-draggable
236        // row (fields, groups, scene/settings leaves) keeps default selectable text.
237        // Hover coloring is unaffected: it is computed from the response independent
238        // of `selectable` (see egui `Label`), so ordinary non-drag hover is unchanged.
239        // A label can carry catalogued glyphs of its own — the sketch panel
240        // appends ⛓ / ◐ / ⏚ marks to entity rows — and with no icon font left,
241        // drawing those as text would render a box. `icon_label` draws them from
242        // the same SVG catalog the glyph column uses.
243        //
244        // A DRAGGABLE row keeps the plain `Label`: it is the reorder handle, and
245        // it must stay ONE widget whose response is the drag. Nothing needs
246        // both — a draggable row's glyph goes in the glyph column above, not
247        // inline — and `has_icon` is false for those labels, so this only ever
248        // takes the icon path for the rows that need it.
249        let has_icon = row.label.chars().any(crate::icons::has);
250
251        // --- right-aligned content, THEN the label ----------------------------
252        // The right content is allocated FIRST, from the panel's right edge, and
253        // the label truncates into whatever it leaves — the order the form's
254        // reference lines and the form view's title row already use, for the
255        // same reason. A row's label is not a UI string the app chose: a scene
256        // row is labelled with a SOLID's name, as long as the modelling history
257        // made it. Laid out label-first, at its natural width, a 76-character
258        // name pushed the visibility checkbox 202 pt past the edge of a 320 pt
259        // panel — off the panel, where an ancestor clip eats the click too.
260        //
261        // Both label flavours take a wrap mode, so neither can overflow: the
262        // plain `Label` is the drag handle and keeps its `sense`, and `IconText`
263        // truncates the same way (a row whose label carries ⛓ / ◐ / ⏚ marks).
264        // A label SHORTER than the band still measures its own text, so ordinary
265        // rows keep the content-width hit rect every caller publishes.
266        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
267            ui.add_space(RIGHT_PAD);
268            add_right(ui);
269            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
270                label_resp = Some(if has_icon && !row.draggable {
271                    IconText::new(text)
272                        .sense(sense)
273                        .selectable(false)
274                        .truncate()
275                        .show(ui)
276                } else {
277                    ui.add(
278                        egui::Label::new(text)
279                            .sense(sense)
280                            .selectable(!row.draggable)
281                            .truncate(),
282                    )
283                });
284            });
285        });
286    });
287
288    let row_rect = inner.response.rect;
289    if !row.root {
290        paint_connectors(ui, row_rect, row.guides, connector_col, row.is_last);
291    }
292
293    NodeResponse {
294        row_rect,
295        box_rect,
296        toggled,
297        label: label_resp.expect("label always drawn"),
298    }
299}
300
301/// Draw a LEAF whose content is a multi-line, WRAPPING colored message — e.g. a
302/// feature's error text shown under its header. Unlike [`node`] (a single
303/// non-wrapping row), the label WRAPS to the available width and the row grows as
304/// tall as it needs; the `└`/`├` connector anchors to the FIRST line so it still
305/// reads as a normal child leaf under its parent. Returns the full row rect.
306pub fn message_leaf(
307    ui: &mut egui::Ui,
308    guides: &[bool],
309    is_last: bool,
310    text: &str,
311    color: egui::Color32,
312) -> egui::Rect {
313    let connector_col = guides.len();
314    // Align the wrapped text with where a LEAF's label starts: indent to the box
315    // slot column, then clear the (empty) box slot + gap.
316    let indent = (connector_col as f32 + 1.0) * INDENT + BOX + GAP;
317    let line_h = ui.text_style_height(&egui::TextStyle::Body);
318    let inner = ui.horizontal(|ui| {
319        ui.spacing_mut().item_spacing.x = 0.0;
320        ui.add_space(indent);
321        // A vertical child claims the remaining row; the wrapping label wraps to its
322        // width (minus the right gutter so it clears the sidebar scroll bar).
323        ui.vertical(|ui| {
324            ui.set_max_width((ui.available_width() - RIGHT_PAD).max(40.0));
325            ui.add(
326                egui::Label::new(egui::RichText::new(text).color(color))
327                    .wrap_mode(egui::TextWrapMode::Wrap),
328            );
329        });
330    });
331    let row_rect = inner.response.rect;
332    let tick_y = row_rect.top() + line_h * 0.5;
333    paint_connectors_at(ui, row_rect, guides, connector_col, is_last, tick_y);
334    row_rect
335}
336
337/// Paint a `[+]`/`[-]` collapse box: a rounded stroked square with a minus bar
338/// (expanded) plus a vertical bar (collapsed → a plus). Theme-aware.
339fn paint_box(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
340    let painter = ui.painter();
341    let stroke = egui::Stroke::new(1.0, line_color(ui));
342    painter.rect(
343        rect,
344        egui::CornerRadius::same(2),
345        egui::Color32::TRANSPARENT,
346        stroke,
347        egui::StrokeKind::Inside,
348    );
349    let c = rect.center();
350    let arm = rect.width() * 0.28;
351    let sign = egui::Stroke::new(1.4, ui.visuals().text_color());
352    // horizontal bar (always → the minus of a `[-]`)
353    painter.line_segment(
354        [egui::pos2(c.x - arm, c.y), egui::pos2(c.x + arm, c.y)],
355        sign,
356    );
357    // vertical bar (only when collapsed → completes the plus of a `[+]`)
358    if !expanded {
359        painter.line_segment(
360            [egui::pos2(c.x, c.y - arm), egui::pos2(c.x, c.y + arm)],
361            sign,
362        );
363    }
364}
365
366/// Paint the ancestor guide lines + this node's `├`/`└` connector into the row's
367/// left gutter (over-drawn by the inter-row spacing so verticals join seamlessly
368/// across rows). The `├`/`└` tick lands at the row's vertical center.
369fn paint_connectors(
370    ui: &egui::Ui,
371    row: egui::Rect,
372    guides: &[bool],
373    connector_col: usize,
374    is_last: bool,
375) {
376    paint_connectors_at(ui, row, guides, connector_col, is_last, row.center().y);
377}
378
379/// Like [`paint_connectors`] but with an explicit `tick_y` for the `├`/`└` join —
380/// so a MULTI-LINE row (a wrapping message leaf) can anchor its connector to its
381/// FIRST line instead of the block's vertical center.
382fn paint_connectors_at(
383    ui: &egui::Ui,
384    row: egui::Rect,
385    guides: &[bool],
386    connector_col: usize,
387    is_last: bool,
388    mid: f32,
389) {
390    let painter = ui.painter();
391    let stroke = egui::Stroke::new(1.0, line_color(ui));
392    let sp = ui.spacing().item_spacing.y + 1.0;
393    let left = row.left();
394    let top = row.top() - sp;
395    let bottom = row.bottom() + sp;
396    let col_x = |c: usize| left + c as f32 * INDENT + INDENT * 0.5;
397
398    // Ancestor sibling lines passing through this row.
399    for (i, on) in guides.iter().enumerate() {
400        if *on {
401            let x = col_x(i);
402            painter.line_segment([egui::pos2(x, top), egui::pos2(x, bottom)], stroke);
403        }
404    }
405    // This node's own connector: vertical down to mid (└) or through (├), plus a
406    // horizontal tick reaching the box slot.
407    let x = col_x(connector_col);
408    let v_bottom = if is_last { mid } else { bottom };
409    painter.line_segment([egui::pos2(x, top), egui::pos2(x, v_bottom)], stroke);
410    painter.line_segment(
411        [egui::pos2(x, mid), egui::pos2(x + INDENT * 0.5, mid)],
412        stroke,
413    );
414}
415
416/// The subtle connector / box stroke color — the theme's non-interactive
417/// foreground, dimmed. Theme-aware (light + dark) and the seam the later theme
418/// pass tunes in one place.
419fn line_color(ui: &egui::Ui) -> egui::Color32 {
420    ui.visuals()
421        .widgets
422        .noninteractive
423        .fg_stroke
424        .color
425        .gamma_multiply(0.7)
426}
427
428// BREP private tests: 5fd2d7adee70c2ef