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