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