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