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