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 eframe::egui;
30
31/// One tree indentation level, in egui points.
32pub const INDENT: f32 = 16.0;
33/// The collapse box's side length.
34const BOX: f32 = 13.0;
35/// Gap between the box slot, an optional glyph, and the label.
36const GAP: f32 = 4.0;
37/// Right-side gutter reserved before the right-aligned row content (timing,
38/// delete X, field inputs, a Select button) so those controls clear the sidebar
39/// scroll bar instead of underlapping it — an underlapping X eats the click as a
40/// scroll drag rather than firing.
41const RIGHT_PAD: f32 = 10.0;
42
43/// The spec for one tree row. Borrows its strings; holds no state (expansion is
44/// the caller's — see the module docs).
45pub struct TreeRow<'a> {
46    /// Ancestor vertical-guide flags, one per level strictly above this node
47    /// (`true` = a sibling line passes through this row at that level). The
48    /// node's own connector column index is `guides.len()`.
49    pub guides: &'a [bool],
50    /// This node is the LAST among its siblings (`└` vs `├`). Ignored when `root`.
51    pub is_last: bool,
52    /// Draw a `[+]`/`[-]` collapse box (an expandable node) instead of an empty
53    /// box slot (a leaf). A leaf keeps the same label indent so labels align.
54    pub expandable: bool,
55    /// Current expand state — only meaningful when `expandable`.
56    pub expanded: bool,
57    /// The far-left ROOT row (e.g. `Features`): no connector, box at column 0.
58    pub root: bool,
59    /// An optional per-type glyph drawn between the box slot and the label.
60    pub glyph: Option<&'a str>,
61    /// The row's text label.
62    pub label: &'a str,
63    /// Emphasize the label (the rolled-to / selected node).
64    pub selected: bool,
65    /// Make the label a drag handle (`Sense::click_and_drag`) — drag-reorder.
66    pub draggable: bool,
67}
68
69impl<'a> TreeRow<'a> {
70    /// A plain expandable branch node.
71    pub fn branch(guides: &'a [bool], is_last: bool, expanded: bool, label: &'a str) -> Self {
72        Self {
73            guides,
74            is_last,
75            expandable: true,
76            expanded,
77            root: false,
78            glyph: None,
79            label,
80            selected: false,
81            draggable: false,
82        }
83    }
84
85    /// A plain leaf node (no collapse box).
86    pub fn leaf(guides: &'a [bool], is_last: bool, label: &'a str) -> Self {
87        Self {
88            guides,
89            is_last,
90            expandable: false,
91            expanded: false,
92            root: false,
93            glyph: None,
94            label,
95            selected: false,
96            draggable: false,
97        }
98    }
99
100    pub fn glyph(mut self, glyph: Option<&'a str>) -> Self {
101        self.glyph = glyph;
102        self
103    }
104    pub fn selected(mut self, selected: bool) -> Self {
105        self.selected = selected;
106        self
107    }
108    pub fn draggable(mut self, draggable: bool) -> Self {
109        self.draggable = draggable;
110        self
111    }
112}
113
114/// What happened to a drawn tree row.
115pub struct NodeResponse {
116    /// The whole row rect (for hit publishing / drag-target hit-testing).
117    pub row_rect: egui::Rect,
118    /// The collapse-box rect (for hit publishing).
119    pub box_rect: egui::Rect,
120    /// The `[+]`/`[-]` collapse box was clicked (a pure toggle intent).
121    pub toggled: bool,
122    /// The label response — the click/drag handle. Use `.clicked()` to select,
123    /// `.drag_started()` / `.drag_stopped()` to reorder.
124    pub label: egui::Response,
125}
126
127/// Extend a guide stack for a node's children: the node's own connector column
128/// keeps drawing a vertical line past the children ONLY if the node has more
129/// siblings below it (`!is_last`).
130pub fn child_guides(guides: &[bool], is_last: bool) -> Vec<bool> {
131    let mut next = guides.to_vec();
132    next.push(!is_last);
133    next
134}
135
136/// Draw ONE tree row and return what was interacted with. `add_right` fills the
137/// right-aligned content area (timing + delete, field inputs, a Select button…).
138pub fn node(
139    ui: &mut egui::Ui,
140    row: TreeRow,
141    add_right: impl FnOnce(&mut egui::Ui),
142) -> NodeResponse {
143    let connector_col = if row.root { 0 } else { row.guides.len() };
144    // The box slot (and thus the label) begins one column right of the connector.
145    let indent = if row.root {
146        0.0
147    } else {
148        (connector_col as f32 + 1.0) * INDENT
149    };
150
151    let mut box_rect = egui::Rect::NOTHING;
152    let mut toggled = false;
153    let mut label_resp: Option<egui::Response> = None;
154
155    let inner = ui.horizontal(|ui| {
156        // Zero the inter-item spacing so the geometry math is exact; gaps are
157        // added explicitly below.
158        ui.spacing_mut().item_spacing.x = 0.0;
159        if indent > 0.0 {
160            ui.add_space(indent);
161        }
162
163        // --- collapse box (expandable) or an equally-wide empty slot (leaf) ----
164        let (brect, bresp) = ui.allocate_exact_size(
165            egui::vec2(BOX, BOX),
166            if row.expandable {
167                egui::Sense::click()
168            } else {
169                egui::Sense::hover()
170            },
171        );
172        box_rect = brect;
173        if row.expandable {
174            paint_box(ui, brect, row.expanded);
175            if bresp.clicked() {
176                toggled = true;
177            }
178        }
179        ui.add_space(GAP);
180
181        // --- optional per-type glyph ------------------------------------------
182        if let Some(glyph) = row.glyph {
183            ui.label(egui::RichText::new(glyph).color(ui.visuals().text_color()));
184            ui.add_space(GAP);
185        }
186
187        // --- label (the click / drag handle) ----------------------------------
188        let mut text = egui::RichText::new(row.label);
189        if row.selected {
190            text = text.strong();
191        }
192        let sense = if row.draggable {
193            egui::Sense::click_and_drag()
194        } else {
195            egui::Sense::click()
196        };
197        label_resp = Some(ui.add(egui::Label::new(text).sense(sense)));
198
199        // --- right-aligned content --------------------------------------------
200        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
201            ui.add_space(RIGHT_PAD);
202            add_right(ui);
203        });
204    });
205
206    let row_rect = inner.response.rect;
207    if !row.root {
208        paint_connectors(ui, row_rect, row.guides, connector_col, row.is_last);
209    }
210
211    NodeResponse {
212        row_rect,
213        box_rect,
214        toggled,
215        label: label_resp.expect("label always drawn"),
216    }
217}
218
219/// Draw a LEAF whose content is a multi-line, WRAPPING colored message — e.g. a
220/// feature's error text shown under its header. Unlike [`node`] (a single
221/// non-wrapping row), the label WRAPS to the available width and the row grows as
222/// tall as it needs; the `└`/`├` connector anchors to the FIRST line so it still
223/// reads as a normal child leaf under its parent. Returns the full row rect.
224pub fn message_leaf(
225    ui: &mut egui::Ui,
226    guides: &[bool],
227    is_last: bool,
228    text: &str,
229    color: egui::Color32,
230) -> egui::Rect {
231    let connector_col = guides.len();
232    // Align the wrapped text with where a LEAF's label starts: indent to the box
233    // slot column, then clear the (empty) box slot + gap.
234    let indent = (connector_col as f32 + 1.0) * INDENT + BOX + GAP;
235    let line_h = ui.text_style_height(&egui::TextStyle::Body);
236    let inner = ui.horizontal(|ui| {
237        ui.spacing_mut().item_spacing.x = 0.0;
238        ui.add_space(indent);
239        // A vertical child claims the remaining row; the wrapping label wraps to its
240        // width (minus the right gutter so it clears the sidebar scroll bar).
241        ui.vertical(|ui| {
242            ui.set_max_width((ui.available_width() - RIGHT_PAD).max(40.0));
243            ui.add(
244                egui::Label::new(egui::RichText::new(text).color(color))
245                    .wrap_mode(egui::TextWrapMode::Wrap),
246            );
247        });
248    });
249    let row_rect = inner.response.rect;
250    let tick_y = row_rect.top() + line_h * 0.5;
251    paint_connectors_at(ui, row_rect, guides, connector_col, is_last, tick_y);
252    row_rect
253}
254
255/// Paint a `[+]`/`[-]` collapse box: a rounded stroked square with a minus bar
256/// (expanded) plus a vertical bar (collapsed → a plus). Theme-aware.
257fn paint_box(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
258    let painter = ui.painter();
259    let stroke = egui::Stroke::new(1.0, line_color(ui));
260    painter.rect(
261        rect,
262        egui::CornerRadius::same(2),
263        egui::Color32::TRANSPARENT,
264        stroke,
265        egui::StrokeKind::Inside,
266    );
267    let c = rect.center();
268    let arm = rect.width() * 0.28;
269    let sign = egui::Stroke::new(1.4, ui.visuals().text_color());
270    // horizontal bar (always → the minus of a `[-]`)
271    painter.line_segment(
272        [egui::pos2(c.x - arm, c.y), egui::pos2(c.x + arm, c.y)],
273        sign,
274    );
275    // vertical bar (only when collapsed → completes the plus of a `[+]`)
276    if !expanded {
277        painter.line_segment(
278            [egui::pos2(c.x, c.y - arm), egui::pos2(c.x, c.y + arm)],
279            sign,
280        );
281    }
282}
283
284/// Paint the ancestor guide lines + this node's `├`/`└` connector into the row's
285/// left gutter (over-drawn by the inter-row spacing so verticals join seamlessly
286/// across rows). The `├`/`└` tick lands at the row's vertical center.
287fn paint_connectors(
288    ui: &egui::Ui,
289    row: egui::Rect,
290    guides: &[bool],
291    connector_col: usize,
292    is_last: bool,
293) {
294    paint_connectors_at(ui, row, guides, connector_col, is_last, row.center().y);
295}
296
297/// Like [`paint_connectors`] but with an explicit `tick_y` for the `├`/`└` join —
298/// so a MULTI-LINE row (a wrapping message leaf) can anchor its connector to its
299/// FIRST line instead of the block's vertical center.
300fn paint_connectors_at(
301    ui: &egui::Ui,
302    row: egui::Rect,
303    guides: &[bool],
304    connector_col: usize,
305    is_last: bool,
306    mid: f32,
307) {
308    let painter = ui.painter();
309    let stroke = egui::Stroke::new(1.0, line_color(ui));
310    let sp = ui.spacing().item_spacing.y + 1.0;
311    let left = row.left();
312    let top = row.top() - sp;
313    let bottom = row.bottom() + sp;
314    let col_x = |c: usize| left + c as f32 * INDENT + INDENT * 0.5;
315
316    // Ancestor sibling lines passing through this row.
317    for (i, on) in guides.iter().enumerate() {
318        if *on {
319            let x = col_x(i);
320            painter.line_segment([egui::pos2(x, top), egui::pos2(x, bottom)], stroke);
321        }
322    }
323    // This node's own connector: vertical down to mid (└) or through (├), plus a
324    // horizontal tick reaching the box slot.
325    let x = col_x(connector_col);
326    let v_bottom = if is_last { mid } else { bottom };
327    painter.line_segment([egui::pos2(x, top), egui::pos2(x, v_bottom)], stroke);
328    painter.line_segment(
329        [egui::pos2(x, mid), egui::pos2(x + INDENT * 0.5, mid)],
330        stroke,
331    );
332}
333
334/// The subtle connector / box stroke color — the theme's non-interactive
335/// foreground, dimmed. Theme-aware (light + dark) and the seam the later theme
336/// pass tunes in one place.
337fn line_color(ui: &egui::Ui) -> egui::Color32 {
338    ui.visuals()
339        .widgets
340        .noninteractive
341        .fg_stroke
342        .color
343        .gamma_multiply(0.7)
344}