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 was clicked (select) — on its first column's label, or anywhere
288    /// else along its band that no cell of its own claimed.
289    pub clicked: Option<String>,
290    /// A row was double-clicked (open its dialog), over the same area as
291    /// [`Self::clicked`]. egui reports the second press as a click too, so on
292    /// this frame `clicked` names the same row: a consumer checks this first.
293    pub double_clicked: Option<String>,
294    /// A row (any of its cells, in either pane) is under the pointer this
295    /// frame — the consumer's hover-to-highlight hook. `None` when the pointer
296    /// is over the header, the root row, or off the table.
297    pub hovered: Option<String>,
298    /// The layout was changed by the user (reorder / resize / hide / sort) —
299    /// the caller persists it.
300    pub layout_changed: bool,
301}
302
303/// Draw ONE complete column tree and return what the user did.
304///
305/// `hits`, when supplied, receives widget screen rects for the headed
306/// verifier, all prefixed with [`ColumnTreeSpec::hits_prefix`]:
307///
308/// | key | what |
309/// |---|---|
310/// | `col:{key}` | a column heading |
311/// | `grip:{key}` | a heading's resize divider |
312/// | `row:{row id}` | the row's click band (of the pane holding column 0) |
313/// | `box:{row id}` | the first column's collapse box |
314/// | `cell:{row id}:{col key}` | one cell's editor |
315/// | `menu:{row id}` | the row's action-menu trigger cell |
316/// | `menuitem:{row id}:{action id}` | one entry of the OPEN action menu |
317/// | `freeze:divider` | the frozen / scrolling boundary (only when frozen) |
318pub fn column_tree(
319    ui: &mut egui::Ui,
320    spec: &ColumnTreeSpec<'_>,
321    layout: &mut ColumnLayout,
322    rows: &[RowNode],
323    mut hits: Option<&mut HashMap<String, egui::Rect>>,
324) -> ColumnTreeOut {
325    let mut out = ColumnTreeOut::default();
326    // The arranged order INCLUDING the hidden columns: the freeze boundary is
327    // counted over THIS, so hiding a frozen column cannot silently promote the
328    // next one into the frozen band.
329    let arranged = arranged_columns(spec, layout);
330    let visible: Vec<&ColumnSpec> = arranged
331        .iter()
332        .copied()
333        .filter(|column| !layout.hidden.contains(&column.key))
334        .collect();
335    if visible.is_empty() {
336        ui.label(egui::RichText::new("(every column is hidden)").weak());
337        return out;
338    }
339
340    // Column widths, in the drawn order.
341    let widths: Vec<f32> = visible
342        .iter()
343        .map(|column| column_width(layout, column))
344        .collect();
345
346    // How many DRAWN columns are frozen. Freezing every column is meaningless
347    // — there is nothing left to scroll it against — and would strand any
348    // column past the pane's right edge with no way to reach it, so it reads
349    // as "frozen: none".
350    let mut frozen = arranged
351        .iter()
352        .take(layout.frozen)
353        .filter(|column| !layout.hidden.contains(&column.key))
354        .count();
355    if frozen >= visible.len() {
356        frozen = 0;
357    }
358
359    ui.spacing_mut().item_spacing.y = 2.0;
360    // The width to lay out in. `available_rect_before_wrap` can be WIDER than
361    // what is on screen — an outer scroll area hands its inner `Ui` a max_rect
362    // that ignores its own viewport — and the horizontal scroll area below
363    // sizes its viewport from this rect. A `full` wider than the clip
364    // overstates that viewport by the difference, and egui clamps scrolling at
365    // `content - viewport`: the last columns then have no scroll position that
366    // brings them inside the pane. In the shipped BOM that stranded the
367    // rightmost column, which is the ACTIONS one — its trigger cell was never
368    // drawn at any scroll offset, so `menu:<row>` was a key the pane could not
369    // publish. Found by the bom-menu verifier migration (2026-09-13); the
370    // right-click trigger on the row was carrying the whole feature.
371    let full = ui.available_rect_before_wrap();
372    let full = egui::Rect::from_min_max(
373        full.min,
374        egui::pos2(full.max.x.min(ui.clip_rect().max.x), full.max.y),
375    );
376    // The frozen band never eats the whole width — something has to be left to
377    // scroll in.
378    let frozen_width: f32 = widths[..frozen]
379        .iter()
380        .sum::<f32>()
381        .min((full.width() - MIN_COLUMN_WIDTH).max(0.0));
382
383    // Which row's action menu either trigger asked for, this frame.
384    let mut pending: Option<MenuOpen> = None;
385    // Heading bounds from BOTH panes, merged: a reorder drag that crosses the
386    // freeze boundary must find its drop target, because dropping a column on
387    // the other side of the boundary is how a column is frozen or unfrozen
388    // with the mouse.
389    let mut bounds: Vec<(String, f32, f32)> = Vec::new();
390    let mut bottom = full.top();
391
392    if frozen > 0 {
393        let rect = egui::Rect::from_min_max(
394            full.min,
395            egui::pos2(full.min.x + frozen_width, full.max.y),
396        );
397        let mut pane = ui.new_child(
398            egui::UiBuilder::new()
399                .max_rect(rect)
400                .layout(egui::Layout::top_down(egui::Align::Min))
401                .id_salt((spec.id, "column-tree-frozen")),
402        );
403        // Clip the frozen band HORIZONTALLY only: its height belongs to the
404        // caller (an outer vertical scroll area owns that axis).
405        pane.set_clip_rect(pane.clip_rect().intersect(egui::Rect::from_x_y_ranges(
406            rect.x_range(),
407            ui.clip_rect().y_range(),
408        )));
409        pane.spacing_mut().item_spacing.y = 2.0;
410        draw_pane(
411            &mut pane,
412            spec,
413            layout,
414            &visible[..frozen],
415            &widths[..frozen],
416            0,
417            frozen_width,
418            rows,
419            &mut hits,
420            &mut out,
421            &mut pending,
422            &mut bounds,
423        );
424        bottom = bottom.max(pane.min_rect().bottom());
425    }
426
427    // The scrolling remainder. The widget owns this scroll area rather than the
428    // caller, because a scroll area OUTSIDE the widget would carry the frozen
429    // columns away with everything else.
430    let scroll_left = full.min.x + if frozen > 0 { frozen_width + FREEZE_GAP } else { 0.0 };
431    let scroll_rect = egui::Rect::from_min_max(egui::pos2(scroll_left, full.min.y), full.max);
432    let viewport = scroll_rect.width();
433    let mut pane = ui.new_child(
434        egui::UiBuilder::new()
435            .max_rect(scroll_rect)
436            .layout(egui::Layout::top_down(egui::Align::Min))
437            .id_salt((spec.id, "column-tree-scrolling")),
438    );
439    let rest: f32 = widths[frozen..].iter().sum();
440    // (egui's default `ScrollSource` drags to scroll on TOUCH only, which is
441    // what we want: a mouse drag-to-scroll would fight the header's reorder
442    // drag and a text cell's selection drag.)
443    egui::ScrollArea::horizontal()
444        .id_salt((spec.id, "column-tree-hscroll"))
445        .show(&mut pane, |ui: &mut egui::Ui| {
446            ui.spacing_mut().item_spacing.y = 2.0;
447            draw_pane(
448                ui,
449                spec,
450                layout,
451                &visible[frozen..],
452                &widths[frozen..],
453                frozen,
454                rest.max(viewport),
455                rows,
456                &mut hits,
457                &mut out,
458                &mut pending,
459                &mut bounds,
460            );
461            if rest > viewport {
462                // A GUTTER for the horizontal scrollbar. egui's bars float over
463                // the content, and this one lands on the LAST ROW — where it
464                // silently eats every click on the bottom half of that row's
465                // cells (found by the headed verifier: the action trigger opened
466                // from its top edge and not from its centre).
467                let scroll = ui.spacing().scroll;
468                ui.add_space(scroll.bar_width + scroll.bar_inner_margin + scroll.bar_outer_margin);
469            }
470        });
471    bottom = bottom.max(pane.min_rect().bottom());
472
473    let used = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, bottom));
474    ui.advance_cursor_after_rect(used);
475
476    // The boundary, so the user can SEE which columns are pinned.
477    if frozen > 0 {
478        let x = full.min.x + frozen_width + FREEZE_GAP * 0.5;
479        let divider = egui::Rect::from_min_max(
480            egui::pos2(x - 1.0, used.top()),
481            egui::pos2(x + 1.0, used.bottom()),
482        );
483        ui.painter()
484            .rect_filled(divider, 0.0, ui.visuals().widgets.active.bg_fill);
485        publish(&mut hits, spec, "freeze:divider", divider);
486    }
487
488    finish_reorder(ui, spec, layout, &arranged, &bounds, &mut out);
489    // ONE menu, drawn from whatever record a trigger left. `pending` is applied
490    // AFTER it — apply it first and the popup's own close-on-click would see
491    // the very click that opened it and shut immediately.
492    row_action_menu(ui, spec, rows, &mut hits, &mut out, &mut pending);
493    out
494}
495
496/// Draw ONE pane — a contiguous run of columns, with the header and every row.
497/// The frozen band and the scrolling remainder are the same code walking the
498/// same rows in the same order, which is what keeps them aligned.
499///
500/// `offset` is the drawn index of `columns[0]`, so the pane holding column 0
501/// (and only it) draws the TREE cell. `band` is the width every row spans here.
502#[allow(clippy::too_many_arguments)]
503fn draw_pane(
504    ui: &mut egui::Ui,
505    spec: &ColumnTreeSpec<'_>,
506    layout: &mut ColumnLayout,
507    columns: &[&ColumnSpec],
508    widths: &[f32],
509    offset: usize,
510    band: f32,
511    rows: &[RowNode],
512    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
513    out: &mut ColumnTreeOut,
514    pending: &mut Option<MenuOpen>,
515    bounds: &mut Vec<(String, f32, f32)>,
516) {
517    header(ui, spec, layout, columns, widths, band, hits, out, bounds);
518    ui.separator();
519
520    if let Some(label) = spec.root_label {
521        let mut cells = spec.root_cells.cloned().unwrap_or_default();
522        // The root's label belongs to whichever column is drawn FIRST — the
523        // tree column follows the user's order, so it is not a fixed key.
524        if offset == 0 {
525            cells.insert(columns[0].key.clone(), Value::String(label.to_string()));
526        }
527        let root = RowNode {
528            id: format!("{}__root", spec.id),
529            cells,
530            editable: false,
531            selected: false,
532            expanded: true,
533            actions: Vec::new(),
534            children: Vec::new(),
535        };
536        draw_row(
537            ui, spec, columns, widths, offset, band, &root, &[], true, true, hits, out, pending,
538        );
539    }
540
541    if rows.is_empty() {
542        if let Some(hint) = spec.empty_hint {
543            if offset == 0 {
544                let guides = tree::child_guides(&[], true);
545                tree::node(ui, TreeRow::leaf(&guides, true, hint), |_| {});
546            } else {
547                // The other pane still spends the same row, so the two panes
548                // keep the same height.
549                ui.allocate_exact_size(
550                    egui::vec2(band, ui.spacing().interact_size.y),
551                    egui::Sense::hover(),
552                );
553            }
554        }
555        return;
556    }
557
558    let ordered = sorted_siblings(rows, layout);
559    let last = ordered.len();
560    for (index, row) in ordered.iter().enumerate() {
561        draw_subtree(
562            ui,
563            spec,
564            layout,
565            columns,
566            widths,
567            offset,
568            band,
569            row,
570            &[],
571            index + 1 == last,
572            hits,
573            out,
574            pending,
575        );
576    }
577}
578
579/// Every spec column in the user's order — `layout.order` first (spec columns
580/// only), then any spec column the layout has never heard of. Hidden columns
581/// KEEP their slot here; the drawn set filters them out afterwards, and the
582/// freeze boundary counts over this list.
583fn arranged_columns<'a>(
584    spec: &'a ColumnTreeSpec<'_>,
585    layout: &ColumnLayout,
586) -> Vec<&'a ColumnSpec> {
587    let mut out: Vec<&ColumnSpec> = Vec::new();
588    for key in &layout.order {
589        if let Some(column) = spec.columns.iter().find(|c| &c.key == key) {
590            if !out.iter().any(|c| c.key == column.key) {
591                out.push(column);
592            }
593        }
594    }
595    for column in spec.columns {
596        if !out.iter().any(|c| c.key == column.key) {
597            out.push(column);
598        }
599    }
600    out
601}
602
603fn column_width(layout: &ColumnLayout, column: &ColumnSpec) -> f32 {
604    layout
605        .widths
606        .get(&column.key)
607        .copied()
608        .unwrap_or(column.default_width)
609        .max(MIN_COLUMN_WIDTH)
610}
611
612/// One pane's header: a heading per column (click = sort, drag = reorder,
613/// right-click = the show/hide checklist) with a resize grip at each right
614/// edge. The drop of a reorder drag is resolved by [`finish_reorder`] once
615/// BOTH panes have contributed their bounds.
616#[allow(clippy::too_many_arguments)]
617fn header(
618    ui: &mut egui::Ui,
619    spec: &ColumnTreeSpec<'_>,
620    layout: &mut ColumnLayout,
621    visible: &[&ColumnSpec],
622    widths: &[f32],
623    width: f32,
624    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
625    out: &mut ColumnTreeOut,
626    bounds: &mut Vec<(String, f32, f32)>,
627) {
628    let height = ui.spacing().interact_size.y;
629    let (band, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
630    let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
631    let dragging: Option<String> = ui.data(|d| d.get_temp(drag_key));
632
633    let mut x = band.left();
634    // The reorder DROP target is decided from the pointer's x at drag end, so
635    // the boundaries are collected while the headings are laid out.
636    let first = bounds.len();
637    for (column, width) in visible.iter().zip(widths) {
638        let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
639        bounds.push((column.key.clone(), cell.left(), cell.right()));
640
641        let resp = ui.interact(
642            cell,
643            ui.id().with(("column-tree-head", spec.id, &column.key)),
644            egui::Sense::click_and_drag(),
645        );
646        let held = dragging.as_deref() == Some(column.key.as_str());
647        let fill = if held {
648            ui.visuals().selection.bg_fill.gamma_multiply(0.45)
649        } else if resp.hovered() {
650            ui.visuals().widgets.hovered.bg_fill
651        } else {
652            ui.visuals().widgets.noninteractive.bg_fill
653        };
654        ui.painter().rect_filled(cell, 0.0, fill);
655        // The sort marker rides the heading text, so the sorted column is
656        // obvious without a second row of chrome.
657        let marker = match &layout.sort {
658            Some((key, true)) if key == &column.key => " \u{25B2}",
659            Some((key, false)) if key == &column.key => " \u{25BC}",
660            _ => "",
661        };
662        let text = format!("{}{marker}", column.label);
663        ui.painter().text(
664            egui::pos2(cell.left() + CELL_PAD, cell.center().y),
665            egui::Align2::LEFT_CENTER,
666            elide(ui, &text, *width - 2.0 * CELL_PAD),
667            egui::TextStyle::Body.resolve(ui.style()),
668            ui.visuals().strong_text_color(),
669        );
670        publish(hits, spec, &format!("col:{}", column.key), cell);
671
672        // Right-click ANY heading → the show/hide checklist. Hiding lives here
673        // rather than on a toolbar because the column is the thing being
674        // hidden and this is where the user's hand already is.
675        resp.context_menu(|ui| {
676            ui.label(egui::RichText::new("Columns").strong());
677            for candidate in spec.columns {
678                let mut shown = !layout.hidden.contains(&candidate.key);
679                if ui.checkbox(&mut shown, &candidate.label).changed() {
680                    if shown {
681                        layout.hidden.remove(&candidate.key);
682                    } else {
683                        layout.hidden.insert(candidate.key.clone());
684                    }
685                    out.layout_changed = true;
686                }
687            }
688        });
689
690        // A CLICK sorts. egui reports `clicked()` false once a press turns
691        // into a drag, so the click and the reorder drag share the heading
692        // without a mode.
693        if resp.clicked() {
694            layout.sort = match &layout.sort {
695                Some((key, true)) if key == &column.key => Some((column.key.clone(), false)),
696                Some((key, false)) if key == &column.key => None,
697                _ => Some((column.key.clone(), true)),
698            };
699            out.layout_changed = true;
700        }
701        if resp.drag_started() {
702            ui.data_mut(|d| d.insert_temp(drag_key, column.key.clone()));
703        }
704
705        ui.painter().line_segment(
706            [
707                egui::pos2(cell.right(), band.top()),
708                egui::pos2(cell.right(), band.bottom()),
709            ],
710            ui.visuals().widgets.noninteractive.bg_stroke,
711        );
712        x = cell.right();
713    }
714
715    // The resize grips, in a SECOND pass. A grip straddles the divider, so it
716    // overlaps the heading to its right — and egui hands an overlapped point to
717    // the LAST widget registered there. Interleaved with the headings, every
718    // grip would therefore lose its own hit to the next heading and no column
719    // would ever resize.
720    for (column, (_, _, right)) in visible.iter().zip(&bounds[first..]) {
721        let grip_rect = egui::Rect::from_min_max(
722            egui::pos2(right - GRIP * 0.5, band.top()),
723            egui::pos2(right + GRIP * 0.5, band.bottom()),
724        );
725        let grip = ui.interact(
726            grip_rect,
727            ui.id().with(("column-tree-grip", spec.id, &column.key)),
728            egui::Sense::drag(),
729        );
730        if grip.hovered() || grip.dragged() {
731            ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
732        }
733        if grip.dragged() {
734            let next = (column_width(layout, column) + grip.drag_delta().x).max(MIN_COLUMN_WIDTH);
735            layout.widths.insert(column.key.clone(), next);
736            out.layout_changed = true;
737        }
738        publish(hits, spec, &format!("grip:{}", column.key), grip_rect);
739    }
740}
741
742/// Finish a reorder: on release, the dragged column moves to whichever heading
743/// the pointer is over — in EITHER pane, so dragging a column across the freeze
744/// boundary is what freezes or unfreezes it.
745fn finish_reorder(
746    ui: &egui::Ui,
747    spec: &ColumnTreeSpec<'_>,
748    layout: &mut ColumnLayout,
749    arranged: &[&ColumnSpec],
750    bounds: &[(String, f32, f32)],
751    out: &mut ColumnTreeOut,
752) {
753    let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
754    let Some(held) = ui.data(|d| d.get_temp::<String>(drag_key)) else {
755        return;
756    };
757    if ui.input(|i| i.pointer.any_down()) {
758        return;
759    }
760    ui.data_mut(|d| d.remove::<String>(drag_key));
761    let Some(pos) = ui.input(|i| i.pointer.latest_pos()) else {
762        return;
763    };
764    if let Some((target, _, _)) = bounds
765        .iter()
766        .find(|(_, left, right)| pos.x >= *left && pos.x < *right)
767    {
768        if *target != held && move_column(layout, arranged, &held, target) {
769            out.layout_changed = true;
770        }
771    }
772}
773
774/// Move `held` to `target`'s slot in the layout order, materializing the
775/// current arranged order first so a layout that never named its columns still
776/// reorders correctly. Returns whether anything moved.
777fn move_column(
778    layout: &mut ColumnLayout,
779    arranged: &[&ColumnSpec],
780    held: &str,
781    target: &str,
782) -> bool {
783    let mut order: Vec<String> = layout.order.clone();
784    // Seed from the drawn order so the first drag on a fresh layout is not a
785    // no-op against an empty `order`.
786    for column in arranged {
787        if !order.iter().any(|key| key == &column.key) {
788            order.push(column.key.clone());
789        }
790    }
791    let Some(from) = order.iter().position(|key| key == held) else {
792        return false;
793    };
794    let key = order.remove(from);
795    let Some(to) = order.iter().position(|k| k == target) else {
796        order.insert(from.min(order.len()), key);
797        return false;
798    };
799    order.insert(to, key);
800    layout.order = order;
801    true
802}
803
804/// Draw one row and, when it is open, its children — the recursion that keeps
805/// the rows a TREE.
806#[allow(clippy::too_many_arguments)]
807fn draw_subtree(
808    ui: &mut egui::Ui,
809    spec: &ColumnTreeSpec<'_>,
810    layout: &ColumnLayout,
811    visible: &[&ColumnSpec],
812    widths: &[f32],
813    offset: usize,
814    band: f32,
815    row: &RowNode,
816    guides: &[bool],
817    is_last: bool,
818    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
819    out: &mut ColumnTreeOut,
820    pending: &mut Option<MenuOpen>,
821) {
822    draw_row(
823        ui, spec, visible, widths, offset, band, row, guides, is_last, false, hits, out, pending,
824    );
825    if !row.expanded || row.children.is_empty() {
826        return;
827    }
828    let child_guides = tree::child_guides(guides, is_last);
829    let ordered = sorted_siblings(&row.children, layout);
830    let last = ordered.len();
831    for (index, child) in ordered.iter().enumerate() {
832        draw_subtree(
833            ui,
834            spec,
835            layout,
836            visible,
837            widths,
838            offset,
839            band,
840            child,
841            &child_guides,
842            index + 1 == last,
843            hits,
844            out,
845            pending,
846        );
847    }
848}
849
850/// Sort ONE level of siblings by the layout's sort column. Sorting is
851/// per-level so the nesting survives it — a child never overtakes its parent.
852/// A stable sort, so an unsorted-equal run keeps the consumer's order.
853fn sorted_siblings<'a>(rows: &'a [RowNode], layout: &ColumnLayout) -> Vec<&'a RowNode> {
854    let mut out: Vec<&RowNode> = rows.iter().collect();
855    if let Some((key, ascending)) = &layout.sort {
856        out.sort_by(|a, b| {
857            let ordering = compare_cells(a.cells.get(key), b.cells.get(key));
858            if *ascending {
859                ordering
860            } else {
861                ordering.reverse()
862            }
863        });
864    }
865    out
866}
867
868/// Order two cell values: numbers numerically, everything else by its display
869/// text case-insensitively, and an ABSENT/null cell last in ascending order
870/// (an unfilled cell is not a small value, it is a missing one).
871fn compare_cells(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
872    use std::cmp::Ordering;
873    let empty = |value: Option<&Value>| match value {
874        None | Some(Value::Null) => true,
875        Some(Value::String(text)) => text.is_empty(),
876        _ => false,
877    };
878    match (empty(a), empty(b)) {
879        (true, true) => return Ordering::Equal,
880        (true, false) => return Ordering::Greater,
881        (false, true) => return Ordering::Less,
882        (false, false) => {}
883    }
884    if let (Some(Value::Number(x)), Some(Value::Number(y))) = (a, b) {
885        if let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) {
886            return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
887        }
888    }
889    display_text(a).to_lowercase().cmp(&display_text(b).to_lowercase())
890}
891
892/// A cell value as the text a cell shows: a string bare (not JSON-quoted), a
893/// number in its shortest form, anything else as its JSON.
894fn display_text(value: Option<&Value>) -> String {
895    match value {
896        None | Some(Value::Null) => String::new(),
897        Some(Value::String(text)) => text.clone(),
898        Some(other) => other.to_string(),
899    }
900}
901
902/// Draw ONE row: the tree cell in column 0, an editor in each other column.
903#[allow(clippy::too_many_arguments)]
904fn draw_row(
905    ui: &mut egui::Ui,
906    spec: &ColumnTreeSpec<'_>,
907    visible: &[&ColumnSpec],
908    widths: &[f32],
909    offset: usize,
910    band_width: f32,
911    row: &RowNode,
912    guides: &[bool],
913    is_last: bool,
914    root: bool,
915    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
916    out: &mut ColumnTreeOut,
917    pending: &mut Option<MenuOpen>,
918) {
919    let height = ui.spacing().interact_size.y;
920    // THE ROW BAND — the row's click target, allocated BEFORE the cells. A row
921    // is picked by clicking anywhere along it, not only on the text in its
922    // first column: in the shipped BOM that text was 40 pt of a 900 pt row, so
923    // 95 % of every row was dead. egui hands an overlapped point to the LAST
924    // widget registered there, and every cell editor is registered after this,
925    // so a button, a checkbox, a text cell and the collapse box all still take
926    // their own clicks — the band receives only what none of them wanted.
927    //
928    // It does NOT take the keyboard focus: the row's Tab stop is the band
929    // inside `tree::node` (drawn once, in whichever pane holds column 0), so a
930    // frozen table does not give one row two stops.
931    let (band, band_resp) =
932        ui.allocate_exact_size(egui::vec2(band_width, height), egui::Sense::CLICK);
933    let clip = ui.clip_rect();
934
935    // TRIGGER 2 — a right-click ANYWHERE on the row. Read off the raw pointer
936    // rather than from a `Response`, because the cell editors are registered
937    // AFTER this band and egui hands an overlapped point to the LAST widget
938    // registered there: a `context_menu` on the band would silently never fire
939    // over a text cell. Reading the position also leaves the band's own primary
940    // click alone, so the select path above and this one cannot fight.
941    let over_row = band.intersect(clip);
942    if !root && over_row.is_positive() && ui.rect_contains_pointer(over_row) {
943        out.hovered = Some(row.id.clone());
944    }
945
946    // The row's published rect IS its band — what a script clicks has to be
947    // what a user can click, and that is the strip, not the name in it.
948    //
949    // A FROZEN table draws the row once per pane, and only the pane holding
950    // column 0 publishes: `click_widget` aims at a rect's CENTRE, and the
951    // centre of the two bands unioned lands in whichever scrolling column
952    // happens to be halfway across — a text editor, a drag-value — where a
953    // click focuses that editor instead of picking the row. The frozen band
954    // leads with the tree column, so its centre is the row. The scrolling
955    // columns are still clickable; they are just not what the key aims at.
956    if !root && offset == 0 && over_row.is_positive() {
957        publish(hits, spec, &format!("row:{}", row.id), over_row);
958    }
959    // A SELECT click anywhere on the band that no cell claimed. The tree cell
960    // reports its own (see below); this is every other column.
961    if !root && band_resp.clicked() {
962        out.clicked = Some(row.id.clone());
963    }
964    if !root && tree::is_double_click(&band_resp) {
965        out.double_clicked = Some(row.id.clone());
966    }
967    if !row.actions.is_empty()
968        && over_row.is_positive()
969        && ui.input(|i| i.pointer.secondary_clicked())
970    {
971        if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
972            // ...and nothing floating (an open menu, the header's own column
973            // checklist) is above that point.
974            let above = ui.ctx().layer_id_at(pos);
975            let ours = above.is_none() || above == Some(ui.layer_id());
976            if over_row.contains(pos) && ours {
977                *pending = Some(MenuOpen {
978                    row: row.id.clone(),
979                    pos,
980                });
981            }
982        }
983    }
984
985    // The SELECTION band. A selected row is emphasised across its whole width,
986    // not just by bolding the tree cell's text: this table is as wide as its
987    // configured columns, and a picked component has to be findable at a
988    // glance from anywhere in it. Painted BEFORE the cells so every editor
989    // draws over it, and drawn in both panes (each calls this with its own
990    // band) so the highlight does not stop at the frozen boundary.
991    if row.selected && !root {
992        let visible_band = band.intersect(clip);
993        if visible_band.is_positive() {
994            ui.painter().rect_filled(
995                visible_band,
996                0.0,
997                ui.visuals().selection.bg_fill.gamma_multiply(0.35),
998            );
999        }
1000    }
1001
1002    let mut x = band.left();
1003    for (index, (column, width)) in visible.iter().zip(widths).enumerate() {
1004        let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
1005        x = cell.right();
1006        let Some(cell_clip) = cell.intersect(clip).is_positive().then_some(cell.intersect(clip))
1007        else {
1008            continue;
1009        };
1010        let mut child = ui.new_child(
1011            egui::UiBuilder::new()
1012                .max_rect(cell.shrink2(egui::vec2(CELL_PAD, 0.0)))
1013                .layout(egui::Layout::left_to_right(egui::Align::Center))
1014                .id_salt(("column-tree-cell", spec.id, &row.id, &column.key)),
1015        );
1016        child.set_clip_rect(cell_clip);
1017
1018        if offset + index == 0 {
1019            // The TREE cell — drawn by `panels::tree::node` itself, so the
1020            // connector rules and collapse boxes are the shared ones.
1021            let label = display_text(row.cells.get(&column.key));
1022            let expandable = !row.children.is_empty();
1023            let mut tree_row = TreeRow {
1024                guides,
1025                is_last,
1026                expandable,
1027                expanded: row.expanded,
1028                root,
1029                glyph: None,
1030                label: &label,
1031                selected: row.selected,
1032                highlighted: false,
1033                draggable: false,
1034                tint: None,
1035            };
1036            if root {
1037                tree_row.expandable = true;
1038                tree_row.expanded = true;
1039            }
1040            let resp = tree::node(&mut child, tree_row, |_| {});
1041            publish(hits, spec, &format!("box:{}", row.id), resp.box_rect);
1042            if resp.toggled {
1043                out.toggled = Some(row.id.clone());
1044            }
1045            // The tree cell has a band of its OWN (`tree::node` draws one), and
1046            // it is registered after this row's, so inside column 0 it is the
1047            // one that hears a blank-space click. Both are the same intent.
1048            if !root && resp.clicked() {
1049                out.clicked = Some(row.id.clone());
1050            }
1051            if !root && resp.double_clicked() {
1052                out.double_clicked = Some(row.id.clone());
1053            }
1054        } else {
1055            let rect = cell_editor(&mut child, spec, row, column, out, pending);
1056            publish(
1057                hits,
1058                spec,
1059                &format!("cell:{}:{}", row.id, column.key),
1060                rect,
1061            );
1062            if matches!(column.kind, CellKind::Actions { .. }) {
1063                publish(hits, spec, &format!("menu:{}", row.id), rect);
1064            }
1065        }
1066    }
1067}
1068
1069/// Draw ONE non-tree cell's editor and return its rect. A non-editable row
1070/// still SHOWS its value and still offers its buttons and its action menu — it
1071/// just cannot be typed into.
1072fn cell_editor(
1073    ui: &mut egui::Ui,
1074    spec: &ColumnTreeSpec<'_>,
1075    row: &RowNode,
1076    column: &ColumnSpec,
1077    out: &mut ColumnTreeOut,
1078    pending: &mut Option<MenuOpen>,
1079) -> egui::Rect {
1080    let value = row.cells.get(&column.key);
1081    let width = ui.available_width();
1082    let mut emit = |new_value: Value| {
1083        out.edits.push(CellEdit {
1084            row_id: row.id.clone(),
1085            column: column.key.clone(),
1086            value: new_value,
1087        });
1088    };
1089
1090    match &column.kind {
1091        CellKind::Button { label } => {
1092            let button = ui.add_sized([width, ui.available_height()], egui::Button::new(label));
1093            if button.clicked() {
1094                out.buttons.push(CellClick {
1095                    row_id: row.id.clone(),
1096                    column: column.key.clone(),
1097                });
1098            }
1099            button.rect
1100        }
1101        CellKind::Actions { label } => {
1102            // TRIGGER 1 — the actions cell. Like the right-click it only
1103            // RECORDS which row was asked for; the menu is drawn once, after
1104            // every row, from that record. A row that offers nothing draws the
1105            // trigger greyed rather than dropping it, so the column stays a
1106            // column.
1107            let offered = !row.actions.is_empty();
1108            let button = ui
1109                .add_enabled_ui(offered, |ui| {
1110                    ui.add_sized([width, ui.available_height()], egui::Button::new(label))
1111                })
1112                .inner;
1113            if button.clicked() {
1114                *pending = Some(MenuOpen {
1115                    row: row.id.clone(),
1116                    pos: button.rect.left_bottom(),
1117                });
1118            }
1119            button.rect
1120        }
1121        // A DISPLAY cell: not selectable, so the click reaches the row band
1122        // behind it. egui labels are drag-to-select by default, which senses
1123        // click and drag and would make a derived value the one piece of the
1124        // row you cannot pick the row by.
1125        CellKind::ReadOnly => ui
1126            .add(
1127                egui::Label::new(display_text(value))
1128                    .selectable(false)
1129                    .truncate(),
1130            )
1131            .rect,
1132        CellKind::Badges => {
1133            let badges = value.and_then(Value::as_array).cloned().unwrap_or_default();
1134            ui.horizontal(|ui| {
1135                ui.spacing_mut().item_spacing.x = 3.0;
1136                for badge in &badges {
1137                    let glyph = badge.get("glyph").and_then(Value::as_str).unwrap_or("");
1138                    if glyph.is_empty() {
1139                        continue;
1140                    }
1141                    let color = badge
1142                        .get("color")
1143                        .and_then(Value::as_str)
1144                        .and_then(parse_hex_color)
1145                        .unwrap_or_else(|| ui.visuals().text_color());
1146                    // A badge glyph carries meaning IN its colour, so it is
1147                    // drawn from the icon catalog and tinted — there is no icon
1148                    // font to render the character with.
1149                    let label = match crate::icon_text::glyph(ui, glyph, color) {
1150                        Some(art) => ui.add(art),
1151                        // …and the text fallback is not selectable either, for
1152                        // the same reason the read-only cell is not.
1153                        None => ui.add(
1154                            egui::Label::new(egui::RichText::new(glyph).color(color))
1155                                .selectable(false),
1156                        ),
1157                    };
1158                    if let Some(tip) = badge.get("tooltip").and_then(Value::as_str) {
1159                        label.on_hover_text(tip);
1160                    }
1161                }
1162            })
1163            .response
1164            .rect
1165        }
1166        CellKind::Toggle => {
1167            // Drawn for every row, clickable only on an editable one: the
1168            // weak-label fallback below would render a bool as the text
1169            // "false", which is not a checkbox.
1170            let mut on = value.and_then(Value::as_bool).unwrap_or(false);
1171            let box_ = ui
1172                .add_enabled_ui(row.editable, |ui| ui.checkbox(&mut on, ""))
1173                .inner;
1174            if box_.changed() {
1175                emit(Value::Bool(on));
1176            }
1177            box_.rect
1178        }
1179        _ if !row.editable => ui
1180            .add(
1181                egui::Label::new(egui::RichText::new(display_text(value)).weak())
1182                    .selectable(false)
1183                    .truncate(),
1184            )
1185            .rect,
1186        CellKind::Text | CellKind::Numeric { .. } | CellKind::Choice { .. } => {
1187            // The VALUE editors are shared (see [`value_editor`]) — the table
1188            // and the part-properties dialog must agree on what editing a
1189            // `Text` / `Numeric` / `Choice` attribute feels like, because they
1190            // edit the very same stored records.
1191            let salt = egui::Id::new(("column-tree-cell", spec.id, &row.id, &column.key));
1192            let size = egui::vec2(width, ui.available_height());
1193            let (edited, rect) = value_editor(ui, salt, &column.kind, value, size);
1194            if let Some(new_value) = edited {
1195                emit(new_value);
1196            }
1197            rect
1198        }
1199    }
1200}
1201
1202/// Draw the editor for ONE value of a [`CellKind`] and report the committed
1203/// edit, if any. `salt` seeds the per-widget egui ids (an in-progress text
1204/// buffer, a combo's open state), so it must be stable for a given value and
1205/// distinct between values; `size` is the space to fill.
1206///
1207/// Split out of [`cell_editor`] so a consumer that is NOT a table can use the
1208/// same editors: the part-properties dialog edits the same BOM attribute
1209/// records the BOM table's cells do, and a `Choice` that could be cleared in
1210/// one place but not the other, or a text field that committed per keystroke
1211/// in one and on focus-loss in the other, would be one attribute with two
1212/// behaviours. The kinds that only make sense inside a table row (`Button`,
1213/// `Actions`, `Badges`, `Toggle`) stay in [`cell_editor`]; asking for one here
1214/// draws the value read-only.
1215pub fn value_editor(
1216    ui: &mut egui::Ui,
1217    salt: egui::Id,
1218    kind: &CellKind,
1219    value: Option<&Value>,
1220    size: egui::Vec2,
1221) -> (Option<Value>, egui::Rect) {
1222    match kind {
1223        CellKind::Text => {
1224            // An in-progress edit lives in egui memory and commits on
1225            // focus-loss / Enter. Per-keystroke commits are wrong here: a
1226            // consumer's commit can be arbitrarily expensive (the BOM's
1227            // part-level one re-signs a part document and re-heals every
1228            // instance of it), and a half-typed value is not a value.
1229            let buffer_id = salt.with("text");
1230            let stored = display_text(value);
1231            let mut buffer: String =
1232                ui.data(|d| d.get_temp(buffer_id)).unwrap_or_else(|| stored.clone());
1233            let edit = ui.add_sized(size, egui::TextEdit::singleline(&mut buffer));
1234            let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1235            let mut committed = None;
1236            if edit.has_focus() || edit.changed() {
1237                ui.data_mut(|d| d.insert_temp(buffer_id, buffer.clone()));
1238            }
1239            if edit.lost_focus() || entered {
1240                ui.data_mut(|d| d.remove::<String>(buffer_id));
1241                if buffer != stored {
1242                    committed = Some(Value::String(buffer));
1243                }
1244            } else if !edit.has_focus() {
1245                // Unfocused: track the model, so an edit made elsewhere (a
1246                // packed fan-out landing on a sibling row) shows immediately.
1247                ui.data_mut(|d| d.remove::<String>(buffer_id));
1248            }
1249            (committed, edit.rect)
1250        }
1251        CellKind::Numeric { step } => {
1252            let mut number = value.and_then(Value::as_f64).unwrap_or(0.0);
1253            let drag = ui.add_sized(size, egui::DragValue::new(&mut number).speed(*step));
1254            let committed = drag.changed().then(|| serde_json::json!(number));
1255            (committed, drag.rect)
1256        }
1257        CellKind::Choice { options } => {
1258            let current = display_text(value);
1259            let mut chosen = current.clone();
1260            let combo = egui::ComboBox::from_id_salt(salt.with("combo"))
1261                .width(size.x)
1262                .selected_text(if current.is_empty() { "—" } else { &current })
1263                .show_ui(ui, |ui| {
1264                    // The blank choice is always offered: without it a dropdown
1265                    // cell can be set but never cleared.
1266                    ui.selectable_value(&mut chosen, String::new(), "—");
1267                    for option in options {
1268                        ui.selectable_value(&mut chosen, option.clone(), option);
1269                    }
1270                });
1271            let committed = (chosen != current).then(|| Value::String(chosen));
1272            (committed, combo.response.rect)
1273        }
1274        _ => {
1275            let label = ui.add(egui::Label::new(display_text(value)).truncate());
1276            (None, label.rect)
1277        }
1278    }
1279}
1280
1281/// Which row's action menu is open, and the point it was opened at. Lives in
1282/// egui memory keyed by [`ColumnTreeSpec::id`] — one record, so there can only
1283/// ever be one open menu per tree.
1284#[derive(Clone)]
1285struct MenuOpen {
1286    row: String,
1287    pos: egui::Pos2,
1288}
1289
1290/// THE action menu — one definition, drawn from whichever trigger recorded a
1291/// [`MenuOpen`]. Neither trigger renders anything itself, so the cell click and
1292/// the right-click cannot drift apart: there is only one menu to drift.
1293fn row_action_menu(
1294    ui: &mut egui::Ui,
1295    spec: &ColumnTreeSpec<'_>,
1296    rows: &[RowNode],
1297    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1298    out: &mut ColumnTreeOut,
1299    pending: &mut Option<MenuOpen>,
1300) {
1301    let key = egui::Id::new((spec.id, "column-tree-menu"));
1302    let mut open: Option<MenuOpen> = ui.data(|d| d.get_temp(key));
1303    let was_open = open.as_ref().map(|state| state.row.clone());
1304
1305    if let Some(state) = open.clone() {
1306        // A row that has gone (or lost every action) takes its menu with it.
1307        match find_row(rows, &state.row) {
1308            Some(row) if !row.actions.is_empty() => {
1309                let (chosen, still_open) = action_menu(
1310                    ui.ctx(),
1311                    ui.layer_id(),
1312                    key.with("popup"),
1313                    state.pos,
1314                    &row.actions,
1315                    |action, rect| {
1316                        publish(hits, spec, &format!("menuitem:{}:{}", row.id, action.id), rect);
1317                    },
1318                );
1319                if let Some(action) = chosen {
1320                    out.actions.push(RowActionClick { row_id: row.id.clone(), action });
1321                }
1322                if !still_open {
1323                    open = None;
1324                }
1325            }
1326            _ => open = None,
1327        }
1328    }
1329
1330    // Applied AFTER the draw (see the call site): applying it first would let
1331    // the popup's own close-on-click see the very click that opened it. A
1332    // trigger fired on the row whose menu just closed itself is a TOGGLE — the
1333    // second click on the same trigger shuts it.
1334    if let Some(next) = pending.take() {
1335        let toggled_off = open.is_none() && was_open.as_deref() == Some(next.row.as_str());
1336        open = (!toggled_off).then_some(next);
1337    }
1338    match &open {
1339        Some(state) => ui.data_mut(|d| {
1340            d.insert_temp(key, state.clone());
1341        }),
1342        None => ui.data_mut(|d| d.remove::<MenuOpen>(key)),
1343    }
1344}
1345
1346/// Draw ONE action menu as a popup at `pos` — the body of a row's menu, and
1347/// the menu an object drawn somewhere else opens for that same row: a placed
1348/// view or a sheet dimension right-clicked on the paper, a PMI label chip
1349/// right-clicked in the 3D view. Every one of them draws its entries HERE, so
1350/// the object's menu and its tree row's cannot drift apart.
1351///
1352/// `on_entry` receives each entry's screen rect as it is drawn (the caller's
1353/// `menuitem:` hit key). Returns the id of the entry chosen this frame and
1354/// whether the popup is still open — it closes on any click, on an entry or
1355/// elsewhere, and on Escape.
1356pub fn action_menu(
1357    ctx: &egui::Context,
1358    layer: egui::LayerId,
1359    id: egui::Id,
1360    pos: egui::Pos2,
1361    actions: &[RowAction],
1362    mut on_entry: impl FnMut(&RowAction, egui::Rect),
1363) -> (Option<String>, bool) {
1364    let mut still_open = true;
1365    let mut chosen: Option<String> = None;
1366    egui::Popup::new(id, ctx.clone(), egui::PopupAnchor::Position(pos), layer)
1367        .open_bool(&mut still_open)
1368        .kind(egui::PopupKind::Menu)
1369        .layout(egui::Layout::top_down_justified(egui::Align::Min))
1370        .width(160.0)
1371        .show(|ui| {
1372            for action in actions {
1373                if action.separator_above {
1374                    ui.separator();
1375                }
1376                // The caption may lead with a catalogued glyph (🔒 Fix,
1377                // ✖ Delete); it is drawn as artwork, not as a character. A
1378                // destructive entry keeps its red — on the artwork as well as
1379                // the text.
1380                let color = action.destructive.then(|| ui.visuals().error_fg_color);
1381                // An entry never wraps. The popup's `Area` keeps the width it
1382                // measured the last time it was up, so without this a menu
1383                // opened after a narrower one broke its longer entries over
1384                // two lines ("Remove from / sheet" after a dimension's menu).
1385                let button = crate::icon_text::icon_button_colored(ui, &action.label, color)
1386                    .wrap_mode(egui::TextWrapMode::Extend);
1387                let entry = ui.add_enabled(action.enabled, button);
1388                on_entry(action, entry.rect);
1389                if !action.tooltip.is_empty() {
1390                    // A greyed entry's tooltip is where its refusal is
1391                    // explained, so it has to be the DISABLED hover.
1392                    if action.enabled {
1393                        entry.clone().on_hover_text(&action.tooltip);
1394                    } else {
1395                        entry.clone().on_disabled_hover_text(&action.tooltip);
1396                    }
1397                }
1398                if entry.clicked() {
1399                    chosen = Some(action.id.clone());
1400                }
1401            }
1402        });
1403    (chosen, still_open)
1404}
1405
1406/// The row with `id`, anywhere in the tree.
1407fn find_row<'a>(rows: &'a [RowNode], id: &str) -> Option<&'a RowNode> {
1408    for row in rows {
1409        if row.id == id {
1410            return Some(row);
1411        }
1412        if let Some(found) = find_row(&row.children, id) {
1413            return Some(found);
1414        }
1415    }
1416    None
1417}
1418
1419/// Truncate `text` with an ellipsis so it fits `width`.
1420fn elide(ui: &egui::Ui, text: &str, width: f32) -> String {
1421    let font = egui::TextStyle::Body.resolve(ui.style());
1422    let measure = |candidate: &str| {
1423        ui.painter()
1424            .layout_no_wrap(candidate.to_string(), font.clone(), egui::Color32::WHITE)
1425            .rect
1426            .width()
1427    };
1428    if width <= 0.0 || measure(text) <= width {
1429        return text.to_string();
1430    }
1431    let mut cut: Vec<char> = text.chars().collect();
1432    while !cut.is_empty() {
1433        cut.pop();
1434        let candidate: String = cut.iter().collect::<String>() + "\u{2026}";
1435        if measure(&candidate) <= width {
1436            return candidate;
1437        }
1438    }
1439    String::new()
1440}
1441
1442fn publish(
1443    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1444    spec: &ColumnTreeSpec<'_>,
1445    key: &str,
1446    rect: egui::Rect,
1447) {
1448    if let Some(map) = hits.as_deref_mut() {
1449        map.insert(format!("{}{key}", spec.hits_prefix), rect);
1450    }
1451}
1452
1453