Skip to main content

brep_app/
column_tree.rs

1//! The SHARED COLUMN-TREE widget — a table whose rows are a TREE, with
2//! resizable, reorderable, hideable, sortable columns and a per-column editor
3//! kind. One function draws a complete columned tree and returns what the user
4//! did.
5//!
6//! ```text
7//!   │ Item          │ Qty │ Description      │ Material │      │  ← header
8//!   │ [-] Assembly  │  4  │                  │          │      │
9//!   │  ├ bracket    │  2  │ [L-bracket     ] │ [6061 ▾] │ [✕]  │  ← editors
10//!   │  └[-] gearbox │  1  │ [drive unit    ] │ [ ---  ▾]│ [✕]  │
11//!   │     └ shaft   │  1  │ [              ] │ [4140 ▾] │ [✕]  │
12//! ```
13//!
14//! # It is generic, and that is the point
15//!
16//! It knows nothing about assemblies, parts, occurrences or `EngineState`. It
17//! is handed COLUMNS (a key, a label, an editor kind), ROWS (an id, a cell
18//! value per column key, children) and a LAYOUT (order, hidden set, widths,
19//! sort), and it hands back EDITS and CLICKS. The BOM is the first consumer;
20//! the wire-harness connection list named as the second one needs no change
21//! here — a connection list is a different `columns` slice and a different
22//! `rows` tree, both built by its own panel.
23//!
24//! The rule that keeps it that way: **nothing in this module may name a
25//! consumer's vocabulary.** If a behaviour cannot be expressed as "a column of
26//! kind K" or "a row with children", it belongs in the consumer, not here.
27//!
28//! # The shape is `form_view`'s
29//!
30//! `(ui, spec, &mut layout, rows) -> Out` — no engine, no document, no store.
31//! Intents come OUT as values ([`ColumnTreeOut`]) that the calling panel acts
32//! on AFTER the draw, exactly as [`crate::form_view`] does and for the same
33//! reason: a caller that has no engine (the headless docs generator; a test)
34//! can still drive the real widget.
35//!
36//! `layout` is the ONE `&mut` input, because column order / widths / hidden /
37//! sort are things the USER changes BY DRAGGING THIS WIDGET, so the widget must
38//! be able to write them. It is plain data the consumer owns and persists (the
39//! BOM round-trips it through a settings textarea); [`ColumnTreeOut::layout_changed`]
40//! says when to save.
41//!
42//! # Column 0 is a real tree
43//!
44//! The first visible column draws through [`crate::panels::tree::node`] — the
45//! same `[+]`/`[-]` boxes and `├`/`└` connector rules the history, scene,
46//! settings, sketch, assembly-structure and constraint panels use — inside a
47//! clipped child `Ui` sized to that column. So this widget is a SIBLING of
48//! `panels::tree`, not a fork of it: the node painter is called, never copied,
49//! and its six existing consumers keep a byte-identical code path.
50//!
51//! # Per-row ACTIONS are a declaration, not a behaviour
52//!
53//! A row carries [`RowNode::actions`] — a list of (id, label, enabled) the
54//! CONSUMER writes. The widget renders them as a menu and reports which id
55//! fired on which row ([`ColumnTreeOut::actions`]); it never learns what any of
56//! them means. Availability is per ROW because that is where it varies (one
57//! row's part has no source file, another's component is fixed), and a refused
58//! entry is drawn GREYED with its tooltip rather than hidden: a menu that
59//! changes shape row to row cannot be learned, and the tooltip is the only
60//! place the reason can be told.
61//!
62//! The menu has TWO triggers and ONE definition. A [`CellKind::Actions`] cell
63//! click and a right-click anywhere on the row both do nothing but record
64//! *which row, at which point*; the menu itself is drawn once per frame from
65//! that record. Two triggers cannot drift apart because there is only one of
66//! them to drift.
67//!
68//! # Frozen columns
69//!
70//! [`ColumnLayout::frozen`] holds the first N columns FIXED while the rest
71//! scroll horizontally (Excel's freeze-panes). The widget therefore owns its
72//! horizontal scrolling — an outer scroll area would carry the frozen columns
73//! away with everything else. `frozen == 0` is one plain scrolling region, i.e.
74//! exactly the behaviour of a table that never heard of freezing.
75//!
76//! # Transient state
77//!
78//! Which column is mid-drag lives in egui MEMORY keyed by [`ColumnTreeSpec::id`],
79//! and so does each text cell's in-progress edit buffer (a text cell commits on
80//! focus-loss / Enter, never per keystroke — a consumer's commit can be
81//! expensive, and the BOM's part-level one rebuilds geometry) and which row's
82//! action menu is open. Expansion is the CALLER's, exactly as in `panels::tree`.
83
84use crate::panels::tree::{self, TreeRow};
85use eframe::egui;
86use serde_json::Value;
87use std::collections::{HashMap, HashSet};
88
89/// Minimum column width, in points — narrow enough to park a column out of the
90/// way, wide enough that its resize grip is still catchable.
91pub const MIN_COLUMN_WIDTH: f32 = 28.0;
92
93/// The default width of a column whose [`ColumnLayout`] carries none.
94pub const DEFAULT_COLUMN_WIDTH: f32 = 110.0;
95
96/// Width of the draggable divider strip at a header cell's right edge.
97const GRIP: f32 = 5.0;
98
99/// Horizontal padding inside a cell, so text does not touch the divider.
100const CELL_PAD: f32 = 3.0;
101
102/// Width of the band between the frozen columns and the scrolling ones.
103const FREEZE_GAP: f32 = 4.0;
104
105/// What a column's cells are, and therefore which editor the widget draws.
106#[derive(Debug, Clone, PartialEq)]
107pub enum CellKind {
108    /// Free text. Committed on focus-loss / Enter, NOT per keystroke.
109    Text,
110    /// A number, edited with a drag-value.
111    Numeric { step: f64 },
112    /// A fixed set of choices — a combobox. An empty string is always
113    /// offered as the "unset" choice, so a user can clear a cell.
114    Choice { options: Vec<String> },
115    /// An action button in every row of the column. The widget reports the
116    /// click ([`ColumnTreeOut::buttons`]); the consumer acts.
117    Button { label: String },
118    /// The row's ACTION MENU trigger. Every row draws `label`; clicking it
119    /// opens that row's [`RowNode::actions`] — the same menu a right-click on
120    /// the row opens. A row that declares no action draws the trigger greyed.
121    Actions { label: String },
122    /// Read-only status glyphs drawn inline, from a cell value shaped
123    /// `[{"glyph": "⏚", "color": "#ff9f0a", "tooltip": "…"}, …]` (`color` and
124    /// `tooltip` optional). A plain text cell cannot colour part of its own
125    /// content, and these glyphs carry meaning IN their colour — a constraint
126    /// status, an out-of-date badge — so they get a kind rather than being
127    /// flattened into a string.
128    Badges,
129    /// A boolean, drawn as a checkbox. The cell's value is read with
130    /// [`Value::as_bool`]; a missing value reads false. A row that is not
131    /// [`RowNode::editable`] still DRAWS its box (so the column stays
132    /// readable) but cannot be clicked — a derived grouping row has no state
133    /// of its own to toggle.
134    Toggle,
135    /// Displayed, never edited (a derived value — a rolled-up quantity, a
136    /// computed length).
137    ReadOnly,
138}
139
140/// One column: its cell key, its heading, its editor, its default width.
141#[derive(Debug, Clone, PartialEq)]
142pub struct ColumnSpec {
143    /// The key this column reads out of [`RowNode::cells`]. Unique per spec.
144    pub key: String,
145    /// The heading text.
146    pub label: String,
147    pub kind: CellKind,
148    /// Width used when [`ColumnLayout::widths`] carries none for this key.
149    pub default_width: f32,
150}
151
152impl ColumnSpec {
153    /// A column with the default width.
154    pub fn new(key: impl Into<String>, label: impl Into<String>, kind: CellKind) -> Self {
155        Self {
156            key: key.into(),
157            label: label.into(),
158            kind,
159            default_width: DEFAULT_COLUMN_WIDTH,
160        }
161    }
162
163    pub fn width(mut self, width: f32) -> Self {
164        self.default_width = width;
165        self
166    }
167}
168
169/// One entry in a row's action menu: what to call it, whether this ROW allows
170/// it, and how to draw it. Everything here is the consumer's vocabulary — the
171/// widget only ever compares [`RowAction::id`] for equality when it reports the
172/// click back.
173#[derive(Debug, Clone, PartialEq)]
174pub struct RowAction {
175    /// Stable id, handed back in [`RowActionClick::action`].
176    pub id: String,
177    /// The menu text.
178    pub label: String,
179    /// Hover text. Set it on a DISABLED entry to say why it is refused — the
180    /// entry is greyed, not hidden, so this is where the reason is told.
181    pub tooltip: String,
182    /// Whether THIS ROW allows it. A disabled entry is drawn greyed and
183    /// reports nothing.
184    pub enabled: bool,
185    /// Draw a separator line above this entry (grouping, e.g. before a
186    /// destructive tail).
187    pub separator_above: bool,
188    /// Draw in the error colour — an entry that destroys something.
189    pub destructive: bool,
190}
191
192impl RowAction {
193    /// An enabled entry.
194    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
195        Self {
196            id: id.into(),
197            label: label.into(),
198            tooltip: String::new(),
199            enabled: true,
200            separator_above: false,
201            destructive: false,
202        }
203    }
204
205    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
206        self.tooltip = text.into();
207        self
208    }
209
210    /// Refuse this entry ON THIS ROW, saying why (the greyed entry's tooltip).
211    pub fn disabled(mut self, why: impl Into<String>) -> Self {
212        self.enabled = false;
213        self.tooltip = why.into();
214        self
215    }
216
217    pub fn separator_above(mut self) -> Self {
218        self.separator_above = true;
219        self
220    }
221
222    pub fn destructive(mut self) -> Self {
223        self.destructive = true;
224        self
225    }
226}
227
228/// One row. Rows nest — this is a TREE, not a flat table — and each row's
229/// cells are looked up by column key, so adding a column never touches the
230/// row builder's shape.
231#[derive(Debug, Clone, Default, PartialEq)]
232pub struct RowNode {
233    /// Stable, unique across the whole tree — the id every out-value names.
234    pub id: String,
235    /// Cell values by column key. A missing key draws an empty cell.
236    pub cells: HashMap<String, Value>,
237    /// Whether this row's cells accept edits. A read-only row still draws its
238    /// values (and its buttons), it just cannot be typed into — the BOM's
239    /// nested sub-assembly rows, whose data belongs to another document.
240    pub editable: bool,
241    /// Draw the first column's label emphasized (the selected row).
242    pub selected: bool,
243    /// Expansion is the CALLER's, as in `panels::tree`.
244    pub expanded: bool,
245    /// What this row offers, in menu order. EMPTY means the row offers
246    /// nothing: its trigger cell draws greyed and a right-click on it opens
247    /// nothing (the BOM's nested sub-assembly rows, which own no feature in
248    /// this document).
249    pub actions: Vec<RowAction>,
250    pub children: Vec<RowNode>,
251}
252
253impl RowNode {
254    pub fn new(id: impl Into<String>) -> Self {
255        Self {
256            id: id.into(),
257            editable: true,
258            ..Default::default()
259        }
260    }
261
262    /// Set one cell.
263    pub fn cell(mut self, key: impl Into<String>, value: Value) -> Self {
264        self.cells.insert(key.into(), value);
265        self
266    }
267
268    /// Set this row's action menu.
269    pub fn actions(mut self, actions: Vec<RowAction>) -> Self {
270        self.actions = actions;
271        self
272    }
273}
274
275/// The user's column arrangement — the widget WRITES this (header drag,
276/// divider drag, hide/show, sort click) and the consumer owns and persists it.
277#[derive(Debug, Clone, Default, PartialEq)]
278pub struct ColumnLayout {
279    /// Column keys in display order. Keys absent from the spec are ignored;
280    /// spec columns absent from `order` are appended in spec order, so a
281    /// consumer may start with an empty layout and get the spec's order.
282    pub order: Vec<String>,
283    /// Column keys the user has hidden.
284    pub hidden: HashSet<String>,
285    /// Per-column width overrides, in points.
286    pub widths: HashMap<String, f32>,
287    /// `(column key, ascending)`. `None` = the consumer's own row order.
288    pub sort: Option<(String, bool)>,
289    /// How many LEADING columns are held fixed while the rest scroll
290    /// horizontally. Counted over the arranged order INCLUDING hidden columns,
291    /// so hiding a frozen column does not silently promote the next one into
292    /// the frozen region. `0` = nothing frozen, one plain scrolling table.
293    pub frozen: usize,
294}
295
296/// One cell was edited.
297#[derive(Debug, Clone, PartialEq)]
298pub struct CellEdit {
299    pub row_id: String,
300    pub column: String,
301    pub value: Value,
302}
303
304/// A [`CellKind::Button`] cell was clicked.
305#[derive(Debug, Clone, PartialEq)]
306pub struct CellClick {
307    pub row_id: String,
308    pub column: String,
309}
310
311/// A row's action menu fired.
312#[derive(Debug, Clone, PartialEq)]
313pub struct RowActionClick {
314    pub row_id: String,
315    /// The [`RowAction::id`] the consumer declared.
316    pub action: String,
317}
318
319/// Everything the consumer supplies. Borrowed; the widget holds no state of
320/// its own beyond egui memory.
321pub struct ColumnTreeSpec<'a> {
322    /// Scope key for this tree's transient view state and widget ids. Must be
323    /// STABLE and UNIQUE per tree — two trees sharing it share their drag
324    /// state and their text buffers.
325    pub id: &'a str,
326    /// The columns, in spec order (the fallback order when `layout.order` does
327    /// not name them).
328    pub columns: &'a [ColumnSpec],
329    /// An optional always-open root row above the tree (`"Assembly"`), drawn
330    /// with its own cells taken from `root_cells`.
331    pub root_label: Option<&'a str>,
332    /// Cells for the root row, when there is one.
333    pub root_cells: Option<&'a HashMap<String, Value>>,
334    /// Text drawn in place of the body when `rows` is empty.
335    pub empty_hint: Option<&'a str>,
336    /// Prefix for every published hit key. `""` for a consumer that shows one
337    /// tree at a time.
338    pub hits_prefix: &'a str,
339}
340
341/// What the user did in one drawn frame.
342#[derive(Debug, Default, Clone, PartialEq)]
343pub struct ColumnTreeOut {
344    /// Cell edits, in draw order. A frame can carry at most one (egui gives
345    /// one widget the focus), but the vec keeps the caller from having to care.
346    pub edits: Vec<CellEdit>,
347    /// Button cells clicked this frame.
348    pub buttons: Vec<CellClick>,
349    /// Row action-menu entries chosen this frame.
350    pub actions: Vec<RowActionClick>,
351    /// A row's `[+]`/`[-]` box was clicked — the caller flips its own
352    /// expansion state (expansion is the caller's, as in `panels::tree`).
353    pub toggled: Option<String>,
354    /// A row's first-column label was clicked (select).
355    pub clicked: Option<String>,
356    /// The layout was changed by the user (reorder / resize / hide / sort) —
357    /// the caller persists it.
358    pub layout_changed: bool,
359}
360
361/// `#rrggbb` → a colour. Any other shape is ignored rather than guessed at, so
362/// a malformed badge simply draws in the ambient text colour.
363fn parse_hex_color(hex: &str) -> Option<egui::Color32> {
364    let digits = hex.strip_prefix('#')?;
365    if digits.len() != 6 {
366        return None;
367    }
368    let byte = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok();
369    Some(egui::Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
370}
371
372/// Draw ONE complete column tree and return what the user did.
373///
374/// `hits`, when supplied, receives widget screen rects for the headed
375/// verifier, all prefixed with [`ColumnTreeSpec::hits_prefix`]:
376///
377/// | key | what |
378/// |---|---|
379/// | `col:{key}` | a column heading |
380/// | `grip:{key}` | a heading's resize divider |
381/// | `row:{row id}` | the first column's label |
382/// | `box:{row id}` | the first column's collapse box |
383/// | `cell:{row id}:{col key}` | one cell's editor |
384/// | `menu:{row id}` | the row's action-menu trigger cell |
385/// | `menuitem:{row id}:{action id}` | one entry of the OPEN action menu |
386/// | `freeze:divider` | the frozen / scrolling boundary (only when frozen) |
387pub fn column_tree(
388    ui: &mut egui::Ui,
389    spec: &ColumnTreeSpec<'_>,
390    layout: &mut ColumnLayout,
391    rows: &[RowNode],
392    mut hits: Option<&mut HashMap<String, egui::Rect>>,
393) -> ColumnTreeOut {
394    let mut out = ColumnTreeOut::default();
395    // The arranged order INCLUDING the hidden columns: the freeze boundary is
396    // counted over THIS, so hiding a frozen column cannot silently promote the
397    // next one into the frozen band.
398    let arranged = arranged_columns(spec, layout);
399    let visible: Vec<&ColumnSpec> = arranged
400        .iter()
401        .copied()
402        .filter(|column| !layout.hidden.contains(&column.key))
403        .collect();
404    if visible.is_empty() {
405        ui.label(egui::RichText::new("(every column is hidden)").weak());
406        return out;
407    }
408
409    // Column widths, in the drawn order.
410    let widths: Vec<f32> = visible
411        .iter()
412        .map(|column| column_width(layout, column))
413        .collect();
414
415    // How many DRAWN columns are frozen. Freezing every column is meaningless
416    // — there is nothing left to scroll it against — and would strand any
417    // column past the pane's right edge with no way to reach it, so it reads
418    // as "frozen: none".
419    let mut frozen = arranged
420        .iter()
421        .take(layout.frozen)
422        .filter(|column| !layout.hidden.contains(&column.key))
423        .count();
424    if frozen >= visible.len() {
425        frozen = 0;
426    }
427
428    ui.spacing_mut().item_spacing.y = 2.0;
429    let full = ui.available_rect_before_wrap();
430    // The frozen band never eats the whole width — something has to be left to
431    // scroll in.
432    let frozen_width: f32 = widths[..frozen]
433        .iter()
434        .sum::<f32>()
435        .min((full.width() - MIN_COLUMN_WIDTH).max(0.0));
436
437    // Which row's action menu either trigger asked for, this frame.
438    let mut pending: Option<MenuOpen> = None;
439    // Heading bounds from BOTH panes, merged: a reorder drag that crosses the
440    // freeze boundary must find its drop target, because dropping a column on
441    // the other side of the boundary is how a column is frozen or unfrozen
442    // with the mouse.
443    let mut bounds: Vec<(String, f32, f32)> = Vec::new();
444    let mut bottom = full.top();
445
446    if frozen > 0 {
447        let rect = egui::Rect::from_min_max(
448            full.min,
449            egui::pos2(full.min.x + frozen_width, full.max.y),
450        );
451        let mut pane = ui.new_child(
452            egui::UiBuilder::new()
453                .max_rect(rect)
454                .layout(egui::Layout::top_down(egui::Align::Min))
455                .id_salt((spec.id, "column-tree-frozen")),
456        );
457        // Clip the frozen band HORIZONTALLY only: its height belongs to the
458        // caller (an outer vertical scroll area owns that axis).
459        pane.set_clip_rect(pane.clip_rect().intersect(egui::Rect::from_x_y_ranges(
460            rect.x_range(),
461            ui.clip_rect().y_range(),
462        )));
463        pane.spacing_mut().item_spacing.y = 2.0;
464        draw_pane(
465            &mut pane,
466            spec,
467            layout,
468            &visible[..frozen],
469            &widths[..frozen],
470            0,
471            frozen_width,
472            rows,
473            &mut hits,
474            &mut out,
475            &mut pending,
476            &mut bounds,
477        );
478        bottom = bottom.max(pane.min_rect().bottom());
479    }
480
481    // The scrolling remainder. The widget owns this scroll area rather than the
482    // caller, because a scroll area OUTSIDE the widget would carry the frozen
483    // columns away with everything else.
484    let scroll_left = full.min.x + if frozen > 0 { frozen_width + FREEZE_GAP } else { 0.0 };
485    let scroll_rect = egui::Rect::from_min_max(egui::pos2(scroll_left, full.min.y), full.max);
486    let viewport = scroll_rect.width();
487    let mut pane = ui.new_child(
488        egui::UiBuilder::new()
489            .max_rect(scroll_rect)
490            .layout(egui::Layout::top_down(egui::Align::Min))
491            .id_salt((spec.id, "column-tree-scrolling")),
492    );
493    let rest: f32 = widths[frozen..].iter().sum();
494    // (egui's default `ScrollSource` drags to scroll on TOUCH only, which is
495    // what we want: a mouse drag-to-scroll would fight the header's reorder
496    // drag and a text cell's selection drag.)
497    egui::ScrollArea::horizontal()
498        .id_salt((spec.id, "column-tree-hscroll"))
499        .show(&mut pane, |ui: &mut egui::Ui| {
500            ui.spacing_mut().item_spacing.y = 2.0;
501            draw_pane(
502                ui,
503                spec,
504                layout,
505                &visible[frozen..],
506                &widths[frozen..],
507                frozen,
508                rest.max(viewport),
509                rows,
510                &mut hits,
511                &mut out,
512                &mut pending,
513                &mut bounds,
514            );
515            if rest > viewport {
516                // A GUTTER for the horizontal scrollbar. egui's bars float over
517                // the content, and this one lands on the LAST ROW — where it
518                // silently eats every click on the bottom half of that row's
519                // cells (found by the headed verifier: the action trigger opened
520                // from its top edge and not from its centre).
521                let scroll = ui.spacing().scroll;
522                ui.add_space(scroll.bar_width + scroll.bar_inner_margin + scroll.bar_outer_margin);
523            }
524        });
525    bottom = bottom.max(pane.min_rect().bottom());
526
527    let used = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, bottom));
528    ui.advance_cursor_after_rect(used);
529
530    // The boundary, so the user can SEE which columns are pinned.
531    if frozen > 0 {
532        let x = full.min.x + frozen_width + FREEZE_GAP * 0.5;
533        let divider = egui::Rect::from_min_max(
534            egui::pos2(x - 1.0, used.top()),
535            egui::pos2(x + 1.0, used.bottom()),
536        );
537        ui.painter()
538            .rect_filled(divider, 0.0, ui.visuals().widgets.active.bg_fill);
539        publish(&mut hits, spec, "freeze:divider", divider);
540    }
541
542    finish_reorder(ui, spec, layout, &arranged, &bounds, &mut out);
543    // ONE menu, drawn from whatever record a trigger left. `pending` is applied
544    // AFTER it — apply it first and the popup's own close-on-click would see
545    // the very click that opened it and shut immediately.
546    row_action_menu(ui, spec, rows, &mut hits, &mut out, &mut pending);
547    out
548}
549
550/// Draw ONE pane — a contiguous run of columns, with the header and every row.
551/// The frozen band and the scrolling remainder are the same code walking the
552/// same rows in the same order, which is what keeps them aligned.
553///
554/// `offset` is the drawn index of `columns[0]`, so the pane holding column 0
555/// (and only it) draws the TREE cell. `band` is the width every row spans here.
556#[allow(clippy::too_many_arguments)]
557fn draw_pane(
558    ui: &mut egui::Ui,
559    spec: &ColumnTreeSpec<'_>,
560    layout: &mut ColumnLayout,
561    columns: &[&ColumnSpec],
562    widths: &[f32],
563    offset: usize,
564    band: f32,
565    rows: &[RowNode],
566    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
567    out: &mut ColumnTreeOut,
568    pending: &mut Option<MenuOpen>,
569    bounds: &mut Vec<(String, f32, f32)>,
570) {
571    header(ui, spec, layout, columns, widths, band, hits, out, bounds);
572    ui.separator();
573
574    if let Some(label) = spec.root_label {
575        let mut cells = spec.root_cells.cloned().unwrap_or_default();
576        // The root's label belongs to whichever column is drawn FIRST — the
577        // tree column follows the user's order, so it is not a fixed key.
578        if offset == 0 {
579            cells.insert(columns[0].key.clone(), Value::String(label.to_string()));
580        }
581        let root = RowNode {
582            id: format!("{}__root", spec.id),
583            cells,
584            editable: false,
585            selected: false,
586            expanded: true,
587            actions: Vec::new(),
588            children: Vec::new(),
589        };
590        draw_row(
591            ui, spec, columns, widths, offset, band, &root, &[], true, true, hits, out, pending,
592        );
593    }
594
595    if rows.is_empty() {
596        if let Some(hint) = spec.empty_hint {
597            if offset == 0 {
598                let guides = tree::child_guides(&[], true);
599                tree::node(ui, TreeRow::leaf(&guides, true, hint), |_| {});
600            } else {
601                // The other pane still spends the same row, so the two panes
602                // keep the same height.
603                ui.allocate_exact_size(
604                    egui::vec2(band, ui.spacing().interact_size.y),
605                    egui::Sense::hover(),
606                );
607            }
608        }
609        return;
610    }
611
612    let ordered = sorted_siblings(rows, layout);
613    let last = ordered.len();
614    for (index, row) in ordered.iter().enumerate() {
615        draw_subtree(
616            ui,
617            spec,
618            layout,
619            columns,
620            widths,
621            offset,
622            band,
623            row,
624            &[],
625            index + 1 == last,
626            hits,
627            out,
628            pending,
629        );
630    }
631}
632
633/// Every spec column in the user's order — `layout.order` first (spec columns
634/// only), then any spec column the layout has never heard of. Hidden columns
635/// KEEP their slot here; the drawn set filters them out afterwards, and the
636/// freeze boundary counts over this list.
637fn arranged_columns<'a>(
638    spec: &'a ColumnTreeSpec<'_>,
639    layout: &ColumnLayout,
640) -> Vec<&'a ColumnSpec> {
641    let mut out: Vec<&ColumnSpec> = Vec::new();
642    for key in &layout.order {
643        if let Some(column) = spec.columns.iter().find(|c| &c.key == key) {
644            if !out.iter().any(|c| c.key == column.key) {
645                out.push(column);
646            }
647        }
648    }
649    for column in spec.columns {
650        if !out.iter().any(|c| c.key == column.key) {
651            out.push(column);
652        }
653    }
654    out
655}
656
657fn column_width(layout: &ColumnLayout, column: &ColumnSpec) -> f32 {
658    layout
659        .widths
660        .get(&column.key)
661        .copied()
662        .unwrap_or(column.default_width)
663        .max(MIN_COLUMN_WIDTH)
664}
665
666/// One pane's header: a heading per column (click = sort, drag = reorder,
667/// right-click = the show/hide checklist) with a resize grip at each right
668/// edge. The drop of a reorder drag is resolved by [`finish_reorder`] once
669/// BOTH panes have contributed their bounds.
670#[allow(clippy::too_many_arguments)]
671fn header(
672    ui: &mut egui::Ui,
673    spec: &ColumnTreeSpec<'_>,
674    layout: &mut ColumnLayout,
675    visible: &[&ColumnSpec],
676    widths: &[f32],
677    width: f32,
678    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
679    out: &mut ColumnTreeOut,
680    bounds: &mut Vec<(String, f32, f32)>,
681) {
682    let height = ui.spacing().interact_size.y;
683    let (band, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
684    let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
685    let dragging: Option<String> = ui.data(|d| d.get_temp(drag_key));
686
687    let mut x = band.left();
688    // The reorder DROP target is decided from the pointer's x at drag end, so
689    // the boundaries are collected while the headings are laid out.
690    let first = bounds.len();
691    for (column, width) in visible.iter().zip(widths) {
692        let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
693        bounds.push((column.key.clone(), cell.left(), cell.right()));
694
695        let resp = ui.interact(
696            cell,
697            ui.id().with(("column-tree-head", spec.id, &column.key)),
698            egui::Sense::click_and_drag(),
699        );
700        let held = dragging.as_deref() == Some(column.key.as_str());
701        let fill = if held {
702            ui.visuals().selection.bg_fill.gamma_multiply(0.45)
703        } else if resp.hovered() {
704            ui.visuals().widgets.hovered.bg_fill
705        } else {
706            ui.visuals().widgets.noninteractive.bg_fill
707        };
708        ui.painter().rect_filled(cell, 0.0, fill);
709        // The sort marker rides the heading text, so the sorted column is
710        // obvious without a second row of chrome.
711        let marker = match &layout.sort {
712            Some((key, true)) if key == &column.key => " \u{25B2}",
713            Some((key, false)) if key == &column.key => " \u{25BC}",
714            _ => "",
715        };
716        let text = format!("{}{marker}", column.label);
717        ui.painter().text(
718            egui::pos2(cell.left() + CELL_PAD, cell.center().y),
719            egui::Align2::LEFT_CENTER,
720            elide(ui, &text, *width - 2.0 * CELL_PAD),
721            egui::TextStyle::Body.resolve(ui.style()),
722            ui.visuals().strong_text_color(),
723        );
724        publish(hits, spec, &format!("col:{}", column.key), cell);
725
726        // Right-click ANY heading → the show/hide checklist. Hiding lives here
727        // rather than on a toolbar because the column is the thing being
728        // hidden and this is where the user's hand already is.
729        resp.context_menu(|ui| {
730            ui.label(egui::RichText::new("Columns").strong());
731            for candidate in spec.columns {
732                let mut shown = !layout.hidden.contains(&candidate.key);
733                if ui.checkbox(&mut shown, &candidate.label).changed() {
734                    if shown {
735                        layout.hidden.remove(&candidate.key);
736                    } else {
737                        layout.hidden.insert(candidate.key.clone());
738                    }
739                    out.layout_changed = true;
740                }
741            }
742        });
743
744        // A CLICK sorts. egui reports `clicked()` false once a press turns
745        // into a drag, so the click and the reorder drag share the heading
746        // without a mode.
747        if resp.clicked() {
748            layout.sort = match &layout.sort {
749                Some((key, true)) if key == &column.key => Some((column.key.clone(), false)),
750                Some((key, false)) if key == &column.key => None,
751                _ => Some((column.key.clone(), true)),
752            };
753            out.layout_changed = true;
754        }
755        if resp.drag_started() {
756            ui.data_mut(|d| d.insert_temp(drag_key, column.key.clone()));
757        }
758
759        ui.painter().line_segment(
760            [
761                egui::pos2(cell.right(), band.top()),
762                egui::pos2(cell.right(), band.bottom()),
763            ],
764            ui.visuals().widgets.noninteractive.bg_stroke,
765        );
766        x = cell.right();
767    }
768
769    // The resize grips, in a SECOND pass. A grip straddles the divider, so it
770    // overlaps the heading to its right — and egui hands an overlapped point to
771    // the LAST widget registered there. Interleaved with the headings, every
772    // grip would therefore lose its own hit to the next heading and no column
773    // would ever resize.
774    for (column, (_, _, right)) in visible.iter().zip(&bounds[first..]) {
775        let grip_rect = egui::Rect::from_min_max(
776            egui::pos2(right - GRIP * 0.5, band.top()),
777            egui::pos2(right + GRIP * 0.5, band.bottom()),
778        );
779        let grip = ui.interact(
780            grip_rect,
781            ui.id().with(("column-tree-grip", spec.id, &column.key)),
782            egui::Sense::drag(),
783        );
784        if grip.hovered() || grip.dragged() {
785            ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
786        }
787        if grip.dragged() {
788            let next = (column_width(layout, column) + grip.drag_delta().x).max(MIN_COLUMN_WIDTH);
789            layout.widths.insert(column.key.clone(), next);
790            out.layout_changed = true;
791        }
792        publish(hits, spec, &format!("grip:{}", column.key), grip_rect);
793    }
794}
795
796/// Finish a reorder: on release, the dragged column moves to whichever heading
797/// the pointer is over — in EITHER pane, so dragging a column across the freeze
798/// boundary is what freezes or unfreezes it.
799fn finish_reorder(
800    ui: &egui::Ui,
801    spec: &ColumnTreeSpec<'_>,
802    layout: &mut ColumnLayout,
803    arranged: &[&ColumnSpec],
804    bounds: &[(String, f32, f32)],
805    out: &mut ColumnTreeOut,
806) {
807    let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
808    let Some(held) = ui.data(|d| d.get_temp::<String>(drag_key)) else {
809        return;
810    };
811    if ui.input(|i| i.pointer.any_down()) {
812        return;
813    }
814    ui.data_mut(|d| d.remove::<String>(drag_key));
815    let Some(pos) = ui.input(|i| i.pointer.latest_pos()) else {
816        return;
817    };
818    if let Some((target, _, _)) = bounds
819        .iter()
820        .find(|(_, left, right)| pos.x >= *left && pos.x < *right)
821    {
822        if *target != held && move_column(layout, arranged, &held, target) {
823            out.layout_changed = true;
824        }
825    }
826}
827
828/// Move `held` to `target`'s slot in the layout order, materializing the
829/// current arranged order first so a layout that never named its columns still
830/// reorders correctly. Returns whether anything moved.
831fn move_column(
832    layout: &mut ColumnLayout,
833    arranged: &[&ColumnSpec],
834    held: &str,
835    target: &str,
836) -> bool {
837    let mut order: Vec<String> = layout.order.clone();
838    // Seed from the drawn order so the first drag on a fresh layout is not a
839    // no-op against an empty `order`.
840    for column in arranged {
841        if !order.iter().any(|key| key == &column.key) {
842            order.push(column.key.clone());
843        }
844    }
845    let Some(from) = order.iter().position(|key| key == held) else {
846        return false;
847    };
848    let key = order.remove(from);
849    let Some(to) = order.iter().position(|k| k == target) else {
850        order.insert(from.min(order.len()), key);
851        return false;
852    };
853    order.insert(to, key);
854    layout.order = order;
855    true
856}
857
858/// Draw one row and, when it is open, its children — the recursion that keeps
859/// the rows a TREE.
860#[allow(clippy::too_many_arguments)]
861fn draw_subtree(
862    ui: &mut egui::Ui,
863    spec: &ColumnTreeSpec<'_>,
864    layout: &ColumnLayout,
865    visible: &[&ColumnSpec],
866    widths: &[f32],
867    offset: usize,
868    band: f32,
869    row: &RowNode,
870    guides: &[bool],
871    is_last: bool,
872    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
873    out: &mut ColumnTreeOut,
874    pending: &mut Option<MenuOpen>,
875) {
876    draw_row(
877        ui, spec, visible, widths, offset, band, row, guides, is_last, false, hits, out, pending,
878    );
879    if !row.expanded || row.children.is_empty() {
880        return;
881    }
882    let child_guides = tree::child_guides(guides, is_last);
883    let ordered = sorted_siblings(&row.children, layout);
884    let last = ordered.len();
885    for (index, child) in ordered.iter().enumerate() {
886        draw_subtree(
887            ui,
888            spec,
889            layout,
890            visible,
891            widths,
892            offset,
893            band,
894            child,
895            &child_guides,
896            index + 1 == last,
897            hits,
898            out,
899            pending,
900        );
901    }
902}
903
904/// Sort ONE level of siblings by the layout's sort column. Sorting is
905/// per-level so the nesting survives it — a child never overtakes its parent.
906/// A stable sort, so an unsorted-equal run keeps the consumer's order.
907fn sorted_siblings<'a>(rows: &'a [RowNode], layout: &ColumnLayout) -> Vec<&'a RowNode> {
908    let mut out: Vec<&RowNode> = rows.iter().collect();
909    if let Some((key, ascending)) = &layout.sort {
910        out.sort_by(|a, b| {
911            let ordering = compare_cells(a.cells.get(key), b.cells.get(key));
912            if *ascending {
913                ordering
914            } else {
915                ordering.reverse()
916            }
917        });
918    }
919    out
920}
921
922/// Order two cell values: numbers numerically, everything else by its display
923/// text case-insensitively, and an ABSENT/null cell last in ascending order
924/// (an unfilled cell is not a small value, it is a missing one).
925fn compare_cells(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
926    use std::cmp::Ordering;
927    let empty = |value: Option<&Value>| match value {
928        None | Some(Value::Null) => true,
929        Some(Value::String(text)) => text.is_empty(),
930        _ => false,
931    };
932    match (empty(a), empty(b)) {
933        (true, true) => return Ordering::Equal,
934        (true, false) => return Ordering::Greater,
935        (false, true) => return Ordering::Less,
936        (false, false) => {}
937    }
938    if let (Some(Value::Number(x)), Some(Value::Number(y))) = (a, b) {
939        if let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) {
940            return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
941        }
942    }
943    display_text(a).to_lowercase().cmp(&display_text(b).to_lowercase())
944}
945
946/// A cell value as the text a cell shows: a string bare (not JSON-quoted), a
947/// number in its shortest form, anything else as its JSON.
948fn display_text(value: Option<&Value>) -> String {
949    match value {
950        None | Some(Value::Null) => String::new(),
951        Some(Value::String(text)) => text.clone(),
952        Some(other) => other.to_string(),
953    }
954}
955
956/// Draw ONE row: the tree cell in column 0, an editor in each other column.
957#[allow(clippy::too_many_arguments)]
958fn draw_row(
959    ui: &mut egui::Ui,
960    spec: &ColumnTreeSpec<'_>,
961    visible: &[&ColumnSpec],
962    widths: &[f32],
963    offset: usize,
964    band_width: f32,
965    row: &RowNode,
966    guides: &[bool],
967    is_last: bool,
968    root: bool,
969    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
970    out: &mut ColumnTreeOut,
971    pending: &mut Option<MenuOpen>,
972) {
973    let height = ui.spacing().interact_size.y;
974    let (band, _) = ui.allocate_exact_size(egui::vec2(band_width, height), egui::Sense::hover());
975    let clip = ui.clip_rect();
976
977    // TRIGGER 2 — a right-click ANYWHERE on the row. Read off the raw pointer
978    // rather than from a `Response`, because the cell editors are registered
979    // AFTER this band and egui hands an overlapped point to the LAST widget
980    // registered there: a `context_menu` on the band would silently never fire
981    // over a text cell. Reading the position also leaves the band's `hover`
982    // sense alone, so no primary click / select / sort / drag path changes.
983    let over_row = band.intersect(clip);
984    if !row.actions.is_empty()
985        && over_row.is_positive()
986        && ui.input(|i| i.pointer.secondary_clicked())
987    {
988        if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
989            // ...and nothing floating (an open menu, the header's own column
990            // checklist) is above that point.
991            let above = ui.ctx().layer_id_at(pos);
992            let ours = above.is_none() || above == Some(ui.layer_id());
993            if over_row.contains(pos) && ours {
994                *pending = Some(MenuOpen {
995                    row: row.id.clone(),
996                    pos,
997                });
998            }
999        }
1000    }
1001
1002    // The SELECTION band. A selected row is emphasised across its whole width,
1003    // not just by bolding the tree cell's text: this table is as wide as its
1004    // configured columns, and a picked component has to be findable at a
1005    // glance from anywhere in it. Painted BEFORE the cells so every editor
1006    // draws over it, and drawn in both panes (each calls this with its own
1007    // band) so the highlight does not stop at the frozen boundary.
1008    if row.selected && !root {
1009        let visible_band = band.intersect(clip);
1010        if visible_band.is_positive() {
1011            ui.painter().rect_filled(
1012                visible_band,
1013                0.0,
1014                ui.visuals().selection.bg_fill.gamma_multiply(0.35),
1015            );
1016        }
1017    }
1018
1019    let mut x = band.left();
1020    for (index, (column, width)) in visible.iter().zip(widths).enumerate() {
1021        let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
1022        x = cell.right();
1023        let Some(cell_clip) = cell.intersect(clip).is_positive().then_some(cell.intersect(clip))
1024        else {
1025            continue;
1026        };
1027        let mut child = ui.new_child(
1028            egui::UiBuilder::new()
1029                .max_rect(cell.shrink2(egui::vec2(CELL_PAD, 0.0)))
1030                .layout(egui::Layout::left_to_right(egui::Align::Center))
1031                .id_salt(("column-tree-cell", spec.id, &row.id, &column.key)),
1032        );
1033        child.set_clip_rect(cell_clip);
1034
1035        if offset + index == 0 {
1036            // The TREE cell — drawn by `panels::tree::node` itself, so the
1037            // connector rules and collapse boxes are the shared ones.
1038            let label = display_text(row.cells.get(&column.key));
1039            let expandable = !row.children.is_empty();
1040            let mut tree_row = TreeRow {
1041                guides,
1042                is_last,
1043                expandable,
1044                expanded: row.expanded,
1045                root,
1046                glyph: None,
1047                label: &label,
1048                selected: row.selected,
1049                draggable: false,
1050            };
1051            if root {
1052                tree_row.expandable = true;
1053                tree_row.expanded = true;
1054            }
1055            let resp = tree::node(&mut child, tree_row, |_| {});
1056            publish(hits, spec, &format!("row:{}", row.id), resp.label.rect);
1057            publish(hits, spec, &format!("box:{}", row.id), resp.box_rect);
1058            if resp.toggled {
1059                out.toggled = Some(row.id.clone());
1060            }
1061            if resp.label.clicked() {
1062                out.clicked = Some(row.id.clone());
1063            }
1064        } else {
1065            let rect = cell_editor(&mut child, spec, row, column, out, pending);
1066            publish(
1067                hits,
1068                spec,
1069                &format!("cell:{}:{}", row.id, column.key),
1070                rect,
1071            );
1072            if matches!(column.kind, CellKind::Actions { .. }) {
1073                publish(hits, spec, &format!("menu:{}", row.id), rect);
1074            }
1075        }
1076    }
1077}
1078
1079/// Draw ONE non-tree cell's editor and return its rect. A non-editable row
1080/// still SHOWS its value and still offers its buttons and its action menu — it
1081/// just cannot be typed into.
1082fn cell_editor(
1083    ui: &mut egui::Ui,
1084    spec: &ColumnTreeSpec<'_>,
1085    row: &RowNode,
1086    column: &ColumnSpec,
1087    out: &mut ColumnTreeOut,
1088    pending: &mut Option<MenuOpen>,
1089) -> egui::Rect {
1090    let value = row.cells.get(&column.key);
1091    let width = ui.available_width();
1092    let mut emit = |new_value: Value| {
1093        out.edits.push(CellEdit {
1094            row_id: row.id.clone(),
1095            column: column.key.clone(),
1096            value: new_value,
1097        });
1098    };
1099
1100    match &column.kind {
1101        CellKind::Button { label } => {
1102            let button = ui.add_sized([width, ui.available_height()], egui::Button::new(label));
1103            if button.clicked() {
1104                out.buttons.push(CellClick {
1105                    row_id: row.id.clone(),
1106                    column: column.key.clone(),
1107                });
1108            }
1109            button.rect
1110        }
1111        CellKind::Actions { label } => {
1112            // TRIGGER 1 — the actions cell. Like the right-click it only
1113            // RECORDS which row was asked for; the menu is drawn once, after
1114            // every row, from that record. A row that offers nothing draws the
1115            // trigger greyed rather than dropping it, so the column stays a
1116            // column.
1117            let offered = !row.actions.is_empty();
1118            let button = ui
1119                .add_enabled_ui(offered, |ui| {
1120                    ui.add_sized([width, ui.available_height()], egui::Button::new(label))
1121                })
1122                .inner;
1123            if button.clicked() {
1124                *pending = Some(MenuOpen {
1125                    row: row.id.clone(),
1126                    pos: button.rect.left_bottom(),
1127                });
1128            }
1129            button.rect
1130        }
1131        CellKind::ReadOnly => ui.add(egui::Label::new(display_text(value)).truncate()).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                        None => ui.label(egui::RichText::new(glyph).color(color)),
1152                    };
1153                    if let Some(tip) = badge.get("tooltip").and_then(Value::as_str) {
1154                        label.on_hover_text(tip);
1155                    }
1156                }
1157            })
1158            .response
1159            .rect
1160        }
1161        CellKind::Toggle => {
1162            // Drawn for every row, clickable only on an editable one: the
1163            // weak-label fallback below would render a bool as the text
1164            // "false", which is not a checkbox.
1165            let mut on = value.and_then(Value::as_bool).unwrap_or(false);
1166            let box_ = ui
1167                .add_enabled_ui(row.editable, |ui| ui.checkbox(&mut on, ""))
1168                .inner;
1169            if box_.changed() {
1170                emit(Value::Bool(on));
1171            }
1172            box_.rect
1173        }
1174        _ if !row.editable => ui
1175            .add(
1176                egui::Label::new(egui::RichText::new(display_text(value)).weak())
1177                    .truncate(),
1178            )
1179            .rect,
1180        CellKind::Text => {
1181            // An in-progress edit lives in egui memory and commits on
1182            // focus-loss / Enter. Per-keystroke commits are wrong here: a
1183            // consumer's commit can be arbitrarily expensive (the BOM's
1184            // part-level one re-signs a part document and re-heals every
1185            // instance of it), and a half-typed value is not a value.
1186            let buffer_id = egui::Id::new(("column-tree-text", spec.id, &row.id, &column.key));
1187            let stored = display_text(value);
1188            let mut buffer: String =
1189                ui.data(|d| d.get_temp(buffer_id)).unwrap_or_else(|| stored.clone());
1190            let edit = ui.add_sized(
1191                [width, ui.available_height()],
1192                egui::TextEdit::singleline(&mut buffer),
1193            );
1194            let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1195            if edit.has_focus() || edit.changed() {
1196                ui.data_mut(|d| d.insert_temp(buffer_id, buffer.clone()));
1197            }
1198            if edit.lost_focus() || entered {
1199                ui.data_mut(|d| d.remove::<String>(buffer_id));
1200                if buffer != stored {
1201                    emit(Value::String(buffer));
1202                }
1203            } else if !edit.has_focus() {
1204                // Unfocused: track the model, so an edit made elsewhere (a
1205                // packed fan-out landing on a sibling row) shows immediately.
1206                ui.data_mut(|d| d.remove::<String>(buffer_id));
1207            }
1208            edit.rect
1209        }
1210        CellKind::Numeric { step } => {
1211            let mut number = value.and_then(Value::as_f64).unwrap_or(0.0);
1212            let drag = ui.add_sized(
1213                [width, ui.available_height()],
1214                egui::DragValue::new(&mut number).speed(*step),
1215            );
1216            if drag.changed() {
1217                emit(serde_json::json!(number));
1218            }
1219            drag.rect
1220        }
1221        CellKind::Choice { options } => {
1222            let current = display_text(value);
1223            let mut chosen = current.clone();
1224            let combo = egui::ComboBox::from_id_salt((
1225                "column-tree-combo",
1226                spec.id,
1227                &row.id,
1228                &column.key,
1229            ))
1230            .width(width)
1231            .selected_text(if current.is_empty() { "—" } else { &current })
1232            .show_ui(ui, |ui| {
1233                // The blank choice is always offered: without it a dropdown
1234                // cell can be set but never cleared.
1235                ui.selectable_value(&mut chosen, String::new(), "—");
1236                for option in options {
1237                    ui.selectable_value(&mut chosen, option.clone(), option);
1238                }
1239            });
1240            if chosen != current {
1241                emit(Value::String(chosen));
1242            }
1243            combo.response.rect
1244        }
1245    }
1246}
1247
1248/// Which row's action menu is open, and the point it was opened at. Lives in
1249/// egui memory keyed by [`ColumnTreeSpec::id`] — one record, so there can only
1250/// ever be one open menu per tree.
1251#[derive(Clone)]
1252struct MenuOpen {
1253    row: String,
1254    pos: egui::Pos2,
1255}
1256
1257/// THE action menu — one definition, drawn from whichever trigger recorded a
1258/// [`MenuOpen`]. Neither trigger renders anything itself, so the cell click and
1259/// the right-click cannot drift apart: there is only one menu to drift.
1260fn row_action_menu(
1261    ui: &mut egui::Ui,
1262    spec: &ColumnTreeSpec<'_>,
1263    rows: &[RowNode],
1264    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1265    out: &mut ColumnTreeOut,
1266    pending: &mut Option<MenuOpen>,
1267) {
1268    let key = egui::Id::new((spec.id, "column-tree-menu"));
1269    let mut open: Option<MenuOpen> = ui.data(|d| d.get_temp(key));
1270    let was_open = open.as_ref().map(|state| state.row.clone());
1271
1272    if let Some(state) = open.clone() {
1273        // A row that has gone (or lost every action) takes its menu with it.
1274        match find_row(rows, &state.row) {
1275            Some(row) if !row.actions.is_empty() => {
1276                let mut still_open = true;
1277                egui::Popup::new(
1278                    key.with("popup"),
1279                    ui.ctx().clone(),
1280                    egui::PopupAnchor::Position(state.pos),
1281                    ui.layer_id(),
1282                )
1283                .open_bool(&mut still_open)
1284                .kind(egui::PopupKind::Menu)
1285                .layout(egui::Layout::top_down_justified(egui::Align::Min))
1286                .width(160.0)
1287                .show(|ui| {
1288                    for action in &row.actions {
1289                        if action.separator_above {
1290                            ui.separator();
1291                        }
1292                        // The caption may lead with a catalogued glyph (🔒 Fix,
1293                        // ✖ Delete); it is drawn as artwork, not as a character.
1294                        // A destructive entry keeps its red — on the artwork as
1295                        // well as the text.
1296                        let color =
1297                            action.destructive.then(|| ui.visuals().error_fg_color);
1298                        let button =
1299                            crate::icon_text::icon_button_colored(ui, &action.label, color);
1300                        let entry = ui.add_enabled(action.enabled, button);
1301                        publish(
1302                            hits,
1303                            spec,
1304                            &format!("menuitem:{}:{}", row.id, action.id),
1305                            entry.rect,
1306                        );
1307                        if !action.tooltip.is_empty() {
1308                            // A greyed entry's tooltip is where its refusal is
1309                            // explained, so it has to be the DISABLED hover.
1310                            if action.enabled {
1311                                entry.clone().on_hover_text(&action.tooltip);
1312                            } else {
1313                                entry.clone().on_disabled_hover_text(&action.tooltip);
1314                            }
1315                        }
1316                        if entry.clicked() {
1317                            out.actions.push(RowActionClick {
1318                                row_id: row.id.clone(),
1319                                action: action.id.clone(),
1320                            });
1321                        }
1322                    }
1323                });
1324                if !still_open {
1325                    open = None;
1326                }
1327            }
1328            _ => open = None,
1329        }
1330    }
1331
1332    // Applied AFTER the draw (see the call site): applying it first would let
1333    // the popup's own close-on-click see the very click that opened it. A
1334    // trigger fired on the row whose menu just closed itself is a TOGGLE — the
1335    // second click on the same trigger shuts it.
1336    if let Some(next) = pending.take() {
1337        let toggled_off = open.is_none() && was_open.as_deref() == Some(next.row.as_str());
1338        open = (!toggled_off).then_some(next);
1339    }
1340    match &open {
1341        Some(state) => ui.data_mut(|d| {
1342            d.insert_temp(key, state.clone());
1343        }),
1344        None => ui.data_mut(|d| d.remove::<MenuOpen>(key)),
1345    }
1346}
1347
1348/// The row with `id`, anywhere in the tree.
1349fn find_row<'a>(rows: &'a [RowNode], id: &str) -> Option<&'a RowNode> {
1350    for row in rows {
1351        if row.id == id {
1352            return Some(row);
1353        }
1354        if let Some(found) = find_row(&row.children, id) {
1355            return Some(found);
1356        }
1357    }
1358    None
1359}
1360
1361/// Truncate `text` with an ellipsis so it fits `width`.
1362fn elide(ui: &egui::Ui, text: &str, width: f32) -> String {
1363    let font = egui::TextStyle::Body.resolve(ui.style());
1364    let measure = |candidate: &str| {
1365        ui.painter()
1366            .layout_no_wrap(candidate.to_string(), font.clone(), egui::Color32::WHITE)
1367            .rect
1368            .width()
1369    };
1370    if width <= 0.0 || measure(text) <= width {
1371        return text.to_string();
1372    }
1373    let mut cut: Vec<char> = text.chars().collect();
1374    while !cut.is_empty() {
1375        cut.pop();
1376        let candidate: String = cut.iter().collect::<String>() + "\u{2026}";
1377        if measure(&candidate) <= width {
1378            return candidate;
1379        }
1380    }
1381    String::new()
1382}
1383
1384fn publish(
1385    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1386    spec: &ColumnTreeSpec<'_>,
1387    key: &str,
1388    rect: egui::Rect,
1389) {
1390    if let Some(map) = hits.as_deref_mut() {
1391        map.insert(format!("{}{key}", spec.hits_prefix), rect);
1392    }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    //! The widget is driven through REAL egui frames (the shared `run_ui`
1398    //! idiom), and every assertion is about behaviour a SECOND consumer would
1399    //! depend on — never about the BOM.
1400    use super::*;
1401
1402    fn columns() -> Vec<ColumnSpec> {
1403        vec![
1404            ColumnSpec::new("name", "Name", CellKind::Text).width(120.0),
1405            ColumnSpec::new("qty", "Qty", CellKind::ReadOnly).width(40.0),
1406            ColumnSpec::new("note", "Note", CellKind::Text),
1407            ColumnSpec::new(
1408                "grade",
1409                "Grade",
1410                CellKind::Choice {
1411                    options: vec!["A".into(), "B".into()],
1412                },
1413            ),
1414            ColumnSpec::new("del", "", CellKind::Button { label: "x".into() }).width(30.0),
1415            ColumnSpec::new("on", "", CellKind::Toggle).width(26.0),
1416            ColumnSpec::new("flags", "", CellKind::Badges).width(52.0),
1417            ColumnSpec::new(
1418                "act",
1419                "",
1420                CellKind::Actions {
1421                    label: "\u{22EF}".into(),
1422                },
1423            )
1424            .width(30.0),
1425        ]
1426    }
1427
1428    /// A consumer's action declaration — three entries, one of them refused on
1429    /// this row, one destructive behind a separator.
1430    fn actions(open_allowed: bool) -> Vec<RowAction> {
1431        vec![
1432            RowAction::new("open", "Open").tooltip("Open it"),
1433            RowAction::new("rename", "Rename").tooltip("Rename it"),
1434            RowAction::new("drop", "Drop")
1435                .separator_above()
1436                .destructive(),
1437        ]
1438        .into_iter()
1439        .map(|action| {
1440            if action.id == "open" && !open_allowed {
1441                action.disabled("This one has nothing to open")
1442            } else {
1443                action
1444            }
1445        })
1446        .collect()
1447    }
1448
1449    /// A wire-harness-shaped tree (the named second consumer): two connectors,
1450    /// one with two nested pins.
1451    fn rows() -> Vec<RowNode> {
1452        vec![
1453            RowNode::new("J1")
1454                .cell("name", Value::String("J1".into()))
1455                .cell("qty", serde_json::json!(2))
1456                .cell("note", Value::String("main".into()))
1457                .cell("on", Value::Bool(true))
1458                .cell(
1459                    "flags",
1460                    serde_json::json!([
1461                        { "glyph": "A", "color": "#ff9f0a", "tooltip": "amber" },
1462                        { "glyph": "B" }
1463                    ]),
1464                )
1465                .actions(actions(true)),
1466            {
1467                let mut parent = RowNode::new("J2")
1468                    .cell("name", Value::String("J2".into()))
1469                    .cell("qty", serde_json::json!(1))
1470                    .actions(actions(false));
1471                parent.expanded = true;
1472                parent.children = vec![
1473                    // A pin declares NOTHING — the row offers no menu at all.
1474                    RowNode::new("J2-P2").cell("name", Value::String("pin2".into())),
1475                    RowNode::new("J2-P1").cell("name", Value::String("pin1".into())),
1476                ];
1477                parent
1478            },
1479        ]
1480    }
1481
1482    fn spec<'a>(columns: &'a [ColumnSpec]) -> ColumnTreeSpec<'a> {
1483        ColumnTreeSpec {
1484            id: "test-tree",
1485            columns,
1486            root_label: None,
1487            root_cells: None,
1488            empty_hint: Some("(nothing)"),
1489            hits_prefix: "",
1490        }
1491    }
1492
1493    /// Draw one frame and return `(out, hits)`.
1494    fn frame(
1495        ctx: &egui::Context,
1496        layout: &mut ColumnLayout,
1497        rows: &[RowNode],
1498        events: Vec<egui::Event>,
1499    ) -> (ColumnTreeOut, HashMap<String, egui::Rect>) {
1500        let cols = columns();
1501        let spec = spec(&cols);
1502        let mut hits = HashMap::new();
1503        let mut out = ColumnTreeOut::default();
1504        let raw = egui::RawInput {
1505            screen_rect: Some(egui::Rect::from_min_size(
1506                egui::pos2(0.0, 0.0),
1507                egui::vec2(700.0, 500.0),
1508            )),
1509            events,
1510            ..Default::default()
1511        };
1512        let _ = ctx.run_ui(raw, |ui| {
1513            out = column_tree(ui, &spec, layout, rows, Some(&mut hits));
1514        });
1515        (out, hits)
1516    }
1517
1518    /// Press and release at `pos` across two frames (egui fires `clicked()` on
1519    /// release).
1520    fn click_at(
1521        ctx: &egui::Context,
1522        layout: &mut ColumnLayout,
1523        rows: &[RowNode],
1524        pos: egui::Pos2,
1525        button: egui::PointerButton,
1526    ) -> ColumnTreeOut {
1527        frame(
1528            ctx,
1529            layout,
1530            rows,
1531            vec![
1532                egui::Event::PointerMoved(pos),
1533                egui::Event::PointerButton {
1534                    pos,
1535                    button,
1536                    pressed: true,
1537                    modifiers: egui::Modifiers::default(),
1538                },
1539            ],
1540        );
1541        frame(
1542            ctx,
1543            layout,
1544            rows,
1545            vec![egui::Event::PointerButton {
1546                pos,
1547                button,
1548                pressed: false,
1549                modifiers: egui::Modifiers::default(),
1550            }],
1551        )
1552        .0
1553    }
1554
1555    /// Press and release the SECONDARY button at `pos`.
1556    fn right_click_at(
1557        ctx: &egui::Context,
1558        layout: &mut ColumnLayout,
1559        rows: &[RowNode],
1560        pos: egui::Pos2,
1561    ) -> ColumnTreeOut {
1562        click_at(ctx, layout, rows, pos, egui::PointerButton::Secondary)
1563    }
1564
1565    /// Draw two idle frames. An egui popup's FIRST frame is a SIZING pass whose
1566    /// widgets are not interactable yet; it asks for a repaint, which a real app
1567    /// serves immediately and a test has to draw by hand.
1568    fn settle(ctx: &egui::Context, layout: &mut ColumnLayout, rows: &[RowNode])
1569        -> HashMap<String, egui::Rect>
1570    {
1571        frame(ctx, layout, rows, vec![]);
1572        frame(ctx, layout, rows, vec![]).1
1573    }
1574
1575    /// The `menuitem:` keys published for `row`, sorted.
1576    fn menu_entries(hits: &HashMap<String, egui::Rect>, row: &str) -> Vec<String> {
1577        let prefix = format!("menuitem:{row}:");
1578        let mut keys: Vec<String> = hits
1579            .keys()
1580            .filter_map(|key| key.strip_prefix(&prefix).map(str::to_string))
1581            .collect();
1582        keys.sort();
1583        keys
1584    }
1585
1586    /// Every column heads a column, every row draws a cell per column, and the
1587    /// nesting is preserved — the widget's basic contract.
1588    #[test]
1589    fn every_column_and_every_row_cell_is_drawn() {
1590        let ctx = egui::Context::default();
1591        let mut layout = ColumnLayout::default();
1592        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1593        for key in ["name", "qty", "note", "grade", "del", "act"] {
1594            assert!(hits.contains_key(&format!("col:{key}")), "heading {key}");
1595        }
1596        for row in ["J1", "J2", "J2-P1", "J2-P2"] {
1597            assert!(hits.contains_key(&format!("row:{row}")), "tree cell {row}");
1598            assert!(
1599                hits.contains_key(&format!("cell:{row}:note")),
1600                "editor cell {row}"
1601            );
1602        }
1603        // The tree cell is column 0, so the nested rows are INDENTED past
1604        // their parent — the connector geometry came from `panels::tree`.
1605        assert!(
1606            hits["row:J2-P1"].left() > hits["row:J2"].left(),
1607            "a child indents"
1608        );
1609    }
1610
1611    /// Clicking a heading sorts ascending, then descending, then off — and the
1612    /// sort applies WITHIN each level, so a child never overtakes its parent.
1613    #[test]
1614    fn heading_click_cycles_sort_and_sorts_within_each_level() {
1615        let ctx = egui::Context::default();
1616        let mut layout = ColumnLayout::default();
1617        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1618        let head = hits["col:name"].center();
1619
1620        let out = click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
1621        assert!(out.layout_changed, "a sort click is a layout change");
1622        assert_eq!(layout.sort, Some(("name".into(), true)));
1623        // Ascending: pin1 now precedes pin2 (the caller handed them reversed),
1624        // and both are still UNDER J2.
1625        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1626        assert!(hits["row:J2-P1"].top() < hits["row:J2-P2"].top(), "sorted");
1627        assert!(hits["row:J2"].top() < hits["row:J2-P1"].top(), "still nested");
1628
1629        click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
1630        assert_eq!(layout.sort, Some(("name".into(), false)), "then descending");
1631        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1632        assert!(hits["row:J2-P2"].top() < hits["row:J2-P1"].top(), "reversed");
1633
1634        click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
1635        assert_eq!(layout.sort, None, "a third click clears the sort");
1636    }
1637
1638    /// An empty cell sorts LAST ascending — an unfilled cell is a missing
1639    /// value, not a small one — and numbers compare numerically, not as text.
1640    #[test]
1641    fn empty_cells_sort_last_and_numbers_compare_numerically() {
1642        use std::cmp::Ordering;
1643        let ten = serde_json::json!(10);
1644        let nine = serde_json::json!(9);
1645        assert_eq!(compare_cells(Some(&ten), Some(&nine)), Ordering::Greater);
1646        // ...whereas as text "10" < "9".
1647        let ten_text = Value::String("10".into());
1648        let nine_text = Value::String("9".into());
1649        assert_eq!(
1650            compare_cells(Some(&ten_text), Some(&nine_text)),
1651            Ordering::Less
1652        );
1653        let filled = Value::String("a".into());
1654        assert_eq!(compare_cells(None, Some(&filled)), Ordering::Greater);
1655        assert_eq!(compare_cells(Some(&Value::Null), Some(&filled)), Ordering::Greater);
1656        assert_eq!(compare_cells(None, None), Ordering::Equal);
1657    }
1658
1659    /// Dragging a divider resizes its column and reports the layout change.
1660    #[test]
1661    fn dragging_a_divider_resizes_the_column() {
1662        let ctx = egui::Context::default();
1663        let mut layout = ColumnLayout::default();
1664        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1665        let grip = hits["grip:name"].center();
1666        frame(
1667            &ctx,
1668            &mut layout,
1669            &rows(),
1670            vec![
1671                egui::Event::PointerMoved(grip),
1672                egui::Event::PointerButton {
1673                    pos: grip,
1674                    button: egui::PointerButton::Primary,
1675                    pressed: true,
1676                    modifiers: egui::Modifiers::default(),
1677                },
1678            ],
1679        );
1680        let mut out = ColumnTreeOut::default();
1681        for step in 1..=4 {
1682            out = frame(
1683                &ctx,
1684                &mut layout,
1685                &rows(),
1686                vec![egui::Event::PointerMoved(egui::pos2(
1687                    grip.x + 10.0 * step as f32,
1688                    grip.y,
1689                ))],
1690            )
1691            .0;
1692        }
1693        assert!(out.layout_changed, "a resize is a layout change");
1694        assert!(
1695            layout.widths["name"] > 120.0,
1696            "widened past its default: {:?}",
1697            layout.widths
1698        );
1699        // It can never be dragged away to nothing.
1700        layout.widths.insert("name".into(), 1.0);
1701        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1702        assert!(hits["col:name"].width() >= MIN_COLUMN_WIDTH);
1703    }
1704
1705    /// A hidden column heads nothing and draws no cell; the rest close up.
1706    #[test]
1707    fn a_hidden_column_disappears_and_the_rest_close_up() {
1708        let ctx = egui::Context::default();
1709        let mut layout = ColumnLayout::default();
1710        let (_, before) = frame(&ctx, &mut layout, &rows(), vec![]);
1711        let note_left = before["col:note"].left();
1712        layout.hidden.insert("qty".into());
1713        let (_, after) = frame(&ctx, &mut layout, &rows(), vec![]);
1714        assert!(!after.contains_key("col:qty"), "no heading");
1715        assert!(!after.contains_key("cell:J1:qty"), "no cell");
1716        assert!(
1717            after["col:note"].left() < note_left,
1718            "the columns to its right close up"
1719        );
1720    }
1721
1722    /// The layout's `order` drives the drawn order, and a column the layout
1723    /// never names still appears (in spec order) rather than vanishing — so a
1724    /// consumer may hand in a partial layout.
1725    #[test]
1726    fn layout_order_reorders_and_unnamed_columns_still_appear() {
1727        let ctx = egui::Context::default();
1728        let mut layout = ColumnLayout {
1729            order: vec!["note".into(), "name".into()],
1730            ..Default::default()
1731        };
1732        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1733        assert!(hits["col:note"].left() < hits["col:name"].left(), "reordered");
1734        for key in ["qty", "grade", "del"] {
1735            assert!(
1736                hits.contains_key(&format!("col:{key}")),
1737                "unnamed column {key} still drawn"
1738            );
1739        }
1740        // Column 0 is whatever comes FIRST — the tree follows the order, it is
1741        // not pinned to one column key.
1742        assert!(hits.contains_key("row:J1"), "the tree cell moved with it");
1743    }
1744
1745    /// `move_column` is the reorder that a header drag commits: it seeds from
1746    /// the DRAWN order, so the first drag on a fresh layout is not a no-op.
1747    #[test]
1748    fn move_column_seeds_from_the_drawn_order() {
1749        let cols = columns();
1750        let visible: Vec<&ColumnSpec> = cols.iter().collect();
1751        let mut layout = ColumnLayout::default();
1752        assert!(move_column(&mut layout, &visible, "grade", "name"));
1753        assert_eq!(
1754            layout.order,
1755            vec!["grade", "name", "qty", "note", "del", "on", "flags", "act"],
1756            "the held column takes the target's slot"
1757        );
1758    }
1759
1760    /// A button cell reports its click by (row, column) and never edits —
1761    /// the widget reports, the consumer acts.
1762    #[test]
1763    fn a_button_cell_reports_the_click_and_edits_nothing() {
1764        let ctx = egui::Context::default();
1765        let mut layout = ColumnLayout::default();
1766        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1767        let out = click_at(
1768            &ctx,
1769            &mut layout,
1770            &rows(),
1771            hits["cell:J1:del"].center(),
1772            egui::PointerButton::Primary,
1773        );
1774        assert_eq!(
1775            out.buttons,
1776            vec![CellClick {
1777                row_id: "J1".into(),
1778                column: "del".into()
1779            }]
1780        );
1781        assert!(out.edits.is_empty(), "a button never writes a cell");
1782    }
1783
1784    /// A TOGGLE cell reports a boolean edit, and reports the value it moved
1785    /// TO — not the one it came from.
1786    #[test]
1787    fn a_toggle_cell_reports_the_flipped_boolean() {
1788        let ctx = egui::Context::default();
1789        let mut layout = ColumnLayout::default();
1790        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1791        let out = click_at(
1792            &ctx,
1793            &mut layout,
1794            &rows(),
1795            hits["cell:J1:on"].center(),
1796            egui::PointerButton::Primary,
1797        );
1798        assert_eq!(
1799            out.edits,
1800            vec![CellEdit {
1801                row_id: "J1".into(),
1802                column: "on".into(),
1803                value: Value::Bool(false),
1804            }],
1805            "the cell was true, so the click writes false"
1806        );
1807    }
1808
1809    /// A row that is not editable draws its toggle but cannot flip it — a
1810    /// derived grouping row has no state of its own to write.
1811    #[test]
1812    fn a_toggle_on_a_read_only_row_draws_but_does_not_write() {
1813        let ctx = egui::Context::default();
1814        let mut layout = ColumnLayout::default();
1815        let mut rows = rows();
1816        rows[0].editable = false;
1817        let (_, hits) = frame(&ctx, &mut layout, &rows, vec![]);
1818        let cell = *hits.get("cell:J1:on").expect("the box is still DRAWN");
1819        let out = click_at(&ctx, &mut layout, &rows, cell.center(), egui::PointerButton::Primary);
1820        assert!(out.edits.is_empty(), "but it does not write");
1821    }
1822
1823    /// A BADGES cell renders its glyphs and writes nothing. Ill-formed colours
1824    /// are ignored rather than guessed at, so a bad badge still draws.
1825    #[test]
1826    fn a_badges_cell_draws_glyphs_and_never_edits() {
1827        let ctx = egui::Context::default();
1828        let mut layout = ColumnLayout::default();
1829        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1830        let cell = *hits.get("cell:J1:flags").expect("a badges rect");
1831        let out = click_at(&ctx, &mut layout, &rows(), cell.center(), egui::PointerButton::Primary);
1832        assert!(out.edits.is_empty(), "badges are read-only");
1833        assert!(out.buttons.is_empty());
1834        assert_eq!(parse_hex_color("#ff9f0a"), Some(egui::Color32::from_rgb(0xff, 0x9f, 0x0a)));
1835        assert_eq!(parse_hex_color("nonsense"), None, "ignored, not guessed");
1836        assert_eq!(parse_hex_color("#fff"), None, "a short form is not a colour");
1837    }
1838
1839    /// A collapse-box click reports a TOGGLE and nothing else — expansion is
1840    /// the caller's state, exactly as in `panels::tree`.
1841    #[test]
1842    fn a_collapse_box_click_reports_a_toggle_not_a_state_change() {
1843        let ctx = egui::Context::default();
1844        let mut layout = ColumnLayout::default();
1845        let mut data = rows();
1846        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1847        let out = click_at(
1848            &ctx,
1849            &mut layout,
1850            &data,
1851            hits["box:J2"].center(),
1852            egui::PointerButton::Primary,
1853        );
1854        assert_eq!(out.toggled.as_deref(), Some("J2"));
1855        assert!(data[1].expanded, "the widget did not touch the caller's state");
1856        // The caller flips it, and the children go.
1857        data[1].expanded = false;
1858        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1859        assert!(!hits.contains_key("row:J2-P1"), "collapsed children are gone");
1860    }
1861
1862    /// A text cell commits on focus-LOSS, not per keystroke: typing produces
1863    /// no edit until the focus leaves. The BOM's part-level commit re-signs a
1864    /// part document, so a per-keystroke commit would rebuild per character.
1865    #[test]
1866    fn a_text_cell_commits_on_focus_loss_not_per_keystroke() {
1867        let ctx = egui::Context::default();
1868        let mut layout = ColumnLayout::default();
1869        let data = rows();
1870        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1871        let cell = hits["cell:J1:note"].center();
1872        click_at(&ctx, &mut layout, &data, cell, egui::PointerButton::Primary);
1873
1874        let out = frame(
1875            &ctx,
1876            &mut layout,
1877            &data,
1878            vec![egui::Event::Text("XY".into())],
1879        )
1880        .0;
1881        assert!(out.edits.is_empty(), "typing alone commits nothing");
1882
1883        // Enter commits, once, with the whole value.
1884        let out = frame(
1885            &ctx,
1886            &mut layout,
1887            &data,
1888            vec![
1889                egui::Event::Key {
1890                    key: egui::Key::Enter,
1891                    physical_key: None,
1892                    pressed: true,
1893                    repeat: false,
1894                    modifiers: egui::Modifiers::default(),
1895                },
1896                egui::Event::Key {
1897                    key: egui::Key::Enter,
1898                    physical_key: None,
1899                    pressed: false,
1900                    repeat: false,
1901                    modifiers: egui::Modifiers::default(),
1902                },
1903            ],
1904        )
1905        .0;
1906        assert_eq!(
1907            out.edits,
1908            vec![CellEdit {
1909                row_id: "J1".into(),
1910                column: "note".into(),
1911                value: Value::String("mainXY".into()),
1912            }],
1913            "one commit carrying the finished text"
1914        );
1915    }
1916
1917    /// A non-editable row still SHOWS its values and still offers its buttons
1918    /// — it just cannot be typed into. (The BOM's nested sub-assembly rows,
1919    /// whose data belongs to another document.)
1920    #[test]
1921    fn a_read_only_row_shows_values_but_takes_no_edit() {
1922        let ctx = egui::Context::default();
1923        let mut layout = ColumnLayout::default();
1924        let mut data = rows();
1925        data[0].editable = false;
1926        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1927        assert!(hits.contains_key("cell:J1:note"), "the cell is still drawn");
1928        let cell = hits["cell:J1:note"].center();
1929        click_at(&ctx, &mut layout, &data, cell, egui::PointerButton::Primary);
1930        let out = frame(&ctx, &mut layout, &data, vec![egui::Event::Text("Z".into())]).0;
1931        assert!(out.edits.is_empty(), "a read-only row takes no text");
1932        // ...but its button still works.
1933        let out = click_at(
1934            &ctx,
1935            &mut layout,
1936            &data,
1937            hits["cell:J1:del"].center(),
1938            egui::PointerButton::Primary,
1939        );
1940        assert_eq!(out.buttons.len(), 1, "buttons stay live on a read-only row");
1941    }
1942
1943    /// An empty tree draws the consumer's hint rather than a bare header.
1944    #[test]
1945    fn an_empty_tree_draws_the_hint() {
1946        let ctx = egui::Context::default();
1947        let mut layout = ColumnLayout::default();
1948        let (out, hits) = frame(&ctx, &mut layout, &[], vec![]);
1949        assert!(hits.contains_key("col:name"), "the header still stands");
1950        assert!(out.edits.is_empty());
1951    }
1952
1953    /// Hiding EVERY column is survivable — it says so rather than drawing an
1954    /// unusable empty band.
1955    #[test]
1956    fn hiding_every_column_says_so() {
1957        let ctx = egui::Context::default();
1958        let mut layout = ColumnLayout::default();
1959        for column in columns() {
1960            layout.hidden.insert(column.key);
1961        }
1962        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1963        assert!(hits.is_empty(), "nothing to publish, and no panic");
1964    }
1965
1966    // ---------------------------------------------------------------- actions
1967
1968    /// THE discriminating test for trigger 2: the right-click lands on a TEXT
1969    /// CELL, which is a live `TextEdit` registered after the row band. A
1970    /// `context_menu` hung off the band would lose that click to the text edit
1971    /// and never open — this asserts the menu opens anyway. It also asserts the
1972    /// right-click did not select the row or touch the sort.
1973    #[test]
1974    fn a_right_click_on_a_text_cell_opens_the_row_menu() {
1975        let ctx = egui::Context::default();
1976        let mut layout = ColumnLayout::default();
1977        let data = rows();
1978        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1979        let cell = hits["cell:J1:note"].center();
1980
1981        let out = right_click_at(&ctx, &mut layout, &data, cell);
1982        assert!(out.clicked.is_none(), "a right-click never selects");
1983        assert_eq!(layout.sort, None, "and never sorts");
1984        assert!(!out.layout_changed);
1985
1986        let hits = settle(&ctx, &mut layout, &data);
1987        assert_eq!(
1988            menu_entries(&hits, "J1"),
1989            vec!["drop", "open", "rename"],
1990            "the row's declared entries, from a right-click over a text cell"
1991        );
1992    }
1993
1994    /// Both triggers render the SAME menu, because there is only one menu: the
1995    /// cell click and the right-click do nothing but record which row.
1996    #[test]
1997    fn both_triggers_open_one_and_the_same_menu() {
1998        let ctx = egui::Context::default();
1999        let mut layout = ColumnLayout::default();
2000        let data = rows();
2001        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2002
2003        click_at(
2004            &ctx,
2005            &mut layout,
2006            &data,
2007            hits["menu:J1"].center(),
2008            egui::PointerButton::Primary,
2009        );
2010        let from_cell = settle(&ctx, &mut layout, &data);
2011        let by_cell = menu_entries(&from_cell, "J1");
2012        assert_eq!(by_cell, vec!["drop", "open", "rename"]);
2013
2014        // Close it, then open the same row's menu by right-clicking its tree cell.
2015        click_at(
2016            &ctx,
2017            &mut layout,
2018            &data,
2019            hits["menu:J1"].center(),
2020            egui::PointerButton::Primary,
2021        );
2022        let closed = settle(&ctx, &mut layout, &data);
2023        assert!(
2024            menu_entries(&closed, "J1").is_empty(),
2025            "a second click on the trigger shuts it"
2026        );
2027
2028        right_click_at(&ctx, &mut layout, &data, hits["row:J1"].center());
2029        let from_row = settle(&ctx, &mut layout, &data);
2030        assert_eq!(menu_entries(&from_row, "J1"), by_cell, "the same menu");
2031    }
2032
2033    /// Choosing an entry reports `(row, action id)` and closes the menu; a
2034    /// DISABLED entry reports nothing at all but is still drawn (greyed, with
2035    /// its reason) rather than hidden.
2036    #[test]
2037    fn an_entry_reports_its_row_and_a_disabled_one_reports_nothing() {
2038        let ctx = egui::Context::default();
2039        let mut layout = ColumnLayout::default();
2040        let data = rows();
2041        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2042
2043        // J2 refuses "open" — but still lists it.
2044        click_at(
2045            &ctx,
2046            &mut layout,
2047            &data,
2048            hits["menu:J2"].center(),
2049            egui::PointerButton::Primary,
2050        );
2051        let open = settle(&ctx, &mut layout, &data);
2052        assert_eq!(
2053            menu_entries(&open, "J2"),
2054            vec!["drop", "open", "rename"],
2055            "a refused entry is GREYED, not hidden"
2056        );
2057        let out = click_at(
2058            &ctx,
2059            &mut layout,
2060            &data,
2061            open["menuitem:J2:open"].center(),
2062            egui::PointerButton::Primary,
2063        );
2064        assert!(out.actions.is_empty(), "a disabled entry fires nothing");
2065
2066        // ...and an allowed one fires, once, naming its row. (The click on the
2067        // greyed entry closed the menu, as a click anywhere in a menu does, so
2068        // open it again.)
2069        click_at(
2070            &ctx,
2071            &mut layout,
2072            &data,
2073            hits["menu:J2"].center(),
2074            egui::PointerButton::Primary,
2075        );
2076        let open = settle(&ctx, &mut layout, &data);
2077        let out = click_at(
2078            &ctx,
2079            &mut layout,
2080            &data,
2081            open["menuitem:J2:rename"].center(),
2082            egui::PointerButton::Primary,
2083        );
2084        assert_eq!(
2085            out.actions,
2086            vec![RowActionClick {
2087                row_id: "J2".into(),
2088                action: "rename".into()
2089            }]
2090        );
2091        assert!(out.edits.is_empty(), "a menu never writes a cell");
2092        let after = settle(&ctx, &mut layout, &data);
2093        assert!(
2094            menu_entries(&after, "J2").is_empty(),
2095            "choosing an entry closes the menu"
2096        );
2097    }
2098
2099    /// A row that declares no action offers no menu from either trigger — and
2100    /// its trigger cell is still drawn, so the column stays a column.
2101    #[test]
2102    fn a_row_that_declares_nothing_opens_nothing() {
2103        let ctx = egui::Context::default();
2104        let mut layout = ColumnLayout::default();
2105        let data = rows();
2106        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2107        assert!(hits.contains_key("menu:J2-P1"), "the trigger cell is drawn");
2108
2109        click_at(
2110            &ctx,
2111            &mut layout,
2112            &data,
2113            hits["menu:J2-P1"].center(),
2114            egui::PointerButton::Primary,
2115        );
2116        right_click_at(&ctx, &mut layout, &data, hits["cell:J2-P1:note"].center());
2117        let after = settle(&ctx, &mut layout, &data);
2118        assert!(
2119            after.keys().all(|key| !key.starts_with("menuitem:")),
2120            "no menu from either trigger: {:?}",
2121            after.keys().collect::<Vec<_>>()
2122        );
2123    }
2124
2125    // ---------------------------------------------------------------- freezing
2126
2127    /// A layout with `frozen` columns pins them: scrolling the remainder moves
2128    /// the scrolling headings and leaves the frozen ones exactly where they
2129    /// were. Without the split (one scroll area over everything) the frozen
2130    /// heading would travel with the rest.
2131    #[test]
2132    fn frozen_columns_hold_still_while_the_rest_scroll() {
2133        let ctx = egui::Context::default();
2134        let mut layout = ColumnLayout {
2135            frozen: 2,
2136            ..Default::default()
2137        };
2138        // Wide enough that the remainder MUST scroll inside a 700pt frame.
2139        layout.widths.insert("note".into(), 400.0);
2140        layout.widths.insert("grade".into(), 400.0);
2141        let data = rows();
2142        let (_, before) = frame(&ctx, &mut layout, &data, vec![]);
2143        assert!(before.contains_key("freeze:divider"), "the boundary is drawn");
2144        let divider = before["freeze:divider"];
2145        assert!(before["col:qty"].right() <= divider.left() + 1.0, "qty is pinned");
2146        assert!(before["col:note"].left() >= divider.left(), "note scrolls");
2147
2148        // Wheel over the scrolling side.
2149        let over = before["col:note"].center();
2150        for _ in 0..8 {
2151            frame(
2152                &ctx,
2153                &mut layout,
2154                &data,
2155                vec![
2156                    egui::Event::PointerMoved(over),
2157                    egui::Event::MouseWheel {
2158                        unit: egui::MouseWheelUnit::Point,
2159                        delta: egui::vec2(-60.0, 0.0),
2160                        phase: egui::TouchPhase::Move,
2161                        modifiers: egui::Modifiers::default(),
2162                    },
2163                ],
2164            );
2165        }
2166        let (_, after) = frame(&ctx, &mut layout, &data, vec![]);
2167        assert_eq!(
2168            after["col:name"], before["col:name"],
2169            "a frozen heading does not move"
2170        );
2171        assert_eq!(after["col:qty"], before["col:qty"], "nor the second one");
2172        assert_eq!(after["row:J1"], before["row:J1"], "nor the frozen tree cell");
2173        assert!(
2174            after["col:note"].left() < before["col:note"].left() - 20.0,
2175            "and the scrolling side did scroll: {:?} -> {:?}",
2176            before["col:note"],
2177            after["col:note"]
2178        );
2179    }
2180
2181    /// Freezing EVERY column reads as freezing none: there would be nothing to
2182    /// scroll it against, and a column past the right edge would be stranded
2183    /// with no way to reach it.
2184    #[test]
2185    fn freezing_every_column_reads_as_none() {
2186        let ctx = egui::Context::default();
2187        let mut layout = ColumnLayout {
2188            frozen: 99,
2189            ..Default::default()
2190        };
2191        let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
2192        assert!(!hits.contains_key("freeze:divider"), "no boundary is drawn");
2193        assert!(hits.contains_key("col:act"), "and the last column is still there");
2194    }
2195
2196    /// The boundary counts over the arranged order INCLUDING hidden columns, so
2197    /// hiding a frozen column does not silently pull the next one in.
2198    #[test]
2199    fn hiding_a_frozen_column_does_not_promote_the_next_one() {
2200        let ctx = egui::Context::default();
2201        let mut layout = ColumnLayout {
2202            frozen: 2,
2203            ..Default::default()
2204        };
2205        layout.widths.insert("note".into(), 400.0);
2206        layout.widths.insert("grade".into(), 400.0);
2207        let (_, before) = frame(&ctx, &mut layout, &rows(), vec![]);
2208        assert!(before["col:note"].left() >= before["freeze:divider"].left());
2209
2210        layout.hidden.insert("qty".into());
2211        let (_, after) = frame(&ctx, &mut layout, &rows(), vec![]);
2212        assert!(
2213            after["col:note"].left() >= after["freeze:divider"].left(),
2214            "note stayed on the scrolling side rather than being promoted"
2215        );
2216        assert!(
2217            after["col:name"].right() <= after["freeze:divider"].left() + 1.0,
2218            "and the surviving frozen column is still frozen"
2219        );
2220    }
2221
2222    /// The horizontal scrollbar FLOATS over the content, and it lands on the
2223    /// last row — where it silently eats the clicks on the bottom half of that
2224    /// row's cells. The widget reserves a gutter for it; without one, the last
2225    /// row's action menu opens from the top edge of its trigger and not from
2226    /// the middle, which is where anyone actually clicks.
2227    #[test]
2228    fn the_horizontal_scrollbar_does_not_eat_the_last_rows_clicks() {
2229        let ctx = egui::Context::default();
2230        let mut layout = ColumnLayout::default();
2231        // Wide enough that the remainder MUST scroll, so the bar is drawn.
2232        layout.widths.insert("note".into(), 300.0);
2233        layout.widths.insert("grade".into(), 300.0);
2234        let mut data = rows();
2235        data[1].expanded = false; // ...so the LAST drawn row is one with actions
2236
2237        // Wheel the trailing action column into view.
2238        let mut hits = frame(&ctx, &mut layout, &data, vec![]).1;
2239        for _ in 0..12 {
2240            hits = frame(
2241                &ctx,
2242                &mut layout,
2243                &data,
2244                vec![
2245                    egui::Event::PointerMoved(egui::pos2(350.0, 40.0)),
2246                    egui::Event::MouseWheel {
2247                        unit: egui::MouseWheelUnit::Point,
2248                        delta: egui::vec2(-60.0, 0.0),
2249                        phase: egui::TouchPhase::Move,
2250                        modifiers: egui::Modifiers::default(),
2251                    },
2252                ],
2253            )
2254            .1;
2255        }
2256        let trigger = *hits
2257            .get("menu:J2")
2258            .expect("the trailing action column scrolled into view");
2259
2260        click_at(
2261            &ctx,
2262            &mut layout,
2263            &data,
2264            trigger.center(),
2265            egui::PointerButton::Primary,
2266        );
2267        let hits = settle(&ctx, &mut layout, &data);
2268        assert!(
2269            !menu_entries(&hits, "J2").is_empty(),
2270            "the CENTRE of the last row's trigger opened nothing: {trigger:?}"
2271        );
2272    }
2273
2274    /// The widget is drawn inside a VERTICAL scroll area in the real shell
2275    /// (`dock.rs`), where the available height is INFINITE. Everything still
2276    /// lays out, publishes finite rects and opens its menu — the "it works in a
2277    /// unit test and vanishes in the app" gap.
2278    #[test]
2279    fn it_survives_being_nested_in_a_vertical_scroll_area() {
2280        let ctx = egui::Context::default();
2281        let cols = columns();
2282        let spec = spec(&cols);
2283        let data = rows();
2284        // Frozen, but narrow enough that every column is on screen — this is
2285        // about the INFINITE height the scroll area hands down, not scrolling.
2286        let mut layout = ColumnLayout {
2287            frozen: 2,
2288            ..Default::default()
2289        };
2290
2291        let mut draw = |events: Vec<egui::Event>| {
2292            let mut hits = HashMap::new();
2293            let raw = egui::RawInput {
2294                screen_rect: Some(egui::Rect::from_min_size(
2295                    egui::pos2(0.0, 0.0),
2296                    egui::vec2(700.0, 500.0),
2297                )),
2298                events,
2299                ..Default::default()
2300            };
2301            let _ = ctx.run_ui(raw, |ui| {
2302                egui::ScrollArea::vertical()
2303                    .auto_shrink([false, false])
2304                    .show(ui, |ui| {
2305                        column_tree(ui, &spec, &mut layout, &data, Some(&mut hits));
2306                    });
2307            });
2308            hits
2309        };
2310
2311        let hits = draw(vec![]);
2312        for (key, rect) in &hits {
2313            // Finite everywhere: the enclosing scroll area hands the widget an
2314            // INFINITE available height, and an infinity that reaches a rect is
2315            // a pane that draws nothing.
2316            assert!(rect.is_finite(), "{key} has an infinite rect {rect:?}");
2317            // ...and the things a verifier must be able to CLICK have an area.
2318            // (An empty read-only cell's label is legitimately zero-wide.)
2319            if key.starts_with("row:")
2320                || key.starts_with("col:")
2321                || key.starts_with("menu")
2322                || key.starts_with("freeze:")
2323            {
2324                assert!(rect.is_positive(), "{key} is unclickable: {rect:?}");
2325            }
2326        }
2327        assert!(hits.contains_key("freeze:divider"), "still frozen in there");
2328        assert!(hits["row:J1"].top() < 500.0, "and drawn on screen");
2329
2330        // ...and the menu still opens, from inside the scroll area.
2331        let trigger = hits["menu:J1"].center();
2332        draw(vec![
2333            egui::Event::PointerMoved(trigger),
2334            egui::Event::PointerButton {
2335                pos: trigger,
2336                button: egui::PointerButton::Primary,
2337                pressed: true,
2338                modifiers: egui::Modifiers::default(),
2339            },
2340        ]);
2341        draw(vec![egui::Event::PointerButton {
2342            pos: trigger,
2343            button: egui::PointerButton::Primary,
2344            pressed: false,
2345            modifiers: egui::Modifiers::default(),
2346        }]);
2347        draw(vec![]);
2348        let hits = draw(vec![]);
2349        assert_eq!(menu_entries(&hits, "J1"), vec!["drop", "open", "rename"]);
2350    }
2351
2352    /// A heading dragged ACROSS the boundary changes what is frozen — the
2353    /// bounds of BOTH panes are merged before the drop is resolved, so the drag
2354    /// does not fall off the edge of the pane it started in.
2355    #[test]
2356    fn a_heading_drags_across_the_freeze_boundary() {
2357        let ctx = egui::Context::default();
2358        let mut layout = ColumnLayout {
2359            order: vec![
2360                "name".into(),
2361                "qty".into(),
2362                "note".into(),
2363                "grade".into(),
2364                "del".into(),
2365                "act".into(),
2366            ],
2367            frozen: 2,
2368            ..Default::default()
2369        };
2370        let data = rows();
2371        let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2372        let from = hits["col:note"].center();
2373        let onto = hits["col:name"].center();
2374
2375        frame(
2376            &ctx,
2377            &mut layout,
2378            &data,
2379            vec![
2380                egui::Event::PointerMoved(from),
2381                egui::Event::PointerButton {
2382                    pos: from,
2383                    button: egui::PointerButton::Primary,
2384                    pressed: true,
2385                    modifiers: egui::Modifiers::default(),
2386                },
2387            ],
2388        );
2389        for step in 1..=4 {
2390            let at = egui::pos2(from.x + (onto.x - from.x) * step as f32 / 4.0, from.y);
2391            frame(&ctx, &mut layout, &data, vec![egui::Event::PointerMoved(at)]);
2392        }
2393        let out = frame(
2394            &ctx,
2395            &mut layout,
2396            &data,
2397            vec![egui::Event::PointerButton {
2398                pos: onto,
2399                button: egui::PointerButton::Primary,
2400                pressed: false,
2401                modifiers: egui::Modifiers::default(),
2402            }],
2403        )
2404        .0;
2405        assert!(out.layout_changed, "the drop is a layout change");
2406        assert_eq!(
2407            layout.order.first().map(String::as_str),
2408            Some("note"),
2409            "the scrolling column took the frozen one's slot: {:?}",
2410            layout.order
2411        );
2412    }
2413}