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}