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