Skip to main content

brep_app/
column_tree.rs

1//! A tree table with resizable, reorderable, hideable, sortable columns.
2//!
3//! Callers supply columns, rows, and layout; [`ColumnTreeOut`] reports edits and
4//! actions for the caller to apply after drawing. Consumer-specific behavior stays
5//! in the panel. The first visible column uses [`crate::panels::tree::node`].
6//!
7//! Layout is mutated directly; callers persist it when `layout_changed` is set.
8//! [`ColumnLayout::frozen`] pins leading columns, so this widget owns horizontal
9//! scrolling. Expansion remains caller-owned. Transient drag, menu, and text-edit
10//! state lives in egui memory keyed by [`ColumnTreeSpec::id`]. Text edits commit on
11//! focus loss or Enter because applying a cell edit may rebuild geometry.
12//!
13//! Row actions share one menu for cell clicks and right-clicks. Disabled entries
14//! remain visible with tooltips explaining why they are unavailable.
15
16use crate::color::parse_hex_color;
17use crate::panels::tree::{self, TreeRow};
18use eframe::egui;
19use serde_json::Value;
20use std::collections::{HashMap, HashSet};
21
22/// Minimum column width, in points — narrow enough to park a column out of the
23/// way, wide enough that its resize grip is still catchable.
24pub const MIN_COLUMN_WIDTH: f32 = 28.0;
25
26/// The default width of a column whose [`ColumnLayout`] carries none.
27pub const DEFAULT_COLUMN_WIDTH: f32 = 110.0;
28
29/// Width of the draggable divider strip at a header cell's right edge.
30const GRIP: f32 = 5.0;
31
32/// Horizontal padding inside a cell, so text does not touch the divider.
33const CELL_PAD: f32 = 3.0;
34
35/// Width of the band between the frozen columns and the scrolling ones.
36const FREEZE_GAP: f32 = 4.0;
37
38/// What a column's cells are, and therefore which editor the widget draws.
39#[derive(Debug, Clone, PartialEq)]
40pub enum CellKind {
41    /// Free text. Committed on focus-loss / Enter, NOT per keystroke.
42    Text,
43    /// A number, edited with a drag-value.
44    Numeric { step: f64 },
45    /// A fixed set of choices — a combobox. An empty string is always
46    /// offered as the "unset" choice, so a user can clear a cell.
47    Choice { options: Vec<String> },
48    /// An action button in every row of the column. The widget reports the
49    /// click ([`ColumnTreeOut::buttons`]); the consumer acts.
50    Button { label: String },
51    /// The row's ACTION MENU trigger. Every row draws `label`; clicking it
52    /// opens that row's [`RowNode::actions`] — the same menu a right-click on
53    /// the row opens. A row that declares no action draws the trigger greyed.
54    Actions { label: String },
55    /// Read-only status glyphs drawn inline, from a cell value shaped
56    /// `[{"glyph": "⏚", "color": "#ff9f0a", "tooltip": "…"}, …]` (`color` and
57    /// `tooltip` optional). A plain text cell cannot colour part of its own
58    /// content, and these glyphs carry meaning IN their colour — a constraint
59    /// status, an out-of-date badge — so they get a kind rather than being
60    /// flattened into a string.
61    Badges,
62    /// A boolean, drawn as a checkbox. The cell's value is read with
63    /// [`Value::as_bool`]; a missing value reads false. A row that is not
64    /// [`RowNode::editable`] still DRAWS its box (so the column stays
65    /// readable) but cannot be clicked — a derived grouping row has no state
66    /// of its own to toggle.
67    Toggle,
68    /// Displayed, never edited (a derived value — a rolled-up quantity, a
69    /// computed length).
70    ReadOnly,
71}
72
73/// One column: its cell key, its heading, its editor, its default width.
74#[derive(Debug, Clone, PartialEq)]
75pub struct ColumnSpec {
76    /// The key this column reads out of [`RowNode::cells`]. Unique per spec.
77    pub key: String,
78    /// The heading text.
79    pub label: String,
80    pub kind: CellKind,
81    /// Width used when [`ColumnLayout::widths`] carries none for this key.
82    pub default_width: f32,
83}
84
85impl ColumnSpec {
86    /// A column with the default width.
87    pub fn new(key: impl Into<String>, label: impl Into<String>, kind: CellKind) -> Self {
88        Self {
89            key: key.into(),
90            label: label.into(),
91            kind,
92            default_width: DEFAULT_COLUMN_WIDTH,
93        }
94    }
95
96    pub fn width(mut self, width: f32) -> Self {
97        self.default_width = width;
98        self
99    }
100}
101
102/// One entry in a row's action menu: what to call it, whether this ROW allows
103/// it, and how to draw it. Everything here is the consumer's vocabulary — the
104/// widget only ever compares [`RowAction::id`] for equality when it reports the
105/// click back.
106#[derive(Debug, Clone, PartialEq)]
107pub struct RowAction {
108    /// Stable id, handed back in [`RowActionClick::action`].
109    pub id: String,
110    /// The menu text.
111    pub label: String,
112    /// Hover text. Set it on a DISABLED entry to say why it is refused — the
113    /// entry is greyed, not hidden, so this is where the reason is told.
114    pub tooltip: String,
115    /// Whether THIS ROW allows it. A disabled entry is drawn greyed and
116    /// reports nothing.
117    pub enabled: bool,
118    /// Draw a separator line above this entry (grouping, e.g. before a
119    /// destructive tail).
120    pub separator_above: bool,
121    /// Draw in the error colour — an entry that destroys something.
122    pub destructive: bool,
123}
124
125impl RowAction {
126    /// An enabled entry.
127    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
128        Self {
129            id: id.into(),
130            label: label.into(),
131            tooltip: String::new(),
132            enabled: true,
133            separator_above: false,
134            destructive: false,
135        }
136    }
137
138    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
139        self.tooltip = text.into();
140        self
141    }
142
143    /// Refuse this entry ON THIS ROW, saying why (the greyed entry's tooltip).
144    pub fn disabled(mut self, why: impl Into<String>) -> Self {
145        self.enabled = false;
146        self.tooltip = why.into();
147        self
148    }
149
150    pub fn separator_above(mut self) -> Self {
151        self.separator_above = true;
152        self
153    }
154
155    pub fn destructive(mut self) -> Self {
156        self.destructive = true;
157        self
158    }
159}
160
161/// One row. Rows nest — this is a TREE, not a flat table — and each row's
162/// cells are looked up by column key, so adding a column never touches the
163/// row builder's shape.
164#[derive(Debug, Clone, Default, PartialEq)]
165pub struct RowNode {
166    /// Stable, unique across the whole tree — the id every out-value names.
167    pub id: String,
168    /// Cell values by column key. A missing key draws an empty cell.
169    pub cells: HashMap<String, Value>,
170    /// Whether this row's cells accept edits. A read-only row still draws its
171    /// values (and its buttons), it just cannot be typed into — the BOM's
172    /// nested sub-assembly rows, whose data belongs to another document.
173    pub editable: bool,
174    /// Draw the first column's label emphasized (the selected row).
175    pub selected: bool,
176    /// Expansion is the CALLER's, as in `panels::tree`.
177    pub expanded: bool,
178    /// What this row offers, in menu order. EMPTY means the row offers
179    /// nothing: its trigger cell draws greyed and a right-click on it opens
180    /// nothing (the BOM's nested sub-assembly rows, which own no feature in
181    /// this document).
182    pub actions: Vec<RowAction>,
183    pub children: Vec<RowNode>,
184}
185
186impl RowNode {
187    pub fn new(id: impl Into<String>) -> Self {
188        Self {
189            id: id.into(),
190            editable: true,
191            ..Default::default()
192        }
193    }
194
195    /// Set one cell.
196    pub fn cell(mut self, key: impl Into<String>, value: Value) -> Self {
197        self.cells.insert(key.into(), value);
198        self
199    }
200
201    /// Set this row's action menu.
202    pub fn actions(mut self, actions: Vec<RowAction>) -> Self {
203        self.actions = actions;
204        self
205    }
206}
207
208/// The user's column arrangement — the widget WRITES this (header drag,
209/// divider drag, hide/show, sort click) and the consumer owns and persists it.
210#[derive(Debug, Clone, Default, PartialEq)]
211pub struct ColumnLayout {
212    /// Column keys in display order. Keys absent from the spec are ignored;
213    /// spec columns absent from `order` are appended in spec order, so a
214    /// consumer may start with an empty layout and get the spec's order.
215    pub order: Vec<String>,
216    /// Column keys the user has hidden.
217    pub hidden: HashSet<String>,
218    /// Per-column width overrides, in points.
219    pub widths: HashMap<String, f32>,
220    /// `(column key, ascending)`. `None` = the consumer's own row order.
221    pub sort: Option<(String, bool)>,
222    /// How many LEADING columns are held fixed while the rest scroll
223    /// horizontally. Counted over the arranged order INCLUDING hidden columns,
224    /// so hiding a frozen column does not silently promote the next one into
225    /// the frozen region. `0` = nothing frozen, one plain scrolling table.
226    pub frozen: usize,
227}
228
229/// One cell was edited.
230#[derive(Debug, Clone, PartialEq)]
231pub struct CellEdit {
232    pub row_id: String,
233    pub column: String,
234    pub value: Value,
235}
236
237/// A [`CellKind::Button`] cell was clicked.
238#[derive(Debug, Clone, PartialEq)]
239pub struct CellClick {
240    pub row_id: String,
241    pub column: String,
242}
243
244/// A row's action menu fired.
245#[derive(Debug, Clone, PartialEq)]
246pub struct RowActionClick {
247    pub row_id: String,
248    /// The [`RowAction::id`] the consumer declared.
249    pub action: String,
250}
251
252/// Everything the consumer supplies. Borrowed; the widget holds no state of
253/// its own beyond egui memory.
254pub struct ColumnTreeSpec<'a> {
255    /// Scope key for this tree's transient view state and widget ids. Must be
256    /// STABLE and UNIQUE per tree — two trees sharing it share their drag
257    /// state and their text buffers.
258    pub id: &'a str,
259    /// The columns, in spec order (the fallback order when `layout.order` does
260    /// not name them).
261    pub columns: &'a [ColumnSpec],
262    /// An optional always-open root row above the tree (`"Assembly"`), drawn
263    /// with its own cells taken from `root_cells`.
264    pub root_label: Option<&'a str>,
265    /// Cells for the root row, when there is one.
266    pub root_cells: Option<&'a HashMap<String, Value>>,
267    /// Text drawn in place of the body when `rows` is empty.
268    pub empty_hint: Option<&'a str>,
269    /// Prefix for every published hit key. `""` for a consumer that shows one
270    /// tree at a time.
271    pub hits_prefix: &'a str,
272}
273
274/// What the user did in one drawn frame.
275#[derive(Debug, Default, Clone, PartialEq)]
276pub struct ColumnTreeOut {
277    /// Cell edits, in draw order. A frame can carry at most one (egui gives
278    /// one widget the focus), but the vec keeps the caller from having to care.
279    pub edits: Vec<CellEdit>,
280    /// Button cells clicked this frame.
281    pub buttons: Vec<CellClick>,
282    /// Row action-menu entries chosen this frame.
283    pub actions: Vec<RowActionClick>,
284    /// A row's `[+]`/`[-]` box was clicked — the caller flips its own
285    /// expansion state (expansion is the caller's, as in `panels::tree`).
286    pub toggled: Option<String>,
287    /// A row's first-column label was clicked (select).
288    pub clicked: Option<String>,
289    /// A row (any of its cells, in either pane) is under the pointer this
290    /// frame — the consumer's hover-to-highlight hook. `None` when the pointer
291    /// is over the header, the root row, or off the table.
292    pub hovered: Option<String>,
293    /// The layout was changed by the user (reorder / resize / hide / sort) —
294    /// the caller persists it.
295    pub layout_changed: bool,
296}
297
298/// Draw ONE complete column tree and return what the user did.
299///
300/// `hits`, when supplied, receives widget screen rects for the headed
301/// verifier, all prefixed with [`ColumnTreeSpec::hits_prefix`]:
302///
303/// | key | what |
304/// |---|---|
305/// | `col:{key}` | a column heading |
306/// | `grip:{key}` | a heading's resize divider |
307/// | `row:{row id}` | the first column's label |
308/// | `box:{row id}` | the first column's collapse box |
309/// | `cell:{row id}:{col key}` | one cell's editor |
310/// | `menu:{row id}` | the row's action-menu trigger cell |
311/// | `menuitem:{row id}:{action id}` | one entry of the OPEN action menu |
312/// | `freeze:divider` | the frozen / scrolling boundary (only when frozen) |
313pub fn column_tree(
314    ui: &mut egui::Ui,
315    spec: &ColumnTreeSpec<'_>,
316    layout: &mut ColumnLayout,
317    rows: &[RowNode],
318    mut hits: Option<&mut HashMap<String, egui::Rect>>,
319) -> ColumnTreeOut {
320    let mut out = ColumnTreeOut::default();
321    // The arranged order INCLUDING the hidden columns: the freeze boundary is
322    // counted over THIS, so hiding a frozen column cannot silently promote the
323    // next one into the frozen band.
324    let arranged = arranged_columns(spec, layout);
325    let visible: Vec<&ColumnSpec> = arranged
326        .iter()
327        .copied()
328        .filter(|column| !layout.hidden.contains(&column.key))
329        .collect();
330    if visible.is_empty() {
331        ui.label(egui::RichText::new("(every column is hidden)").weak());
332        return out;
333    }
334
335    // Column widths, in the drawn order.
336    let widths: Vec<f32> = visible
337        .iter()
338        .map(|column| column_width(layout, column))
339        .collect();
340
341    // How many DRAWN columns are frozen. Freezing every column is meaningless
342    // — there is nothing left to scroll it against — and would strand any
343    // column past the pane's right edge with no way to reach it, so it reads
344    // as "frozen: none".
345    let mut frozen = arranged
346        .iter()
347        .take(layout.frozen)
348        .filter(|column| !layout.hidden.contains(&column.key))
349        .count();
350    if frozen >= visible.len() {
351        frozen = 0;
352    }
353
354    ui.spacing_mut().item_spacing.y = 2.0;
355    let full = ui.available_rect_before_wrap();
356    // The frozen band never eats the whole width — something has to be left to
357    // scroll in.
358    let frozen_width: f32 = widths[..frozen]
359        .iter()
360        .sum::<f32>()
361        .min((full.width() - MIN_COLUMN_WIDTH).max(0.0));
362
363    // Which row's action menu either trigger asked for, this frame.
364    let mut pending: Option<MenuOpen> = None;
365    // Heading bounds from BOTH panes, merged: a reorder drag that crosses the
366    // freeze boundary must find its drop target, because dropping a column on
367    // the other side of the boundary is how a column is frozen or unfrozen
368    // with the mouse.
369    let mut bounds: Vec<(String, f32, f32)> = Vec::new();
370    let mut bottom = full.top();
371
372    if frozen > 0 {
373        let rect = egui::Rect::from_min_max(
374            full.min,
375            egui::pos2(full.min.x + frozen_width, full.max.y),
376        );
377        let mut pane = ui.new_child(
378            egui::UiBuilder::new()
379                .max_rect(rect)
380                .layout(egui::Layout::top_down(egui::Align::Min))
381                .id_salt((spec.id, "column-tree-frozen")),
382        );
383        // Clip the frozen band HORIZONTALLY only: its height belongs to the
384        // caller (an outer vertical scroll area owns that axis).
385        pane.set_clip_rect(pane.clip_rect().intersect(egui::Rect::from_x_y_ranges(
386            rect.x_range(),
387            ui.clip_rect().y_range(),
388        )));
389        pane.spacing_mut().item_spacing.y = 2.0;
390        draw_pane(
391            &mut pane,
392            spec,
393            layout,
394            &visible[..frozen],
395            &widths[..frozen],
396            0,
397            frozen_width,
398            rows,
399            &mut hits,
400            &mut out,
401            &mut pending,
402            &mut bounds,
403        );
404        bottom = bottom.max(pane.min_rect().bottom());
405    }
406
407    // The scrolling remainder. The widget owns this scroll area rather than the
408    // caller, because a scroll area OUTSIDE the widget would carry the frozen
409    // columns away with everything else.
410    let scroll_left = full.min.x + if frozen > 0 { frozen_width + FREEZE_GAP } else { 0.0 };
411    let scroll_rect = egui::Rect::from_min_max(egui::pos2(scroll_left, full.min.y), full.max);
412    let viewport = scroll_rect.width();
413    let mut pane = ui.new_child(
414        egui::UiBuilder::new()
415            .max_rect(scroll_rect)
416            .layout(egui::Layout::top_down(egui::Align::Min))
417            .id_salt((spec.id, "column-tree-scrolling")),
418    );
419    let rest: f32 = widths[frozen..].iter().sum();
420    // (egui's default `ScrollSource` drags to scroll on TOUCH only, which is
421    // what we want: a mouse drag-to-scroll would fight the header's reorder
422    // drag and a text cell's selection drag.)
423    egui::ScrollArea::horizontal()
424        .id_salt((spec.id, "column-tree-hscroll"))
425        .show(&mut pane, |ui: &mut egui::Ui| {
426            ui.spacing_mut().item_spacing.y = 2.0;
427            draw_pane(
428                ui,
429                spec,
430                layout,
431                &visible[frozen..],
432                &widths[frozen..],
433                frozen,
434                rest.max(viewport),
435                rows,
436                &mut hits,
437                &mut out,
438                &mut pending,
439                &mut bounds,
440            );
441            if rest > viewport {
442                // A GUTTER for the horizontal scrollbar. egui's bars float over
443                // the content, and this one lands on the LAST ROW — where it
444                // silently eats every click on the bottom half of that row's
445                // cells (found by the headed verifier: the action trigger opened
446                // from its top edge and not from its centre).
447                let scroll = ui.spacing().scroll;
448                ui.add_space(scroll.bar_width + scroll.bar_inner_margin + scroll.bar_outer_margin);
449            }
450        });
451    bottom = bottom.max(pane.min_rect().bottom());
452
453    let used = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, bottom));
454    ui.advance_cursor_after_rect(used);
455
456    // The boundary, so the user can SEE which columns are pinned.
457    if frozen > 0 {
458        let x = full.min.x + frozen_width + FREEZE_GAP * 0.5;
459        let divider = egui::Rect::from_min_max(
460            egui::pos2(x - 1.0, used.top()),
461            egui::pos2(x + 1.0, used.bottom()),
462        );
463        ui.painter()
464            .rect_filled(divider, 0.0, ui.visuals().widgets.active.bg_fill);
465        publish(&mut hits, spec, "freeze:divider", divider);
466    }
467
468    finish_reorder(ui, spec, layout, &arranged, &bounds, &mut out);
469    // ONE menu, drawn from whatever record a trigger left. `pending` is applied
470    // AFTER it — apply it first and the popup's own close-on-click would see
471    // the very click that opened it and shut immediately.
472    row_action_menu(ui, spec, rows, &mut hits, &mut out, &mut pending);
473    out
474}
475
476/// Draw ONE pane — a contiguous run of columns, with the header and every row.
477/// The frozen band and the scrolling remainder are the same code walking the
478/// same rows in the same order, which is what keeps them aligned.
479///
480/// `offset` is the drawn index of `columns[0]`, so the pane holding column 0
481/// (and only it) draws the TREE cell. `band` is the width every row spans here.
482#[allow(clippy::too_many_arguments)]
483fn draw_pane(
484    ui: &mut egui::Ui,
485    spec: &ColumnTreeSpec<'_>,
486    layout: &mut ColumnLayout,
487    columns: &[&ColumnSpec],
488    widths: &[f32],
489    offset: usize,
490    band: f32,
491    rows: &[RowNode],
492    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
493    out: &mut ColumnTreeOut,
494    pending: &mut Option<MenuOpen>,
495    bounds: &mut Vec<(String, f32, f32)>,
496) {
497    header(ui, spec, layout, columns, widths, band, hits, out, bounds);
498    ui.separator();
499
500    if let Some(label) = spec.root_label {
501        let mut cells = spec.root_cells.cloned().unwrap_or_default();
502        // The root's label belongs to whichever column is drawn FIRST — the
503        // tree column follows the user's order, so it is not a fixed key.
504        if offset == 0 {
505            cells.insert(columns[0].key.clone(), Value::String(label.to_string()));
506        }
507        let root = RowNode {
508            id: format!("{}__root", spec.id),
509            cells,
510            editable: false,
511            selected: false,
512            expanded: true,
513            actions: Vec::new(),
514            children: Vec::new(),
515        };
516        draw_row(
517            ui, spec, columns, widths, offset, band, &root, &[], true, true, hits, out, pending,
518        );
519    }
520
521    if rows.is_empty() {
522        if let Some(hint) = spec.empty_hint {
523            if offset == 0 {
524                let guides = tree::child_guides(&[], true);
525                tree::node(ui, TreeRow::leaf(&guides, true, hint), |_| {});
526            } else {
527                // The other pane still spends the same row, so the two panes
528                // keep the same height.
529                ui.allocate_exact_size(
530                    egui::vec2(band, ui.spacing().interact_size.y),
531                    egui::Sense::hover(),
532                );
533            }
534        }
535        return;
536    }
537
538    let ordered = sorted_siblings(rows, layout);
539    let last = ordered.len();
540    for (index, row) in ordered.iter().enumerate() {
541        draw_subtree(
542            ui,
543            spec,
544            layout,
545            columns,
546            widths,
547            offset,
548            band,
549            row,
550            &[],
551            index + 1 == last,
552            hits,
553            out,
554            pending,
555        );
556    }
557}
558
559/// Every spec column in the user's order — `layout.order` first (spec columns
560/// only), then any spec column the layout has never heard of. Hidden columns
561/// KEEP their slot here; the drawn set filters them out afterwards, and the
562/// freeze boundary counts over this list.
563fn arranged_columns<'a>(
564    spec: &'a ColumnTreeSpec<'_>,
565    layout: &ColumnLayout,
566) -> Vec<&'a ColumnSpec> {
567    let mut out: Vec<&ColumnSpec> = Vec::new();
568    for key in &layout.order {
569        if let Some(column) = spec.columns.iter().find(|c| &c.key == key) {
570            if !out.iter().any(|c| c.key == column.key) {
571                out.push(column);
572            }
573        }
574    }
575    for column in spec.columns {
576        if !out.iter().any(|c| c.key == column.key) {
577            out.push(column);
578        }
579    }
580    out
581}
582
583fn column_width(layout: &ColumnLayout, column: &ColumnSpec) -> f32 {
584    layout
585        .widths
586        .get(&column.key)
587        .copied()
588        .unwrap_or(column.default_width)
589        .max(MIN_COLUMN_WIDTH)
590}
591
592/// One pane's header: a heading per column (click = sort, drag = reorder,
593/// right-click = the show/hide checklist) with a resize grip at each right
594/// edge. The drop of a reorder drag is resolved by [`finish_reorder`] once
595/// BOTH panes have contributed their bounds.
596#[allow(clippy::too_many_arguments)]
597fn header(
598    ui: &mut egui::Ui,
599    spec: &ColumnTreeSpec<'_>,
600    layout: &mut ColumnLayout,
601    visible: &[&ColumnSpec],
602    widths: &[f32],
603    width: f32,
604    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
605    out: &mut ColumnTreeOut,
606    bounds: &mut Vec<(String, f32, f32)>,
607) {
608    let height = ui.spacing().interact_size.y;
609    let (band, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
610    let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
611    let dragging: Option<String> = ui.data(|d| d.get_temp(drag_key));
612
613    let mut x = band.left();
614    // The reorder DROP target is decided from the pointer's x at drag end, so
615    // the boundaries are collected while the headings are laid out.
616    let first = bounds.len();
617    for (column, width) in visible.iter().zip(widths) {
618        let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
619        bounds.push((column.key.clone(), cell.left(), cell.right()));
620
621        let resp = ui.interact(
622            cell,
623            ui.id().with(("column-tree-head", spec.id, &column.key)),
624            egui::Sense::click_and_drag(),
625        );
626        let held = dragging.as_deref() == Some(column.key.as_str());
627        let fill = if held {
628            ui.visuals().selection.bg_fill.gamma_multiply(0.45)
629        } else if resp.hovered() {
630            ui.visuals().widgets.hovered.bg_fill
631        } else {
632            ui.visuals().widgets.noninteractive.bg_fill
633        };
634        ui.painter().rect_filled(cell, 0.0, fill);
635        // The sort marker rides the heading text, so the sorted column is
636        // obvious without a second row of chrome.
637        let marker = match &layout.sort {
638            Some((key, true)) if key == &column.key => " \u{25B2}",
639            Some((key, false)) if key == &column.key => " \u{25BC}",
640            _ => "",
641        };
642        let text = format!("{}{marker}", column.label);
643        ui.painter().text(
644            egui::pos2(cell.left() + CELL_PAD, cell.center().y),
645            egui::Align2::LEFT_CENTER,
646            elide(ui, &text, *width - 2.0 * CELL_PAD),
647            egui::TextStyle::Body.resolve(ui.style()),
648            ui.visuals().strong_text_color(),
649        );
650        publish(hits, spec, &format!("col:{}", column.key), cell);
651
652        // Right-click ANY heading → the show/hide checklist. Hiding lives here
653        // rather than on a toolbar because the column is the thing being
654        // hidden and this is where the user's hand already is.
655        resp.context_menu(|ui| {
656            ui.label(egui::RichText::new("Columns").strong());
657            for candidate in spec.columns {
658                let mut shown = !layout.hidden.contains(&candidate.key);
659                if ui.checkbox(&mut shown, &candidate.label).changed() {
660                    if shown {
661                        layout.hidden.remove(&candidate.key);
662                    } else {
663                        layout.hidden.insert(candidate.key.clone());
664                    }
665                    out.layout_changed = true;
666                }
667            }
668        });
669
670        // A CLICK sorts. egui reports `clicked()` false once a press turns
671        // into a drag, so the click and the reorder drag share the heading
672        // without a mode.
673        if resp.clicked() {
674            layout.sort = match &layout.sort {
675                Some((key, true)) if key == &column.key => Some((column.key.clone(), false)),
676                Some((key, false)) if key == &column.key => None,
677                _ => Some((column.key.clone(), true)),
678            };
679            out.layout_changed = true;
680        }
681        if resp.drag_started() {
682            ui.data_mut(|d| d.insert_temp(drag_key, column.key.clone()));
683        }
684
685        ui.painter().line_segment(
686            [
687                egui::pos2(cell.right(), band.top()),
688                egui::pos2(cell.right(), band.bottom()),
689            ],
690            ui.visuals().widgets.noninteractive.bg_stroke,
691        );
692        x = cell.right();
693    }
694
695    // The resize grips, in a SECOND pass. A grip straddles the divider, so it
696    // overlaps the heading to its right — and egui hands an overlapped point to
697    // the LAST widget registered there. Interleaved with the headings, every
698    // grip would therefore lose its own hit to the next heading and no column
699    // would ever resize.
700    for (column, (_, _, right)) in visible.iter().zip(&bounds[first..]) {
701        let grip_rect = egui::Rect::from_min_max(
702            egui::pos2(right - GRIP * 0.5, band.top()),
703            egui::pos2(right + GRIP * 0.5, band.bottom()),
704        );
705        let grip = ui.interact(
706            grip_rect,
707            ui.id().with(("column-tree-grip", spec.id, &column.key)),
708            egui::Sense::drag(),
709        );
710        if grip.hovered() || grip.dragged() {
711            ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
712        }
713        if grip.dragged() {
714            let next = (column_width(layout, column) + grip.drag_delta().x).max(MIN_COLUMN_WIDTH);
715            layout.widths.insert(column.key.clone(), next);
716            out.layout_changed = true;
717        }
718        publish(hits, spec, &format!("grip:{}", column.key), grip_rect);
719    }
720}
721
722/// Finish a reorder: on release, the dragged column moves to whichever heading
723/// the pointer is over — in EITHER pane, so dragging a column across the freeze
724/// boundary is what freezes or unfreezes it.
725fn finish_reorder(
726    ui: &egui::Ui,
727    spec: &ColumnTreeSpec<'_>,
728    layout: &mut ColumnLayout,
729    arranged: &[&ColumnSpec],
730    bounds: &[(String, f32, f32)],
731    out: &mut ColumnTreeOut,
732) {
733    let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
734    let Some(held) = ui.data(|d| d.get_temp::<String>(drag_key)) else {
735        return;
736    };
737    if ui.input(|i| i.pointer.any_down()) {
738        return;
739    }
740    ui.data_mut(|d| d.remove::<String>(drag_key));
741    let Some(pos) = ui.input(|i| i.pointer.latest_pos()) else {
742        return;
743    };
744    if let Some((target, _, _)) = bounds
745        .iter()
746        .find(|(_, left, right)| pos.x >= *left && pos.x < *right)
747    {
748        if *target != held && move_column(layout, arranged, &held, target) {
749            out.layout_changed = true;
750        }
751    }
752}
753
754/// Move `held` to `target`'s slot in the layout order, materializing the
755/// current arranged order first so a layout that never named its columns still
756/// reorders correctly. Returns whether anything moved.
757fn move_column(
758    layout: &mut ColumnLayout,
759    arranged: &[&ColumnSpec],
760    held: &str,
761    target: &str,
762) -> bool {
763    let mut order: Vec<String> = layout.order.clone();
764    // Seed from the drawn order so the first drag on a fresh layout is not a
765    // no-op against an empty `order`.
766    for column in arranged {
767        if !order.iter().any(|key| key == &column.key) {
768            order.push(column.key.clone());
769        }
770    }
771    let Some(from) = order.iter().position(|key| key == held) else {
772        return false;
773    };
774    let key = order.remove(from);
775    let Some(to) = order.iter().position(|k| k == target) else {
776        order.insert(from.min(order.len()), key);
777        return false;
778    };
779    order.insert(to, key);
780    layout.order = order;
781    true
782}
783
784/// Draw one row and, when it is open, its children — the recursion that keeps
785/// the rows a TREE.
786#[allow(clippy::too_many_arguments)]
787fn draw_subtree(
788    ui: &mut egui::Ui,
789    spec: &ColumnTreeSpec<'_>,
790    layout: &ColumnLayout,
791    visible: &[&ColumnSpec],
792    widths: &[f32],
793    offset: usize,
794    band: f32,
795    row: &RowNode,
796    guides: &[bool],
797    is_last: bool,
798    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
799    out: &mut ColumnTreeOut,
800    pending: &mut Option<MenuOpen>,
801) {
802    draw_row(
803        ui, spec, visible, widths, offset, band, row, guides, is_last, false, hits, out, pending,
804    );
805    if !row.expanded || row.children.is_empty() {
806        return;
807    }
808    let child_guides = tree::child_guides(guides, is_last);
809    let ordered = sorted_siblings(&row.children, layout);
810    let last = ordered.len();
811    for (index, child) in ordered.iter().enumerate() {
812        draw_subtree(
813            ui,
814            spec,
815            layout,
816            visible,
817            widths,
818            offset,
819            band,
820            child,
821            &child_guides,
822            index + 1 == last,
823            hits,
824            out,
825            pending,
826        );
827    }
828}
829
830/// Sort ONE level of siblings by the layout's sort column. Sorting is
831/// per-level so the nesting survives it — a child never overtakes its parent.
832/// A stable sort, so an unsorted-equal run keeps the consumer's order.
833fn sorted_siblings<'a>(rows: &'a [RowNode], layout: &ColumnLayout) -> Vec<&'a RowNode> {
834    let mut out: Vec<&RowNode> = rows.iter().collect();
835    if let Some((key, ascending)) = &layout.sort {
836        out.sort_by(|a, b| {
837            let ordering = compare_cells(a.cells.get(key), b.cells.get(key));
838            if *ascending {
839                ordering
840            } else {
841                ordering.reverse()
842            }
843        });
844    }
845    out
846}
847
848/// Order two cell values: numbers numerically, everything else by its display
849/// text case-insensitively, and an ABSENT/null cell last in ascending order
850/// (an unfilled cell is not a small value, it is a missing one).
851fn compare_cells(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
852    use std::cmp::Ordering;
853    let empty = |value: Option<&Value>| match value {
854        None | Some(Value::Null) => true,
855        Some(Value::String(text)) => text.is_empty(),
856        _ => false,
857    };
858    match (empty(a), empty(b)) {
859        (true, true) => return Ordering::Equal,
860        (true, false) => return Ordering::Greater,
861        (false, true) => return Ordering::Less,
862        (false, false) => {}
863    }
864    if let (Some(Value::Number(x)), Some(Value::Number(y))) = (a, b) {
865        if let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) {
866            return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
867        }
868    }
869    display_text(a).to_lowercase().cmp(&display_text(b).to_lowercase())
870}
871
872/// A cell value as the text a cell shows: a string bare (not JSON-quoted), a
873/// number in its shortest form, anything else as its JSON.
874fn display_text(value: Option<&Value>) -> String {
875    match value {
876        None | Some(Value::Null) => String::new(),
877        Some(Value::String(text)) => text.clone(),
878        Some(other) => other.to_string(),
879    }
880}
881
882/// Draw ONE row: the tree cell in column 0, an editor in each other column.
883#[allow(clippy::too_many_arguments)]
884fn draw_row(
885    ui: &mut egui::Ui,
886    spec: &ColumnTreeSpec<'_>,
887    visible: &[&ColumnSpec],
888    widths: &[f32],
889    offset: usize,
890    band_width: f32,
891    row: &RowNode,
892    guides: &[bool],
893    is_last: bool,
894    root: bool,
895    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
896    out: &mut ColumnTreeOut,
897    pending: &mut Option<MenuOpen>,
898) {
899    let height = ui.spacing().interact_size.y;
900    let (band, _) = ui.allocate_exact_size(egui::vec2(band_width, height), egui::Sense::hover());
901    let clip = ui.clip_rect();
902
903    // TRIGGER 2 — a right-click ANYWHERE on the row. Read off the raw pointer
904    // rather than from a `Response`, because the cell editors are registered
905    // AFTER this band and egui hands an overlapped point to the LAST widget
906    // registered there: a `context_menu` on the band would silently never fire
907    // over a text cell. Reading the position also leaves the band's `hover`
908    // sense alone, so no primary click / select / sort / drag path changes.
909    let over_row = band.intersect(clip);
910    if !root && over_row.is_positive() && ui.rect_contains_pointer(over_row) {
911        out.hovered = Some(row.id.clone());
912    }
913    if !row.actions.is_empty()
914        && over_row.is_positive()
915        && ui.input(|i| i.pointer.secondary_clicked())
916    {
917        if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
918            // ...and nothing floating (an open menu, the header's own column
919            // checklist) is above that point.
920            let above = ui.ctx().layer_id_at(pos);
921            let ours = above.is_none() || above == Some(ui.layer_id());
922            if over_row.contains(pos) && ours {
923                *pending = Some(MenuOpen {
924                    row: row.id.clone(),
925                    pos,
926                });
927            }
928        }
929    }
930
931    // The SELECTION band. A selected row is emphasised across its whole width,
932    // not just by bolding the tree cell's text: this table is as wide as its
933    // configured columns, and a picked component has to be findable at a
934    // glance from anywhere in it. Painted BEFORE the cells so every editor
935    // draws over it, and drawn in both panes (each calls this with its own
936    // band) so the highlight does not stop at the frozen boundary.
937    if row.selected && !root {
938        let visible_band = band.intersect(clip);
939        if visible_band.is_positive() {
940            ui.painter().rect_filled(
941                visible_band,
942                0.0,
943                ui.visuals().selection.bg_fill.gamma_multiply(0.35),
944            );
945        }
946    }
947
948    let mut x = band.left();
949    for (index, (column, width)) in visible.iter().zip(widths).enumerate() {
950        let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
951        x = cell.right();
952        let Some(cell_clip) = cell.intersect(clip).is_positive().then_some(cell.intersect(clip))
953        else {
954            continue;
955        };
956        let mut child = ui.new_child(
957            egui::UiBuilder::new()
958                .max_rect(cell.shrink2(egui::vec2(CELL_PAD, 0.0)))
959                .layout(egui::Layout::left_to_right(egui::Align::Center))
960                .id_salt(("column-tree-cell", spec.id, &row.id, &column.key)),
961        );
962        child.set_clip_rect(cell_clip);
963
964        if offset + index == 0 {
965            // The TREE cell — drawn by `panels::tree::node` itself, so the
966            // connector rules and collapse boxes are the shared ones.
967            let label = display_text(row.cells.get(&column.key));
968            let expandable = !row.children.is_empty();
969            let mut tree_row = TreeRow {
970                guides,
971                is_last,
972                expandable,
973                expanded: row.expanded,
974                root,
975                glyph: None,
976                label: &label,
977                selected: row.selected,
978                draggable: false,
979                tint: None,
980            };
981            if root {
982                tree_row.expandable = true;
983                tree_row.expanded = true;
984            }
985            let resp = tree::node(&mut child, tree_row, |_| {});
986            publish(hits, spec, &format!("row:{}", row.id), resp.label.rect);
987            publish(hits, spec, &format!("box:{}", row.id), resp.box_rect);
988            if resp.toggled {
989                out.toggled = Some(row.id.clone());
990            }
991            if resp.label.clicked() {
992                out.clicked = Some(row.id.clone());
993            }
994        } else {
995            let rect = cell_editor(&mut child, spec, row, column, out, pending);
996            publish(
997                hits,
998                spec,
999                &format!("cell:{}:{}", row.id, column.key),
1000                rect,
1001            );
1002            if matches!(column.kind, CellKind::Actions { .. }) {
1003                publish(hits, spec, &format!("menu:{}", row.id), rect);
1004            }
1005        }
1006    }
1007}
1008
1009/// Draw ONE non-tree cell's editor and return its rect. A non-editable row
1010/// still SHOWS its value and still offers its buttons and its action menu — it
1011/// just cannot be typed into.
1012fn cell_editor(
1013    ui: &mut egui::Ui,
1014    spec: &ColumnTreeSpec<'_>,
1015    row: &RowNode,
1016    column: &ColumnSpec,
1017    out: &mut ColumnTreeOut,
1018    pending: &mut Option<MenuOpen>,
1019) -> egui::Rect {
1020    let value = row.cells.get(&column.key);
1021    let width = ui.available_width();
1022    let mut emit = |new_value: Value| {
1023        out.edits.push(CellEdit {
1024            row_id: row.id.clone(),
1025            column: column.key.clone(),
1026            value: new_value,
1027        });
1028    };
1029
1030    match &column.kind {
1031        CellKind::Button { label } => {
1032            let button = ui.add_sized([width, ui.available_height()], egui::Button::new(label));
1033            if button.clicked() {
1034                out.buttons.push(CellClick {
1035                    row_id: row.id.clone(),
1036                    column: column.key.clone(),
1037                });
1038            }
1039            button.rect
1040        }
1041        CellKind::Actions { label } => {
1042            // TRIGGER 1 — the actions cell. Like the right-click it only
1043            // RECORDS which row was asked for; the menu is drawn once, after
1044            // every row, from that record. A row that offers nothing draws the
1045            // trigger greyed rather than dropping it, so the column stays a
1046            // column.
1047            let offered = !row.actions.is_empty();
1048            let button = ui
1049                .add_enabled_ui(offered, |ui| {
1050                    ui.add_sized([width, ui.available_height()], egui::Button::new(label))
1051                })
1052                .inner;
1053            if button.clicked() {
1054                *pending = Some(MenuOpen {
1055                    row: row.id.clone(),
1056                    pos: button.rect.left_bottom(),
1057                });
1058            }
1059            button.rect
1060        }
1061        CellKind::ReadOnly => ui.add(egui::Label::new(display_text(value)).truncate()).rect,
1062        CellKind::Badges => {
1063            let badges = value.and_then(Value::as_array).cloned().unwrap_or_default();
1064            ui.horizontal(|ui| {
1065                ui.spacing_mut().item_spacing.x = 3.0;
1066                for badge in &badges {
1067                    let glyph = badge.get("glyph").and_then(Value::as_str).unwrap_or("");
1068                    if glyph.is_empty() {
1069                        continue;
1070                    }
1071                    let color = badge
1072                        .get("color")
1073                        .and_then(Value::as_str)
1074                        .and_then(parse_hex_color)
1075                        .unwrap_or_else(|| ui.visuals().text_color());
1076                    // A badge glyph carries meaning IN its colour, so it is
1077                    // drawn from the icon catalog and tinted — there is no icon
1078                    // font to render the character with.
1079                    let label = match crate::icon_text::glyph(ui, glyph, color) {
1080                        Some(art) => ui.add(art),
1081                        None => ui.label(egui::RichText::new(glyph).color(color)),
1082                    };
1083                    if let Some(tip) = badge.get("tooltip").and_then(Value::as_str) {
1084                        label.on_hover_text(tip);
1085                    }
1086                }
1087            })
1088            .response
1089            .rect
1090        }
1091        CellKind::Toggle => {
1092            // Drawn for every row, clickable only on an editable one: the
1093            // weak-label fallback below would render a bool as the text
1094            // "false", which is not a checkbox.
1095            let mut on = value.and_then(Value::as_bool).unwrap_or(false);
1096            let box_ = ui
1097                .add_enabled_ui(row.editable, |ui| ui.checkbox(&mut on, ""))
1098                .inner;
1099            if box_.changed() {
1100                emit(Value::Bool(on));
1101            }
1102            box_.rect
1103        }
1104        _ if !row.editable => ui
1105            .add(
1106                egui::Label::new(egui::RichText::new(display_text(value)).weak())
1107                    .truncate(),
1108            )
1109            .rect,
1110        CellKind::Text | CellKind::Numeric { .. } | CellKind::Choice { .. } => {
1111            // The VALUE editors are shared (see [`value_editor`]) — the table
1112            // and the part-properties dialog must agree on what editing a
1113            // `Text` / `Numeric` / `Choice` attribute feels like, because they
1114            // edit the very same stored records.
1115            let salt = egui::Id::new(("column-tree-cell", spec.id, &row.id, &column.key));
1116            let size = egui::vec2(width, ui.available_height());
1117            let (edited, rect) = value_editor(ui, salt, &column.kind, value, size);
1118            if let Some(new_value) = edited {
1119                emit(new_value);
1120            }
1121            rect
1122        }
1123    }
1124}
1125
1126/// Draw the editor for ONE value of a [`CellKind`] and report the committed
1127/// edit, if any. `salt` seeds the per-widget egui ids (an in-progress text
1128/// buffer, a combo's open state), so it must be stable for a given value and
1129/// distinct between values; `size` is the space to fill.
1130///
1131/// Split out of [`cell_editor`] so a consumer that is NOT a table can use the
1132/// same editors: the part-properties dialog edits the same BOM attribute
1133/// records the BOM table's cells do, and a `Choice` that could be cleared in
1134/// one place but not the other, or a text field that committed per keystroke
1135/// in one and on focus-loss in the other, would be one attribute with two
1136/// behaviours. The kinds that only make sense inside a table row (`Button`,
1137/// `Actions`, `Badges`, `Toggle`) stay in [`cell_editor`]; asking for one here
1138/// draws the value read-only.
1139pub fn value_editor(
1140    ui: &mut egui::Ui,
1141    salt: egui::Id,
1142    kind: &CellKind,
1143    value: Option<&Value>,
1144    size: egui::Vec2,
1145) -> (Option<Value>, egui::Rect) {
1146    match kind {
1147        CellKind::Text => {
1148            // An in-progress edit lives in egui memory and commits on
1149            // focus-loss / Enter. Per-keystroke commits are wrong here: a
1150            // consumer's commit can be arbitrarily expensive (the BOM's
1151            // part-level one re-signs a part document and re-heals every
1152            // instance of it), and a half-typed value is not a value.
1153            let buffer_id = salt.with("text");
1154            let stored = display_text(value);
1155            let mut buffer: String =
1156                ui.data(|d| d.get_temp(buffer_id)).unwrap_or_else(|| stored.clone());
1157            let edit = ui.add_sized(size, egui::TextEdit::singleline(&mut buffer));
1158            let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1159            let mut committed = None;
1160            if edit.has_focus() || edit.changed() {
1161                ui.data_mut(|d| d.insert_temp(buffer_id, buffer.clone()));
1162            }
1163            if edit.lost_focus() || entered {
1164                ui.data_mut(|d| d.remove::<String>(buffer_id));
1165                if buffer != stored {
1166                    committed = Some(Value::String(buffer));
1167                }
1168            } else if !edit.has_focus() {
1169                // Unfocused: track the model, so an edit made elsewhere (a
1170                // packed fan-out landing on a sibling row) shows immediately.
1171                ui.data_mut(|d| d.remove::<String>(buffer_id));
1172            }
1173            (committed, edit.rect)
1174        }
1175        CellKind::Numeric { step } => {
1176            let mut number = value.and_then(Value::as_f64).unwrap_or(0.0);
1177            let drag = ui.add_sized(size, egui::DragValue::new(&mut number).speed(*step));
1178            let committed = drag.changed().then(|| serde_json::json!(number));
1179            (committed, drag.rect)
1180        }
1181        CellKind::Choice { options } => {
1182            let current = display_text(value);
1183            let mut chosen = current.clone();
1184            let combo = egui::ComboBox::from_id_salt(salt.with("combo"))
1185                .width(size.x)
1186                .selected_text(if current.is_empty() { "—" } else { &current })
1187                .show_ui(ui, |ui| {
1188                    // The blank choice is always offered: without it a dropdown
1189                    // cell can be set but never cleared.
1190                    ui.selectable_value(&mut chosen, String::new(), "—");
1191                    for option in options {
1192                        ui.selectable_value(&mut chosen, option.clone(), option);
1193                    }
1194                });
1195            let committed = (chosen != current).then(|| Value::String(chosen));
1196            (committed, combo.response.rect)
1197        }
1198        _ => {
1199            let label = ui.add(egui::Label::new(display_text(value)).truncate());
1200            (None, label.rect)
1201        }
1202    }
1203}
1204
1205/// Which row's action menu is open, and the point it was opened at. Lives in
1206/// egui memory keyed by [`ColumnTreeSpec::id`] — one record, so there can only
1207/// ever be one open menu per tree.
1208#[derive(Clone)]
1209struct MenuOpen {
1210    row: String,
1211    pos: egui::Pos2,
1212}
1213
1214/// THE action menu — one definition, drawn from whichever trigger recorded a
1215/// [`MenuOpen`]. Neither trigger renders anything itself, so the cell click and
1216/// the right-click cannot drift apart: there is only one menu to drift.
1217fn row_action_menu(
1218    ui: &mut egui::Ui,
1219    spec: &ColumnTreeSpec<'_>,
1220    rows: &[RowNode],
1221    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1222    out: &mut ColumnTreeOut,
1223    pending: &mut Option<MenuOpen>,
1224) {
1225    let key = egui::Id::new((spec.id, "column-tree-menu"));
1226    let mut open: Option<MenuOpen> = ui.data(|d| d.get_temp(key));
1227    let was_open = open.as_ref().map(|state| state.row.clone());
1228
1229    if let Some(state) = open.clone() {
1230        // A row that has gone (or lost every action) takes its menu with it.
1231        match find_row(rows, &state.row) {
1232            Some(row) if !row.actions.is_empty() => {
1233                let mut still_open = true;
1234                egui::Popup::new(
1235                    key.with("popup"),
1236                    ui.ctx().clone(),
1237                    egui::PopupAnchor::Position(state.pos),
1238                    ui.layer_id(),
1239                )
1240                .open_bool(&mut still_open)
1241                .kind(egui::PopupKind::Menu)
1242                .layout(egui::Layout::top_down_justified(egui::Align::Min))
1243                .width(160.0)
1244                .show(|ui| {
1245                    for action in &row.actions {
1246                        if action.separator_above {
1247                            ui.separator();
1248                        }
1249                        // The caption may lead with a catalogued glyph (🔒 Fix,
1250                        // ✖ Delete); it is drawn as artwork, not as a character.
1251                        // A destructive entry keeps its red — on the artwork as
1252                        // well as the text.
1253                        let color =
1254                            action.destructive.then(|| ui.visuals().error_fg_color);
1255                        let button =
1256                            crate::icon_text::icon_button_colored(ui, &action.label, color);
1257                        let entry = ui.add_enabled(action.enabled, button);
1258                        publish(
1259                            hits,
1260                            spec,
1261                            &format!("menuitem:{}:{}", row.id, action.id),
1262                            entry.rect,
1263                        );
1264                        if !action.tooltip.is_empty() {
1265                            // A greyed entry's tooltip is where its refusal is
1266                            // explained, so it has to be the DISABLED hover.
1267                            if action.enabled {
1268                                entry.clone().on_hover_text(&action.tooltip);
1269                            } else {
1270                                entry.clone().on_disabled_hover_text(&action.tooltip);
1271                            }
1272                        }
1273                        if entry.clicked() {
1274                            out.actions.push(RowActionClick {
1275                                row_id: row.id.clone(),
1276                                action: action.id.clone(),
1277                            });
1278                        }
1279                    }
1280                });
1281                if !still_open {
1282                    open = None;
1283                }
1284            }
1285            _ => open = None,
1286        }
1287    }
1288
1289    // Applied AFTER the draw (see the call site): applying it first would let
1290    // the popup's own close-on-click see the very click that opened it. A
1291    // trigger fired on the row whose menu just closed itself is a TOGGLE — the
1292    // second click on the same trigger shuts it.
1293    if let Some(next) = pending.take() {
1294        let toggled_off = open.is_none() && was_open.as_deref() == Some(next.row.as_str());
1295        open = (!toggled_off).then_some(next);
1296    }
1297    match &open {
1298        Some(state) => ui.data_mut(|d| {
1299            d.insert_temp(key, state.clone());
1300        }),
1301        None => ui.data_mut(|d| d.remove::<MenuOpen>(key)),
1302    }
1303}
1304
1305/// The row with `id`, anywhere in the tree.
1306fn find_row<'a>(rows: &'a [RowNode], id: &str) -> Option<&'a RowNode> {
1307    for row in rows {
1308        if row.id == id {
1309            return Some(row);
1310        }
1311        if let Some(found) = find_row(&row.children, id) {
1312            return Some(found);
1313        }
1314    }
1315    None
1316}
1317
1318/// Truncate `text` with an ellipsis so it fits `width`.
1319fn elide(ui: &egui::Ui, text: &str, width: f32) -> String {
1320    let font = egui::TextStyle::Body.resolve(ui.style());
1321    let measure = |candidate: &str| {
1322        ui.painter()
1323            .layout_no_wrap(candidate.to_string(), font.clone(), egui::Color32::WHITE)
1324            .rect
1325            .width()
1326    };
1327    if width <= 0.0 || measure(text) <= width {
1328        return text.to_string();
1329    }
1330    let mut cut: Vec<char> = text.chars().collect();
1331    while !cut.is_empty() {
1332        cut.pop();
1333        let candidate: String = cut.iter().collect::<String>() + "\u{2026}";
1334        if measure(&candidate) <= width {
1335            return candidate;
1336        }
1337    }
1338    String::new()
1339}
1340
1341fn publish(
1342    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1343    spec: &ColumnTreeSpec<'_>,
1344    key: &str,
1345    rect: egui::Rect,
1346) {
1347    if let Some(map) = hits.as_deref_mut() {
1348        map.insert(format!("{}{key}", spec.hits_prefix), rect);
1349    }
1350}
1351
1352// BREP private tests: 4c7e71f6d875f1f6