BREP_app 0.2.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! A reusable, custom-painted TREE-NODE widget — the shared building block for
//! the engine-native side panels (the history feature tree here; the Scene tree
//! and others reuse it verbatim). egui's default `CollapsingHeader` draws a
//! disclosure TRIANGLE and no connector rules; the design reference is a classic
//! file-tree with **`[+]`/`[-]` collapse boxes + connector lines**, so this
//! module paints those itself.
//!
//! # The node model (immutable-mode friendly)
//!
//! A node is ONE row: `⟨connectors⟩ [+/-] ⟨glyph⟩ ⟨label⟩ … ⟨right content⟩`.
//! Expansion state is OWNED BY THE CALLER (the panels need exclusive-expand +
//! roll-to side effects), so [`node`] just draws a row and returns what was
//! clicked; the caller decides whether to recurse into children.
//!
//! ## Connector geometry
//!
//! Indentation is one [`INDENT`] column per tree level. A node at connector
//! column `d = guides.len()` draws its `├`/`└` connector at column `d` and its
//! box slot at column `d + 1`. `guides[i]` (`i < d`) says whether an ANCESTOR's
//! sibling line passes vertically through this row at column `i`. Descending into
//! a node's children extends the guide stack by [`child_guides`] with
//! `!this_node_is_last` — the node's own connector line continues down past its
//! children only if it has following siblings. This is the standard file-tree
//! rule and yields the reference's exact rules.
//!
//! This module has NO engine dependency — it is pure egui + geometry, so a later
//! `brep-ui` crate (and the theme pass, #49) can reuse it unchanged.

use eframe::egui;

/// One tree indentation level, in egui points.
pub const INDENT: f32 = 16.0;
/// The collapse box's side length.
const BOX: f32 = 13.0;
/// Gap between the box slot, an optional glyph, and the label.
const GAP: f32 = 4.0;
/// Right-side gutter reserved before the right-aligned row content (timing,
/// delete X, field inputs, a Select button) so those controls clear the sidebar
/// scroll bar instead of underlapping it — an underlapping X eats the click as a
/// scroll drag rather than firing.
const RIGHT_PAD: f32 = 10.0;

/// The spec for one tree row. Borrows its strings; holds no state (expansion is
/// the caller's — see the module docs).
pub struct TreeRow<'a> {
    /// Ancestor vertical-guide flags, one per level strictly above this node
    /// (`true` = a sibling line passes through this row at that level). The
    /// node's own connector column index is `guides.len()`.
    pub guides: &'a [bool],
    /// This node is the LAST among its siblings (`└` vs `├`). Ignored when `root`.
    pub is_last: bool,
    /// Draw a `[+]`/`[-]` collapse box (an expandable node) instead of an empty
    /// box slot (a leaf). A leaf keeps the same label indent so labels align.
    pub expandable: bool,
    /// Current expand state — only meaningful when `expandable`.
    pub expanded: bool,
    /// The far-left ROOT row (e.g. `Features`): no connector, box at column 0.
    pub root: bool,
    /// An optional per-type glyph drawn between the box slot and the label.
    pub glyph: Option<&'a str>,
    /// The row's text label.
    pub label: &'a str,
    /// Emphasize the label (the rolled-to / selected node).
    pub selected: bool,
    /// Make the label a drag handle (`Sense::click_and_drag`) — drag-reorder.
    pub draggable: bool,
}

impl<'a> TreeRow<'a> {
    /// A plain expandable branch node.
    pub fn branch(guides: &'a [bool], is_last: bool, expanded: bool, label: &'a str) -> Self {
        Self {
            guides,
            is_last,
            expandable: true,
            expanded,
            root: false,
            glyph: None,
            label,
            selected: false,
            draggable: false,
        }
    }

    /// A plain leaf node (no collapse box).
    pub fn leaf(guides: &'a [bool], is_last: bool, label: &'a str) -> Self {
        Self {
            guides,
            is_last,
            expandable: false,
            expanded: false,
            root: false,
            glyph: None,
            label,
            selected: false,
            draggable: false,
        }
    }

    pub fn glyph(mut self, glyph: Option<&'a str>) -> Self {
        self.glyph = glyph;
        self
    }
    pub fn selected(mut self, selected: bool) -> Self {
        self.selected = selected;
        self
    }
    pub fn draggable(mut self, draggable: bool) -> Self {
        self.draggable = draggable;
        self
    }
}

/// What happened to a drawn tree row.
pub struct NodeResponse {
    /// The whole row rect (for hit publishing / drag-target hit-testing).
    pub row_rect: egui::Rect,
    /// The collapse-box rect (for hit publishing).
    pub box_rect: egui::Rect,
    /// The `[+]`/`[-]` collapse box was clicked (a pure toggle intent).
    pub toggled: bool,
    /// The label response — the click/drag handle. Use `.clicked()` to select,
    /// `.drag_started()` / `.drag_stopped()` to reorder.
    pub label: egui::Response,
}

/// Extend a guide stack for a node's children: the node's own connector column
/// keeps drawing a vertical line past the children ONLY if the node has more
/// siblings below it (`!is_last`).
pub fn child_guides(guides: &[bool], is_last: bool) -> Vec<bool> {
    let mut next = guides.to_vec();
    next.push(!is_last);
    next
}

/// Draw ONE tree row and return what was interacted with. `add_right` fills the
/// right-aligned content area (timing + delete, field inputs, a Select button…).
pub fn node(
    ui: &mut egui::Ui,
    row: TreeRow,
    add_right: impl FnOnce(&mut egui::Ui),
) -> NodeResponse {
    let connector_col = if row.root { 0 } else { row.guides.len() };
    // The box slot (and thus the label) begins one column right of the connector.
    let indent = if row.root {
        0.0
    } else {
        (connector_col as f32 + 1.0) * INDENT
    };

    let mut box_rect = egui::Rect::NOTHING;
    let mut toggled = false;
    let mut label_resp: Option<egui::Response> = None;

    let inner = ui.horizontal(|ui| {
        // Zero the inter-item spacing so the geometry math is exact; gaps are
        // added explicitly below.
        ui.spacing_mut().item_spacing.x = 0.0;
        if indent > 0.0 {
            ui.add_space(indent);
        }

        // --- collapse box (expandable) or an equally-wide empty slot (leaf) ----
        let (brect, bresp) = ui.allocate_exact_size(
            egui::vec2(BOX, BOX),
            if row.expandable {
                egui::Sense::click()
            } else {
                egui::Sense::hover()
            },
        );
        box_rect = brect;
        if row.expandable {
            paint_box(ui, brect, row.expanded);
            if bresp.clicked() {
                toggled = true;
            }
        }
        ui.add_space(GAP);

        // --- optional per-type glyph ------------------------------------------
        if let Some(glyph) = row.glyph {
            ui.label(egui::RichText::new(glyph).color(ui.visuals().text_color()));
            ui.add_space(GAP);
        }

        // --- label (the click / drag handle) ----------------------------------
        let mut text = egui::RichText::new(row.label);
        if row.selected {
            text = text.strong();
        }
        let sense = if row.draggable {
            egui::Sense::click_and_drag()
        } else {
            egui::Sense::click()
        };
        // A DRAGGABLE row is a reorder handle, so its label must NOT be
        // text-selectable: egui labels are selectable by default, and a press on a
        // selectable label anchors a text selection that the ensuing reorder drag
        // then SWEEPS across every row the cursor passes over (the passed-over rows
        // "light up"). Making only the drag handle non-selectable means the reorder
        // press never anchors a selection, so no sweep — while every non-draggable
        // row (fields, groups, scene/settings leaves) keeps default selectable text.
        // Hover coloring is unaffected: it is computed from the response independent
        // of `selectable` (see egui `Label`), so ordinary non-drag hover is unchanged.
        label_resp = Some(ui.add(egui::Label::new(text).sense(sense).selectable(!row.draggable)));

        // --- right-aligned content --------------------------------------------
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            ui.add_space(RIGHT_PAD);
            add_right(ui);
        });
    });

    let row_rect = inner.response.rect;
    if !row.root {
        paint_connectors(ui, row_rect, row.guides, connector_col, row.is_last);
    }

    NodeResponse {
        row_rect,
        box_rect,
        toggled,
        label: label_resp.expect("label always drawn"),
    }
}

/// Draw a LEAF whose content is a multi-line, WRAPPING colored message — e.g. a
/// feature's error text shown under its header. Unlike [`node`] (a single
/// non-wrapping row), the label WRAPS to the available width and the row grows as
/// tall as it needs; the `└`/`├` connector anchors to the FIRST line so it still
/// reads as a normal child leaf under its parent. Returns the full row rect.
pub fn message_leaf(
    ui: &mut egui::Ui,
    guides: &[bool],
    is_last: bool,
    text: &str,
    color: egui::Color32,
) -> egui::Rect {
    let connector_col = guides.len();
    // Align the wrapped text with where a LEAF's label starts: indent to the box
    // slot column, then clear the (empty) box slot + gap.
    let indent = (connector_col as f32 + 1.0) * INDENT + BOX + GAP;
    let line_h = ui.text_style_height(&egui::TextStyle::Body);
    let inner = ui.horizontal(|ui| {
        ui.spacing_mut().item_spacing.x = 0.0;
        ui.add_space(indent);
        // A vertical child claims the remaining row; the wrapping label wraps to its
        // width (minus the right gutter so it clears the sidebar scroll bar).
        ui.vertical(|ui| {
            ui.set_max_width((ui.available_width() - RIGHT_PAD).max(40.0));
            ui.add(
                egui::Label::new(egui::RichText::new(text).color(color))
                    .wrap_mode(egui::TextWrapMode::Wrap),
            );
        });
    });
    let row_rect = inner.response.rect;
    let tick_y = row_rect.top() + line_h * 0.5;
    paint_connectors_at(ui, row_rect, guides, connector_col, is_last, tick_y);
    row_rect
}

/// Paint a `[+]`/`[-]` collapse box: a rounded stroked square with a minus bar
/// (expanded) plus a vertical bar (collapsed → a plus). Theme-aware.
fn paint_box(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
    let painter = ui.painter();
    let stroke = egui::Stroke::new(1.0, line_color(ui));
    painter.rect(
        rect,
        egui::CornerRadius::same(2),
        egui::Color32::TRANSPARENT,
        stroke,
        egui::StrokeKind::Inside,
    );
    let c = rect.center();
    let arm = rect.width() * 0.28;
    let sign = egui::Stroke::new(1.4, ui.visuals().text_color());
    // horizontal bar (always → the minus of a `[-]`)
    painter.line_segment(
        [egui::pos2(c.x - arm, c.y), egui::pos2(c.x + arm, c.y)],
        sign,
    );
    // vertical bar (only when collapsed → completes the plus of a `[+]`)
    if !expanded {
        painter.line_segment(
            [egui::pos2(c.x, c.y - arm), egui::pos2(c.x, c.y + arm)],
            sign,
        );
    }
}

/// Paint the ancestor guide lines + this node's `├`/`└` connector into the row's
/// left gutter (over-drawn by the inter-row spacing so verticals join seamlessly
/// across rows). The `├`/`└` tick lands at the row's vertical center.
fn paint_connectors(
    ui: &egui::Ui,
    row: egui::Rect,
    guides: &[bool],
    connector_col: usize,
    is_last: bool,
) {
    paint_connectors_at(ui, row, guides, connector_col, is_last, row.center().y);
}

/// Like [`paint_connectors`] but with an explicit `tick_y` for the `├`/`└` join —
/// so a MULTI-LINE row (a wrapping message leaf) can anchor its connector to its
/// FIRST line instead of the block's vertical center.
fn paint_connectors_at(
    ui: &egui::Ui,
    row: egui::Rect,
    guides: &[bool],
    connector_col: usize,
    is_last: bool,
    mid: f32,
) {
    let painter = ui.painter();
    let stroke = egui::Stroke::new(1.0, line_color(ui));
    let sp = ui.spacing().item_spacing.y + 1.0;
    let left = row.left();
    let top = row.top() - sp;
    let bottom = row.bottom() + sp;
    let col_x = |c: usize| left + c as f32 * INDENT + INDENT * 0.5;

    // Ancestor sibling lines passing through this row.
    for (i, on) in guides.iter().enumerate() {
        if *on {
            let x = col_x(i);
            painter.line_segment([egui::pos2(x, top), egui::pos2(x, bottom)], stroke);
        }
    }
    // This node's own connector: vertical down to mid (└) or through (├), plus a
    // horizontal tick reaching the box slot.
    let x = col_x(connector_col);
    let v_bottom = if is_last { mid } else { bottom };
    painter.line_segment([egui::pos2(x, top), egui::pos2(x, v_bottom)], stroke);
    painter.line_segment(
        [egui::pos2(x, mid), egui::pos2(x + INDENT * 0.5, mid)],
        stroke,
    );
}

/// The subtle connector / box stroke color — the theme's non-interactive
/// foreground, dimmed. Theme-aware (light + dark) and the seam the later theme
/// pass tunes in one place.
fn line_color(ui: &egui::Ui) -> egui::Color32 {
    ui.visuals()
        .widgets
        .noninteractive
        .fg_stroke
        .color
        .gamma_multiply(0.7)
}

#[cfg(test)]
mod tests {
    //! Guard the drag-reorder polish: a DRAGGABLE row is a reorder handle, so its
    //! label must not be text-selectable — otherwise the press that begins a
    //! reorder also anchors an egui label text-selection that the ensuing drag
    //! SWEEPS across every passed-over row (the reported "rows I mouse over get
    //! highlighted" bug). These drive the REAL `node` widget through raw egui
    //! frames (like the scene/settings panel tests) and read egui's own
    //! `LabelSelectionState` — the exact signal, no pixels (the wgpu canvas
    //! screenshots black headless, so a visual assert is impossible).
    use super::*;
    use eframe::egui;
    use std::cell::RefCell;
    use std::rc::Rc;

    /// Draw `rows` (label, draggable) as tree nodes for one frame, feeding
    /// `events`; capture each row's label rect so the caller can aim pointer
    /// events at the label text.
    fn frame(
        ctx: &egui::Context,
        rows: &[(&str, bool)],
        events: Vec<egui::Event>,
        rects: &Rc<RefCell<Vec<egui::Rect>>>,
    ) {
        let raw = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(400.0, 300.0),
            )),
            events,
            ..Default::default()
        };
        let _ = ctx.run_ui(raw, |ui| {
            ui.spacing_mut().item_spacing.y = 2.0;
            let mut rs = rects.borrow_mut();
            rs.clear();
            let n = rows.len();
            for (i, (label, draggable)) in rows.iter().enumerate() {
                let resp = node(
                    ui,
                    TreeRow::branch(&[], i + 1 == n, false, label).draggable(*draggable),
                    |_| {},
                );
                rs.push(resp.label.rect);
            }
        });
    }

    /// Is any egui label text-selection currently active?
    fn has_label_selection(ctx: &egui::Context) -> bool {
        ctx.plugin::<egui::text_selection::LabelSelectionState>()
            .lock()
            .has_selection()
    }

    /// Press on the first row's label and drag DOWN across the rows below WITHOUT
    /// releasing (far enough to cross egui's decidedly-dragging threshold), then
    /// report whether a label text-selection formed.
    fn press_drag_from_row0(ctx: &egui::Context, rows: &[(&str, bool)]) -> bool {
        let rects = Rc::new(RefCell::new(Vec::new()));
        frame(ctx, rows, vec![], &rects); // learn the rects
        let (start, end_y) = {
            let rs = rects.borrow();
            (rs[0].center(), rs[rs.len() - 1].center().y)
        };
        frame(
            ctx,
            rows,
            vec![
                egui::Event::PointerMoved(start),
                egui::Event::PointerButton {
                    pos: start,
                    button: egui::PointerButton::Primary,
                    pressed: true,
                    modifiers: egui::Modifiers::default(),
                },
            ],
            &rects,
        );
        for k in 1..=8 {
            let y = start.y + (end_y - start.y) * (k as f32 / 8.0);
            frame(ctx, rows, vec![egui::Event::PointerMoved(egui::pos2(start.x, y))], &rects);
        }
        has_label_selection(ctx)
    }

    #[test]
    fn reorder_handle_press_drag_does_not_text_select_passed_over_rows() {
        // Real history layout: a draggable feature handle above selectable field
        // leaves. Dragging the handle must anchor NO selection to sweep.
        let ctx = egui::Context::default();
        let rows = [("FeatureA", true), ("field one", false), ("field two", false)];
        assert!(
            !press_drag_from_row0(&ctx, &rows),
            "dragging a reorder handle must not create/ sweep a label text selection"
        );
    }

    #[test]
    fn selectable_non_drag_row_still_text_selects_on_press_drag() {
        // Positive control / shared-helper no-regression: a NON-draggable row is
        // still selectable, so the same press-drag DOES text-select — proving the
        // suppression is scoped to drag handles, not a global kill of selection.
        let ctx = egui::Context::default();
        let rows = [("field one", false), ("field two", false), ("field three", false)];
        assert!(
            press_drag_from_row0(&ctx, &rows),
            "a normal selectable tree row must still support text selection"
        );
    }
}