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}