Skip to main content

sqlly_datatable/pivot/
state.rs

1//! `PivotState` — runtime state for the pivot view: the computed result,
2//! expand/collapse, sorting, source filters, selection, scrolling, and
3//! hit-testing.
4//!
5//! The state holds an `Arc` snapshot of the source rows shared with the flat
6//! grid. **The source is never mutated**; every interaction either re-runs
7//! the pure engine (config/filter changes) or just re-flattens the visible
8//! row/column lists (expand/collapse/sort — no engine recompute).
9
10use crate::config::{KeyBindings, NumberFormat, ResolvedColumnFormat, TextAlignment};
11use crate::data::{compare_cells, CellValue, Column, ColumnKind};
12use crate::format::format_cell;
13use crate::grid::selection::{ScrollbarAxis, SortDirection};
14use crate::grid::state::{FilterValueRow, SCROLLBAR_SIZE};
15use crate::grid::theme::GridTheme;
16use crate::pivot::aggregation::AggregationFn;
17use crate::pivot::config::{PivotConfig, PivotZone};
18use crate::pivot::context_menu::{
19    PivotCellContext, PivotContextMenuProviderHandle, PivotContextMenuRequest, PivotMenuItem,
20    PivotMenuTarget, PivotPathComponent, PIVOT_ACTION_COPY_CSV, PIVOT_ACTION_COPY_VALUE,
21    PIVOT_ACTION_SHOW_SOURCE_ROWS,
22};
23use crate::pivot::engine::{compute_pivot, PivotNode, PivotResult, TOTAL_KEY};
24
25use gpui::{px, App, Bounds, FocusHandle, Pixels, Point, ScrollHandle, Size};
26use std::collections::{HashMap, HashSet};
27use std::rc::Rc;
28use std::sync::Arc;
29
30/// Callback invoked when the user clicks the sidebar's save-configuration
31/// button. Receives the live [`PivotConfig`] to persist.
32pub type PivotSaveConfigHandler = Rc<dyn Fn(&PivotConfig, &mut App)>;
33
34/// What kind of line a visible pivot row is.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum VisibleRowKind {
37    /// An innermost group — a plain data row.
38    Leaf,
39    /// A group header. `expanded == false` means the group is collapsed and
40    /// this row shows the group's subtotals.
41    GroupHeader {
42        /// Whether the group's children are currently shown.
43        expanded: bool,
44    },
45    /// The grand-total row at the bottom.
46    GrandTotal,
47}
48
49/// One row of the flattened, display-ready pivot.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct VisibleRow {
52    /// Row node id, or [`TOTAL_KEY`] for the grand-total row (and for the
53    /// single value row when no row fields are assigned).
54    pub key: usize,
55    /// Indent level (0 = outermost).
56    pub depth: usize,
57    /// What this line is.
58    pub kind: VisibleRowKind,
59    /// Alternating-shade flag for leaf rows; resets at every group header
60    /// so striping restarts within each group.
61    pub zebra: bool,
62}
63
64/// What kind of column a visible pivot column is.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum VisibleColKind {
67    /// An innermost column group — a plain value column.
68    Leaf,
69    /// A collapsed column group shown as a single subtotal column.
70    Collapsed,
71    /// The "Total" column appended after an expanded group
72    /// (when [`PivotConfig::show_column_subtotals`] is on).
73    Subtotal,
74    /// The grand-total column at the right.
75    GrandTotal,
76}
77
78/// One column of the flattened, display-ready pivot.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct VisibleCol {
81    /// Column node id, or [`TOTAL_KEY`] for the grand-total column (and for
82    /// the single value column when no column fields are assigned).
83    pub key: usize,
84    /// Depth of the node this column represents.
85    pub depth: usize,
86    /// What this column is.
87    pub kind: VisibleColKind,
88}
89
90/// What the row axis (and, for `ColLabel`, the column axis) is ordered by.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum PivotSortKey {
93    /// Order row groups by their grouping value.
94    RowLabel,
95    /// Order row groups by their aggregated value in the given visible
96    /// column key ([`TOTAL_KEY`] = the grand-total column, i.e. "by
97    /// subtotal").
98    RowsByColumn(usize),
99    /// Order column groups by their grouping value.
100    ColLabel,
101}
102
103/// What a pointer position over the pivot grid resolved to. Row/column
104/// coordinates are indices into the visible row/column lists.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum PivotHitResult {
107    /// Nothing interactive.
108    None,
109    /// The top-left corner block.
110    Corner,
111    /// A column header cell (any header level) over visible column `col`.
112    ColHeader {
113        /// Header level (0 = caption row).
114        level: usize,
115        /// Visible column index.
116        col: usize,
117    },
118    /// A row label cell.
119    RowHeader {
120        /// Visible row index.
121        row: usize,
122    },
123    /// The lower edge of a visible row in the row-header area.
124    RowBorder {
125        /// Visible row index whose lower edge was hit.
126        row: usize,
127    },
128    /// The right edge of a visible value column in the header area.
129    ColBorder {
130        /// Visible column index whose right edge was hit.
131        col: usize,
132    },
133    /// The right edge of the row-label area (drag to resize its width).
134    RowHeaderBorder,
135    /// The expand/collapse chevron on a row group header.
136    RowChevron {
137        /// Visible row index.
138        row: usize,
139    },
140    /// The expand/collapse chevron on a column group header.
141    ColChevron {
142        /// Visible column index the chevron was painted at (run start).
143        col: usize,
144        /// Depth of the group the chevron belongs to.
145        depth: usize,
146    },
147    /// A data cell.
148    Cell {
149        /// Visible row index.
150        row: usize,
151        /// Visible column index.
152        col: usize,
153    },
154    /// The vertical scrollbar strip.
155    VScrollbar,
156    /// The horizontal scrollbar strip.
157    HScrollbar,
158}
159
160/// Working state of the checklist popover opened from a Filters-zone chip.
161#[derive(Clone, Debug)]
162pub struct PivotFilterPopover {
163    /// The source column being filtered.
164    pub field: usize,
165    /// Distinct formatted values with their checked state.
166    pub rows: Vec<FilterValueRow>,
167    /// Window-absolute anchor where the popover opens (the click position).
168    pub anchor: Point<Pixels>,
169}
170
171impl PivotFilterPopover {
172    /// `true` when every value is checked (filter is inert).
173    #[must_use]
174    pub fn all_checked(&self) -> bool {
175        !self.rows.is_empty() && self.rows.iter().all(|r| r.checked)
176    }
177}
178
179/// Working state of the per-field format dialog opened by double-clicking a
180/// sidebar zone chip.
181#[derive(Clone, Copy, Debug)]
182pub struct PivotFormatDialog {
183    /// The source column being configured.
184    pub field: usize,
185    /// The zone whose chip was double-clicked. [`PivotZone::Values`] edits
186    /// [`PivotConfig::value_format`] (value cells); every other zone edits
187    /// [`PivotConfig::field_formats`] (that field's labels).
188    pub zone: PivotZone,
189    /// Window-absolute anchor where the dialog opens (the click position).
190    pub anchor: Point<Pixels>,
191}
192
193/// An open pivot right-click menu: what to show, where, and the context it
194/// was opened for.
195#[derive(Clone, Debug)]
196pub(crate) struct PivotMenu {
197    /// Grid-relative anchor (the right-click position).
198    pub(crate) anchor: Point<Pixels>,
199    /// Items in display order.
200    pub(crate) items: Vec<PivotMenuItem>,
201    /// Index (into action items only) currently hovered.
202    pub(crate) hovered: Option<usize>,
203    /// The context snapshot handed to the provider.
204    pub(crate) request: PivotContextMenuRequest,
205    /// Precomputed drill-through filters for the built-in "Show source
206    /// rows" action.
207    pub(crate) drill: Vec<(usize, HashSet<String>)>,
208}
209
210/// Fixed indent per row-group depth level, in pixels.
211pub(crate) const ROW_INDENT: f32 = 16.0;
212/// Square hit/paint size of an expand/collapse chevron.
213pub(crate) const CHEVRON_SIZE: f32 = 14.0;
214const RESIZE_HIT_SLOP: f32 = 3.0;
215/// Default height of pivot data rows, in logical pixels.
216pub const DEFAULT_PIVOT_ROW_HEIGHT: f32 = 24.0;
217/// Default width of pivot value columns, in logical pixels.
218pub const DEFAULT_PIVOT_COLUMN_WIDTH: f32 = 140.0;
219/// Smallest supported pivot data-row height.
220pub const MIN_PIVOT_ROW_HEIGHT: f32 = 18.0;
221/// Smallest supported pivot value-column width.
222pub const MIN_PIVOT_COLUMN_WIDTH: f32 = 40.0;
223/// Smallest supported width of the pivot row-label area.
224pub const MIN_PIVOT_ROW_HEADER_WIDTH: f32 = 60.0;
225/// Default width of the pivot controls sidebar, in logical pixels.
226pub const DEFAULT_PIVOT_SIDEBAR_WIDTH: f32 = 260.0;
227
228#[derive(Clone, Copy, Debug)]
229enum PivotResizeDrag {
230    Row {
231        boundary: usize,
232        start_y: f32,
233        start_height: f32,
234    },
235    Column {
236        boundary: usize,
237        start_x: f32,
238        start_width: f32,
239    },
240    RowHeader {
241        start_x: f32,
242        start_width: f32,
243    },
244}
245
246/// Complete pivot-view state owned by a GPUI `Entity<PivotState>`.
247pub struct PivotState {
248    /// Live pivot layout. Mutate it (or use the helper methods) and call
249    /// [`PivotState::recompute`]; read it back for persistence — this struct
250    /// *is* the "current configuration" API.
251    pub config: PivotConfig,
252    /// The computed pivot, Arc-wrapped so paint snapshots clone in O(1).
253    pub result: Arc<PivotResult>,
254    /// Shared, immutable source rows (same Arc as the flat grid's snapshot).
255    pub(crate) source_rows: Arc<Vec<Vec<CellValue>>>,
256    /// Source column metadata.
257    pub(crate) source_columns: Vec<Column>,
258    /// Resolved per-source-column formats; drive group labels and filters.
259    pub(crate) resolved_formats: Vec<ResolvedColumnFormat>,
260    /// [`Self::resolved_formats`] with [`PivotConfig::field_formats`]
261    /// overrides applied. Rebuilt on every recompute; everything that
262    /// formats group labels or filter values reads these.
263    pub(crate) label_formats: Vec<ResolvedColumnFormat>,
264    /// Format used for value cells (kind-adjusted for the aggregation).
265    pub(crate) value_fmt: ResolvedColumnFormat,
266
267    /// Flattened display rows, rebuilt on recompute/collapse/sort.
268    /// Arc-wrapped so paint snapshots clone in O(1).
269    pub(crate) visible_rows: Arc<Vec<VisibleRow>>,
270    /// Flattened display columns.
271    pub(crate) visible_cols: Arc<Vec<VisibleCol>>,
272    /// Collapsed row groups, keyed by label path so collapse state survives
273    /// recomputes (node ids do not).
274    pub(crate) collapsed_row_paths: HashSet<Vec<String>>,
275    /// Collapsed column groups, keyed by label path.
276    pub(crate) collapsed_col_paths: HashSet<Vec<String>>,
277    /// Active ordering. `None` = canonical (ascending by grouping value).
278    pub sort: Option<(PivotSortKey, SortDirection)>,
279    /// Per filter-field allow-list of formatted labels. Absent entry = all
280    /// values pass.
281    pub(crate) filter_values: HashMap<usize, HashSet<String>>,
282    /// Open Filters-zone checklist popover, if any.
283    pub(crate) filter_popover: Option<PivotFilterPopover>,
284    /// Open per-field format dialog (sidebar chip double-click), if any.
285    pub(crate) format_dialog: Option<PivotFormatDialog>,
286    /// Whether the sidebar's aggregation picker is expanded.
287    pub agg_menu_open: bool,
288    /// Registered save-configuration action. The sidebar's save button only
289    /// renders while this is `Some`.
290    pub(crate) save_config_handler: Option<PivotSaveConfigHandler>,
291    /// Current width of the pivot controls sidebar, kept in sync by the host
292    /// widget. The sidebar uses it to decide when chip labels are truncated.
293    pub(crate) sidebar_width: f32,
294    /// Registered right-click provider; `None` shows the built-in menu.
295    pub(crate) context_menu_provider: Option<PivotContextMenuProviderHandle>,
296    /// Open right-click menu, if any.
297    pub(crate) menu: Option<PivotMenu>,
298    /// Drill-through request produced by a double-click or the built-in
299    /// "Show source rows" action: per-source-column allowed formatted
300    /// values. Drained by `SqllyDataTable::render`, which applies them as
301    /// grid filters and switches to the Grid tab.
302    pub(crate) pending_drill_down: Option<Vec<(usize, HashSet<String>)>>,
303
304    /// Selected rectangle in visible coordinates `(r1, c1, r2, c2)`,
305    /// normalized.
306    pub selection: Option<(usize, usize, usize, usize)>,
307    pub(crate) select_anchor: Option<(usize, usize)>,
308    pub(crate) is_selecting: bool,
309    /// Last hit under the pointer (drives hover affordances).
310    pub hover_hit: Option<PivotHitResult>,
311    pub(crate) scrollbar_drag: Option<ScrollbarAxis>,
312    resize_drag: Option<PivotResizeDrag>,
313
314    /// Theme shared with the host grid.
315    pub theme: GridTheme,
316    /// Mirrors [`crate::GridConfig::animations`]: whether the pivot's transient
317    /// surfaces (menu, filter popover, format dialog, drag ghost, accordion
318    /// bodies) fade in on appear. Set from the host grid's config at build time.
319    pub animations: bool,
320    pub(crate) key_bindings: KeyBindings,
321    /// Scroll offset of the data region.
322    pub scroll_handle: ScrollHandle,
323    /// Focus handle for keyboard input.
324    pub focus_handle: FocusHandle,
325    /// Painted bounds, updated each layout pass.
326    pub bounds: Bounds<Pixels>,
327    pub(crate) window_viewport: Size<Pixels>,
328    /// Height of one data row.
329    pub row_height: f32,
330    /// Height of one column-header level.
331    pub header_row_height: f32,
332    /// Width of the row-label column.
333    pub row_header_width: f32,
334    /// Uniform width of every value column.
335    pub value_col_width: f32,
336    /// Font size for all pivot text.
337    pub font_size: f32,
338    /// Approximate monospace character width used for text measurement.
339    pub char_width: f32,
340}
341
342impl PivotState {
343    /// Build a pivot state over a shared source snapshot and compute the
344    /// initial result.
345    #[must_use]
346    pub fn new(
347        source_columns: Vec<Column>,
348        source_rows: Arc<Vec<Vec<CellValue>>>,
349        resolved_formats: Vec<ResolvedColumnFormat>,
350        config: PivotConfig,
351        key_bindings: KeyBindings,
352        focus_handle: FocusHandle,
353    ) -> Self {
354        let mut state = Self {
355            config,
356            result: Arc::new(PivotResult::default()),
357            source_rows,
358            source_columns,
359            resolved_formats,
360            label_formats: Vec::new(),
361            value_fmt: default_value_format(),
362            visible_rows: Arc::new(Vec::new()),
363            visible_cols: Arc::new(Vec::new()),
364            collapsed_row_paths: HashSet::new(),
365            collapsed_col_paths: HashSet::new(),
366            sort: None,
367            filter_values: HashMap::new(),
368            filter_popover: None,
369            format_dialog: None,
370            agg_menu_open: false,
371            save_config_handler: None,
372            sidebar_width: DEFAULT_PIVOT_SIDEBAR_WIDTH,
373            context_menu_provider: None,
374            menu: None,
375            pending_drill_down: None,
376            selection: None,
377            select_anchor: None,
378            is_selecting: false,
379            hover_hit: None,
380            scrollbar_drag: None,
381            theme: GridTheme::default(),
382            animations: true,
383            key_bindings,
384            scroll_handle: ScrollHandle::new(),
385            focus_handle,
386            bounds: Bounds::default(),
387            window_viewport: Size::default(),
388            row_height: DEFAULT_PIVOT_ROW_HEIGHT,
389            header_row_height: 26.0,
390            row_header_width: 220.0,
391            value_col_width: DEFAULT_PIVOT_COLUMN_WIDTH,
392            // Match the flat grid's cell font (grid/state.rs) so cell text
393            // doesn't change size when the user toggles Grid ↔ Pivot.
394            font_size: 14.0,
395            char_width: crate::grid::paint::default_char_width(14.0),
396            resize_drag: None,
397        };
398        state.recompute();
399        state
400    }
401
402    /// Replace the source snapshot (e.g. after the flat grid appended rows)
403    /// and recompute. O(1) extra memory when `rows` shares the grid's Arc.
404    pub fn set_source(&mut self, columns: Vec<Column>, rows: Arc<Vec<Vec<CellValue>>>) {
405        self.source_columns = columns;
406        self.source_rows = rows;
407        self.recompute();
408    }
409
410    /// `true` when `rows` is a different snapshot than the current source.
411    #[must_use]
412    pub fn source_differs(&self, rows: &Arc<Vec<Vec<CellValue>>>) -> bool {
413        !Arc::ptr_eq(&self.source_rows, rows)
414    }
415
416    /// Source column metadata (for building field lists).
417    #[must_use]
418    pub fn source_columns(&self) -> &[Column] {
419        &self.source_columns
420    }
421
422    /// Re-run the pivot engine against the current config/filters, then
423    /// rebuild the flattened display lists. Never mutates the source rows.
424    pub fn recompute(&mut self) {
425        self.config.clamp_to_columns(self.source_columns.len());
426        let filter_fields = self.config.filter_fields.clone();
427        self.filter_values
428            .retain(|field, _| filter_fields.contains(field));
429        self.label_formats = self.build_label_formats();
430
431        if self.config.is_ready() {
432            let included = self.filtered_source_rows();
433            self.result = Arc::new(compute_pivot(
434                &self.source_columns,
435                &self.source_rows,
436                &included,
437                &self.config,
438                &self.label_formats,
439            ));
440        } else {
441            self.result = Arc::new(PivotResult::default());
442        }
443        self.value_fmt = self.build_value_format();
444        self.resort();
445    }
446
447    /// [`Self::resolved_formats`] with [`PivotConfig::field_formats`]
448    /// applied: the override replaces the number format and its alignment
449    /// carries over to every kind's alignment.
450    fn build_label_formats(&self) -> Vec<ResolvedColumnFormat> {
451        self.resolved_formats
452            .iter()
453            .enumerate()
454            .map(|(i, base)| {
455                let mut fmt = base.clone();
456                if let Some(over) = self.config.field_formats.get(&i) {
457                    fmt.number = *over;
458                    fmt.string.alignment = over.alignment;
459                    fmt.date.alignment = over.alignment;
460                    fmt.boolean.alignment = over.alignment;
461                }
462                fmt
463            })
464            .collect()
465    }
466
467    /// The effective display format for one field's group labels and filter
468    /// values (resolved column format plus any
469    /// [`PivotConfig::field_formats`] override).
470    #[must_use]
471    pub fn label_format(&self, field: usize) -> Option<&ResolvedColumnFormat> {
472        self.label_formats.get(field)
473    }
474
475    /// Source row indices that pass every active Filters-zone filter.
476    fn filtered_source_rows(&self) -> Vec<usize> {
477        let active: Vec<(usize, &HashSet<String>)> = self
478            .config
479            .filter_fields
480            .iter()
481            .filter_map(|&f| self.filter_values.get(&f).map(|set| (f, set)))
482            .collect();
483        if active.is_empty() {
484            return (0..self.source_rows.len()).collect();
485        }
486        (0..self.source_rows.len())
487            .filter(|&r| {
488                active.iter().all(|(field, allowed)| {
489                    let cell = self.source_rows[r].get(*field).unwrap_or(&CellValue::None);
490                    let label = format_cell(cell, &self.label_formats[*field]).0;
491                    allowed.contains(&label)
492                })
493            })
494            .collect()
495    }
496
497    /// Resolved display format for value cells: the value column's format
498    /// with its kind adjusted to what the aggregation actually produces
499    /// (`Count` → Integer, `Avg` → Decimal), plus the optional
500    /// [`PivotConfig::value_format`] number override. Value cells are always
501    /// right-aligned and paint negative numbers red regardless of the source
502    /// column's format (an explicit `value_format` override may still choose
503    /// otherwise).
504    fn build_value_format(&self) -> ResolvedColumnFormat {
505        let mut fmt = self
506            .config
507            .value_field
508            .and_then(|f| self.resolved_formats.get(f).cloned())
509            .unwrap_or_else(default_value_format);
510        match self.config.aggregation {
511            AggregationFn::Count => {
512                fmt.kind = ColumnKind::Integer;
513                fmt.number = NumberFormat {
514                    decimals: 0,
515                    ..fmt.number
516                };
517            }
518            AggregationFn::Avg => fmt.kind = ColumnKind::Decimal,
519            AggregationFn::Sum | AggregationFn::Min | AggregationFn::Max => {}
520        }
521        fmt.number.alignment = TextAlignment::Right;
522        fmt.string.alignment = TextAlignment::Right;
523        fmt.date.alignment = TextAlignment::Right;
524        fmt.boolean.alignment = TextAlignment::Right;
525        fmt.number.show_negative_red = true;
526        if let Some(over) = self.config.value_format {
527            fmt.number = over;
528            fmt.string.alignment = over.alignment;
529            fmt.date.alignment = over.alignment;
530            fmt.boolean.alignment = over.alignment;
531        }
532        fmt
533    }
534
535    /// The format used for value cells (public for export code).
536    #[must_use]
537    pub fn value_format(&self) -> &ResolvedColumnFormat {
538        &self.value_fmt
539    }
540
541    // ------------------------------------------------------------------
542    // Flattening / expand-collapse
543    // ------------------------------------------------------------------
544
545    /// Rebuild [`Self::visible_rows`] / [`Self::visible_cols`] from the
546    /// result and the collapse sets. Cheap: no engine work.
547    pub(crate) fn rebuild_visible(&mut self) {
548        self.visible_rows = Arc::new(flatten_rows(
549            &self.result,
550            &self.collapsed_row_paths,
551            &self.config,
552        ));
553        self.visible_cols = Arc::new(flatten_cols(
554            &self.result,
555            &self.collapsed_col_paths,
556            &self.config,
557        ));
558        self.clamp_selection();
559        self.clamp_scroll_to_bounds();
560    }
561
562    /// The flattened display rows.
563    #[must_use]
564    pub fn visible_rows(&self) -> &[VisibleRow] {
565        &self.visible_rows
566    }
567
568    /// The flattened display columns.
569    #[must_use]
570    pub fn visible_cols(&self) -> &[VisibleCol] {
571        &self.visible_cols
572    }
573
574    /// Toggle a row group open/closed. `node` is a row node id. Instant —
575    /// only re-flattens, no engine recompute.
576    pub fn toggle_row_group(&mut self, node: usize) {
577        if node >= self.result.row_nodes.len() {
578            return;
579        }
580        let path = node_path(&self.result.row_nodes, node);
581        if !self.collapsed_row_paths.remove(&path) {
582            self.collapsed_row_paths.insert(path);
583        }
584        self.rebuild_visible();
585    }
586
587    /// Toggle a column group open/closed. `node` is a column node id.
588    pub fn toggle_col_group(&mut self, node: usize) {
589        if node >= self.result.col_nodes.len() {
590            return;
591        }
592        let path = node_path(&self.result.col_nodes, node);
593        if !self.collapsed_col_paths.remove(&path) {
594            self.collapsed_col_paths.insert(path);
595        }
596        self.rebuild_visible();
597    }
598
599    /// Collapse every row group at every level.
600    pub fn collapse_all_rows(&mut self) {
601        self.collapsed_row_paths = all_group_paths(&self.result.row_nodes);
602        self.rebuild_visible();
603    }
604
605    /// Expand every row group.
606    pub fn expand_all_rows(&mut self) {
607        self.collapsed_row_paths.clear();
608        self.rebuild_visible();
609    }
610
611    /// Collapse every column group at every level.
612    pub fn collapse_all_cols(&mut self) {
613        self.collapsed_col_paths = all_group_paths(&self.result.col_nodes);
614        self.rebuild_visible();
615    }
616
617    /// Expand every column group.
618    pub fn expand_all_cols(&mut self) {
619        self.collapsed_col_paths.clear();
620        self.rebuild_visible();
621    }
622
623    // ------------------------------------------------------------------
624    // Sorting
625    // ------------------------------------------------------------------
626
627    /// Cycle row ordering by a visible column key (asc → desc → canonical).
628    /// Pass [`TOTAL_KEY`] to sort by the row subtotals.
629    pub fn cycle_sort_by_column(&mut self, col_key: usize) {
630        self.sort = match self.sort {
631            Some((PivotSortKey::RowsByColumn(k), SortDirection::Ascending)) if k == col_key => {
632                Some((
633                    PivotSortKey::RowsByColumn(col_key),
634                    SortDirection::Descending,
635                ))
636            }
637            Some((PivotSortKey::RowsByColumn(k), SortDirection::Descending)) if k == col_key => {
638                None
639            }
640            _ => Some((
641                PivotSortKey::RowsByColumn(col_key),
642                SortDirection::Ascending,
643            )),
644        };
645        self.resort();
646    }
647
648    /// Cycle row ordering by the row labels. The canonical order is itself
649    /// ascending, so this cycles asc → desc → canonical.
650    pub fn cycle_label_sort(&mut self) {
651        self.sort = match self.sort {
652            Some((PivotSortKey::RowLabel, SortDirection::Ascending)) => {
653                Some((PivotSortKey::RowLabel, SortDirection::Descending))
654            }
655            Some((PivotSortKey::RowLabel, SortDirection::Descending)) => None,
656            _ => Some((PivotSortKey::RowLabel, SortDirection::Ascending)),
657        };
658        self.resort();
659    }
660
661    /// Cycle column ordering by the column labels.
662    pub fn cycle_col_label_sort(&mut self) {
663        self.sort = match self.sort {
664            Some((PivotSortKey::ColLabel, SortDirection::Ascending)) => {
665                Some((PivotSortKey::ColLabel, SortDirection::Descending))
666            }
667            Some((PivotSortKey::ColLabel, SortDirection::Descending)) => None,
668            _ => Some((PivotSortKey::ColLabel, SortDirection::Ascending)),
669        };
670        self.resort();
671    }
672
673    /// Re-apply the active sort to the result trees, then re-flatten.
674    /// Collapse only hides children — sorting is computed over all groups,
675    /// so re-expanding shows children in sorted order.
676    pub(crate) fn resort(&mut self) {
677        let result = Arc::make_mut(&mut self.result);
678        // Always restore the canonical (ascending grouping value) order
679        // first so the active sort applies over a deterministic base.
680        sort_axis_by_key(&mut result.row_nodes, &mut result.row_roots, false);
681        sort_axis_by_key(&mut result.col_nodes, &mut result.col_roots, false);
682        match self.sort {
683            None => {}
684            Some((PivotSortKey::RowLabel, dir)) => {
685                sort_axis_by_key(
686                    &mut result.row_nodes,
687                    &mut result.row_roots,
688                    dir == SortDirection::Descending,
689                );
690            }
691            Some((PivotSortKey::ColLabel, dir)) => {
692                sort_axis_by_key(
693                    &mut result.col_nodes,
694                    &mut result.col_roots,
695                    dir == SortDirection::Descending,
696                );
697            }
698            Some((PivotSortKey::RowsByColumn(col_key), dir)) => {
699                let keys: Vec<CellValue> = (0..result.row_nodes.len())
700                    .map(|id| {
701                        result
702                            .values
703                            .get(&(id, col_key))
704                            .cloned()
705                            .unwrap_or(CellValue::None)
706                    })
707                    .collect();
708                sort_axis_by(
709                    &mut result.row_nodes,
710                    &mut result.row_roots,
711                    &keys,
712                    dir == SortDirection::Descending,
713                );
714            }
715        }
716        self.rebuild_visible();
717    }
718
719    // ------------------------------------------------------------------
720    // Filters-zone popover
721    // ------------------------------------------------------------------
722
723    /// Open (or re-open) the value checklist for a Filters-zone field.
724    /// `anchor` is the window-absolute position the popover opens at.
725    pub fn open_filter_popover(&mut self, field: usize, anchor: Point<Pixels>) {
726        if field >= self.source_columns.len() {
727            return;
728        }
729        let fmt = &self.label_formats[field];
730        let allowed = self.filter_values.get(&field);
731        let mut seen: HashSet<String> = HashSet::new();
732        let mut pairs: Vec<(String, CellValue)> = Vec::new();
733        for row in self.source_rows.iter() {
734            let cell = row.get(field).unwrap_or(&CellValue::None);
735            let label = format_cell(cell, fmt).0;
736            if seen.insert(label.clone()) {
737                pairs.push((label, cell.clone()));
738            }
739        }
740        pairs.sort_by(|(_, a), (_, b)| compare_cells(a, b));
741        let rows = pairs
742            .into_iter()
743            .map(|(label, _)| {
744                let checked = allowed.is_none_or(|set| set.contains(&label));
745                FilterValueRow { label, checked }
746            })
747            .collect();
748        self.filter_popover = Some(PivotFilterPopover {
749            field,
750            rows,
751            anchor,
752        });
753    }
754
755    /// The open Filters-zone popover, if any.
756    #[must_use]
757    pub fn filter_popover(&self) -> Option<&PivotFilterPopover> {
758        self.filter_popover.as_ref()
759    }
760
761    /// Toggle one checklist row and re-apply immediately.
762    pub fn toggle_filter_popover_value(&mut self, index: usize) {
763        if let Some(p) = &mut self.filter_popover {
764            if let Some(row) = p.rows.get_mut(index) {
765                row.checked = !row.checked;
766            }
767        }
768        self.apply_filter_popover();
769    }
770
771    /// Toggle all checklist rows at once and re-apply.
772    pub fn toggle_filter_popover_select_all(&mut self) {
773        if let Some(p) = &mut self.filter_popover {
774            let target = !p.all_checked();
775            for row in &mut p.rows {
776                row.checked = target;
777            }
778        }
779        self.apply_filter_popover();
780    }
781
782    /// Commit the popover's checked set to the active filters and recompute.
783    /// All-checked stores no entry (inert filter).
784    pub fn apply_filter_popover(&mut self) {
785        let Some(p) = &self.filter_popover else {
786            return;
787        };
788        let field = p.field;
789        if p.all_checked() {
790            self.filter_values.remove(&field);
791        } else {
792            self.filter_values.insert(
793                field,
794                p.rows
795                    .iter()
796                    .filter(|r| r.checked)
797                    .map(|r| r.label.clone())
798                    .collect(),
799            );
800        }
801        self.recompute();
802    }
803
804    /// Close the popover (its edits are already applied — auto-apply).
805    pub fn close_filter_popover(&mut self) {
806        self.filter_popover = None;
807    }
808
809    /// Clear the filter on one Filters-zone field.
810    pub fn clear_filter(&mut self, field: usize) {
811        if self.filter_values.remove(&field).is_some() {
812            self.recompute();
813        }
814        if let Some(p) = &mut self.filter_popover {
815            if p.field == field {
816                for row in &mut p.rows {
817                    row.checked = true;
818                }
819            }
820        }
821    }
822
823    /// `true` when the given Filters-zone field has an active (non-inert)
824    /// filter.
825    #[must_use]
826    pub fn filter_active(&self, field: usize) -> bool {
827        self.filter_values.contains_key(&field)
828    }
829
830    // ------------------------------------------------------------------
831    // Per-field format dialog
832    // ------------------------------------------------------------------
833
834    /// The open format dialog, if any.
835    #[must_use]
836    pub fn format_dialog(&self) -> Option<&PivotFormatDialog> {
837        self.format_dialog.as_ref()
838    }
839
840    /// Open the format dialog for `field` as it appears in `zone`. `anchor`
841    /// is the window-absolute position the dialog opens at.
842    pub fn open_format_dialog(&mut self, field: usize, zone: PivotZone, anchor: Point<Pixels>) {
843        if field >= self.source_columns.len() {
844            return;
845        }
846        self.format_dialog = Some(PivotFormatDialog {
847            field,
848            zone,
849            anchor,
850        });
851    }
852
853    /// Close the format dialog (its edits are already applied — auto-apply).
854    pub fn close_format_dialog(&mut self) {
855        self.format_dialog = None;
856    }
857
858    /// The number format the open dialog is editing: the effective value-cell
859    /// format for a Values chip, or the field's effective label format
860    /// otherwise.
861    #[must_use]
862    pub fn format_dialog_format(&self) -> Option<NumberFormat> {
863        let dialog = self.format_dialog.as_ref()?;
864        let fmt = match dialog.zone {
865            PivotZone::Values => &self.value_fmt,
866            _ => self.label_formats.get(dialog.field)?,
867        };
868        // Report the kind-effective alignment (a Text field's labels follow
869        // its string alignment, not the number format's).
870        let mut number = fmt.number;
871        number.alignment = fmt.alignment();
872        Some(number)
873    }
874
875    /// Apply `mutate` to the open dialog's target format (stored as a config
876    /// override) and recompute.
877    pub fn update_format_dialog(&mut self, mutate: impl FnOnce(&mut NumberFormat)) {
878        let Some(dialog) = self.format_dialog else {
879            return;
880        };
881        let Some(mut fmt) = self.format_dialog_format() else {
882            return;
883        };
884        mutate(&mut fmt);
885        match dialog.zone {
886            PivotZone::Values => self.config.value_format = Some(fmt),
887            _ => {
888                self.config.field_formats.insert(dialog.field, fmt);
889            }
890        }
891        self.recompute();
892    }
893
894    /// Drop the open dialog's override, reverting the field to its resolved
895    /// default format.
896    pub fn reset_format_dialog(&mut self) {
897        let Some(dialog) = self.format_dialog else {
898            return;
899        };
900        match dialog.zone {
901            PivotZone::Values => self.config.value_format = None,
902            _ => {
903                self.config.field_formats.remove(&dialog.field);
904            }
905        }
906        self.recompute();
907    }
908
909    // ------------------------------------------------------------------
910    // Right-click menu / drill-through
911    // ------------------------------------------------------------------
912
913    /// Register (or replace) the right-click menu provider. When set, the
914    /// provider fully controls the pivot's context menu; built-in `pivot.*`
915    /// action ids remain handled by the pivot itself.
916    pub fn set_context_menu_provider(
917        &mut self,
918        provider: impl crate::pivot::context_menu::PivotContextMenuProvider + 'static,
919    ) {
920        self.context_menu_provider = Some(PivotContextMenuProviderHandle::new(provider));
921    }
922
923    /// Register (or replace) the save-configuration action. While registered,
924    /// the sidebar renders a save button next to the Layout section that
925    /// invokes the handler with the live [`PivotConfig`].
926    pub fn on_save_config(&mut self, handler: impl Fn(&PivotConfig, &mut App) + 'static) {
927        self.save_config_handler = Some(Rc::new(handler));
928    }
929
930    /// Remove the save-configuration action; the sidebar's save button
931    /// disappears.
932    pub fn clear_save_config_handler(&mut self) {
933        self.save_config_handler = None;
934    }
935
936    /// Whether a save-configuration action is currently registered.
937    #[must_use]
938    pub fn has_save_config_handler(&self) -> bool {
939        self.save_config_handler.is_some()
940    }
941
942    /// The registered save-configuration action, if any.
943    #[must_use]
944    pub fn save_config_handler(&self) -> Option<PivotSaveConfigHandler> {
945        self.save_config_handler.clone()
946    }
947
948    /// The grouping path for a row-axis key ([`TOTAL_KEY`] → empty).
949    #[must_use]
950    pub fn row_path_components(&self, row_key: usize) -> Vec<PivotPathComponent> {
951        path_components(
952            &self.result.row_nodes,
953            &self.config.row_fields,
954            &self.source_columns,
955            &self.config.blank_label,
956            row_key,
957        )
958    }
959
960    /// The grouping path for a column-axis key ([`TOTAL_KEY`] → empty).
961    #[must_use]
962    pub fn col_path_components(&self, col_key: usize) -> Vec<PivotPathComponent> {
963        path_components(
964            &self.result.col_nodes,
965            &self.config.column_fields,
966            &self.source_columns,
967            &self.config.blank_label,
968            col_key,
969        )
970    }
971
972    /// Indices into the source rows that drive the `(row_key, col_key)`
973    /// intersection: rows passing the pivot's source filters whose grouping
974    /// labels match both paths. [`TOTAL_KEY`] on either axis means "no
975    /// constraint on that axis", so totals and subtotals resolve naturally.
976    #[must_use]
977    pub fn source_rows_for(&self, row_key: usize, col_key: usize) -> Vec<usize> {
978        let constraints: Vec<(usize, String)> = self
979            .row_path_components(row_key)
980            .into_iter()
981            .chain(self.col_path_components(col_key))
982            .map(|c| (c.field_index, c.label))
983            .collect();
984        rows_matching_path(
985            &self.source_rows,
986            &self.label_formats,
987            &self.config.blank_label,
988            &self.filtered_source_rows(),
989            &constraints,
990        )
991    }
992
993    /// Per-source-column allowed formatted values that reproduce the
994    /// `(row_key, col_key)` cell's driving rows as flat-grid value filters:
995    /// the pivot's active source filters plus one allow-set per grouping
996    /// path component. Blank groups map to the empty formatted value the
997    /// grid's filter pipeline produces for null cells.
998    #[must_use]
999    pub fn drill_down_filters(
1000        &self,
1001        row_key: usize,
1002        col_key: usize,
1003    ) -> Vec<(usize, HashSet<String>)> {
1004        let mut out: Vec<(usize, HashSet<String>)> = self
1005            .config
1006            .filter_fields
1007            .iter()
1008            .filter_map(|&f| self.filter_values.get(&f).map(|set| (f, set.clone())))
1009            .collect();
1010        for comp in self
1011            .row_path_components(row_key)
1012            .into_iter()
1013            .chain(self.col_path_components(col_key))
1014        {
1015            let set = drill_filter_set(&comp.label, &self.config.blank_label);
1016            if let Some(entry) = out.iter_mut().find(|(f, _)| *f == comp.field_index) {
1017                entry.1 = set;
1018            } else {
1019                out.push((comp.field_index, set));
1020            }
1021        }
1022        out
1023    }
1024
1025    /// Queue a drill-through for the visible cell at `(row, col)`: the host
1026    /// widget applies the resulting filters to the flat grid and switches
1027    /// to the Grid tab. Triggered by double-click and by the built-in
1028    /// "Show source rows in Grid" menu action.
1029    pub fn request_drill_down(&mut self, row: usize, col: usize) {
1030        let (Some(vr), Some(vc)) = (
1031            self.visible_rows.get(row).copied(),
1032            self.visible_cols.get(col).copied(),
1033        ) else {
1034            return;
1035        };
1036        self.pending_drill_down = Some(self.drill_down_filters(vr.key, vc.key));
1037    }
1038
1039    /// Take the queued drill-through, if any. Called by the host widget.
1040    pub(crate) fn take_pending_drill_down(&mut self) -> Option<Vec<(usize, HashSet<String>)>> {
1041        self.pending_drill_down.take()
1042    }
1043
1044    /// Build the [`PivotCellContext`] for a visible cell.
1045    fn cell_context(&self, vr: &VisibleRow, vc: &VisibleCol) -> PivotCellContext {
1046        let value = self.result.value(vr.key, vc.key).clone();
1047        let formatted_value = if matches!(value, CellValue::None) {
1048            String::new()
1049        } else {
1050            format_cell(&value, &self.value_fmt).0
1051        };
1052        PivotCellContext {
1053            row_path: self.row_path_components(vr.key),
1054            col_path: self.col_path_components(vc.key),
1055            value,
1056            formatted_value,
1057            is_row_grand_total: vr.kind == VisibleRowKind::GrandTotal,
1058            is_col_grand_total: vc.kind == VisibleColKind::GrandTotal,
1059            is_row_subtotal: matches!(vr.kind, VisibleRowKind::GroupHeader { .. }),
1060            is_col_subtotal: matches!(
1061                vc.kind,
1062                VisibleColKind::Subtotal | VisibleColKind::Collapsed
1063            ),
1064        }
1065    }
1066
1067    /// Open the right-click menu for a hit-test result at the given
1068    /// grid-relative anchor. Builds the [`PivotContextMenuRequest`] and asks
1069    /// the provider (or the built-in default) for items. Non-interactive
1070    /// hits close any open menu.
1071    pub fn open_context_menu(&mut self, hit: PivotHitResult, anchor: Point<Pixels>) {
1072        self.filter_popover = None;
1073        let resolved: Option<(PivotMenuTarget, usize, usize)> = match hit {
1074            PivotHitResult::Cell { row, col } => {
1075                match (self.visible_rows.get(row), self.visible_cols.get(col)) {
1076                    (Some(vr), Some(vc)) => {
1077                        let ctx = self.cell_context(vr, vc);
1078                        Some((PivotMenuTarget::Cell(ctx), vr.key, vc.key))
1079                    }
1080                    _ => None,
1081                }
1082            }
1083            PivotHitResult::RowHeader { row } | PivotHitResult::RowChevron { row } => {
1084                self.visible_rows.get(row).map(|vr| {
1085                    (
1086                        PivotMenuTarget::RowHeader {
1087                            path: self.row_path_components(vr.key),
1088                            is_grand_total: vr.kind == VisibleRowKind::GrandTotal,
1089                        },
1090                        vr.key,
1091                        TOTAL_KEY,
1092                    )
1093                })
1094            }
1095            PivotHitResult::ColHeader { level: 0, .. } | PivotHitResult::Corner => {
1096                Some((PivotMenuTarget::Corner, TOTAL_KEY, TOTAL_KEY))
1097            }
1098            PivotHitResult::ColHeader { level, col } => {
1099                let depth = level - 1;
1100                let key = self
1101                    .col_ancestor_at(col, depth)
1102                    .or_else(|| self.visible_cols.get(col).map(|vc| vc.key));
1103                key.map(|key| {
1104                    let is_grand = key == TOTAL_KEY
1105                        && self
1106                            .visible_cols
1107                            .get(col)
1108                            .is_some_and(|vc| vc.kind == VisibleColKind::GrandTotal);
1109                    (
1110                        PivotMenuTarget::ColHeader {
1111                            path: self.col_path_components(key),
1112                            is_grand_total: is_grand,
1113                        },
1114                        TOTAL_KEY,
1115                        key,
1116                    )
1117                })
1118            }
1119            PivotHitResult::ColChevron { col, depth } => {
1120                self.col_group_run_at(col, depth).map(|(node, _)| {
1121                    (
1122                        PivotMenuTarget::ColHeader {
1123                            path: self.col_path_components(node),
1124                            is_grand_total: false,
1125                        },
1126                        TOTAL_KEY,
1127                        node,
1128                    )
1129                })
1130            }
1131            PivotHitResult::None
1132            | PivotHitResult::RowBorder { .. }
1133            | PivotHitResult::ColBorder { .. }
1134            | PivotHitResult::RowHeaderBorder
1135            | PivotHitResult::VScrollbar
1136            | PivotHitResult::HScrollbar => None,
1137        };
1138        let Some((target, row_key, col_key)) = resolved else {
1139            self.menu = None;
1140            return;
1141        };
1142
1143        let request = PivotContextMenuRequest {
1144            source_row_indices: self.source_rows_for(row_key, col_key),
1145            target,
1146            aggregation: self.config.aggregation,
1147            value_field_index: self.config.value_field,
1148            value_caption: self.result.value_caption.clone(),
1149            config: self.config.clone(),
1150        };
1151        let items = match &self.context_menu_provider {
1152            Some(provider) => provider.menu_items(&request),
1153            None => PivotMenuItem::standard_items(&request.target),
1154        };
1155        if items.is_empty() {
1156            self.menu = None;
1157            return;
1158        }
1159        let drill = self.drill_down_filters(row_key, col_key);
1160        self.menu = Some(PivotMenu {
1161            anchor,
1162            items,
1163            hovered: None,
1164            request,
1165            drill,
1166        });
1167    }
1168
1169    /// Execute a clicked menu item. Built-in `pivot.*` ids are handled here;
1170    /// everything else is dispatched to the registered provider.
1171    pub(crate) fn execute_menu_action(&mut self, id: &str, menu: PivotMenu, cx: &mut App) {
1172        match id {
1173            PIVOT_ACTION_SHOW_SOURCE_ROWS => {
1174                self.pending_drill_down = Some(menu.drill);
1175            }
1176            PIVOT_ACTION_COPY_VALUE => {
1177                if let Some(cell) = menu.request.clicked_cell() {
1178                    cx.write_to_clipboard(gpui::ClipboardItem::new_string(
1179                        cell.formatted_value.clone(),
1180                    ));
1181                }
1182            }
1183            PIVOT_ACTION_COPY_CSV => {
1184                let csv = self.to_csv();
1185                if !csv.is_empty() {
1186                    cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1187                }
1188            }
1189            _ => {
1190                if let Some(provider) = self.context_menu_provider.clone() {
1191                    provider.on_action(id, &menu.request, self, cx);
1192                }
1193            }
1194        }
1195    }
1196
1197    // ------------------------------------------------------------------
1198    // Labels
1199    // ------------------------------------------------------------------
1200
1201    /// Display label for a visible row.
1202    #[must_use]
1203    pub fn row_label(&self, row: &VisibleRow) -> String {
1204        match row.kind {
1205            VisibleRowKind::GrandTotal => "Grand Total".into(),
1206            _ if row.key == TOTAL_KEY => "Total".into(),
1207            _ => self
1208                .result
1209                .row_nodes
1210                .get(row.key)
1211                .map(|n| n.label.clone())
1212                .unwrap_or_default(),
1213        }
1214    }
1215
1216    /// Display label for a visible column (as shown at the innermost header
1217    /// level).
1218    #[must_use]
1219    pub fn col_label(&self, col: &VisibleCol) -> String {
1220        match col.kind {
1221            VisibleColKind::GrandTotal => "Grand Total".into(),
1222            VisibleColKind::Subtotal | VisibleColKind::Collapsed => self
1223                .result
1224                .col_nodes
1225                .get(col.key)
1226                .map(|n| format!("{} Total", n.label))
1227                .unwrap_or_else(|| "Total".into()),
1228            _ if col.key == TOTAL_KEY => self.result.value_caption.clone(),
1229            _ => self
1230                .result
1231                .col_nodes
1232                .get(col.key)
1233                .map(|n| n.label.clone())
1234                .unwrap_or_default(),
1235        }
1236    }
1237
1238    /// The value cell for a visible (row, col) pair.
1239    #[must_use]
1240    pub fn cell_value(&self, row: &VisibleRow, col: &VisibleCol) -> &CellValue {
1241        self.result.value(row.key, col.key)
1242    }
1243
1244    // ------------------------------------------------------------------
1245    // Geometry / hit testing
1246    // ------------------------------------------------------------------
1247
1248    /// Current height of every pivot data row, in logical pixels.
1249    #[must_use]
1250    pub fn row_height(&self) -> f32 {
1251        self.row_height
1252    }
1253
1254    /// Set the height of every pivot data row.
1255    ///
1256    /// Non-finite values are ignored and finite values are clamped to the
1257    /// minimum supported height.
1258    pub fn set_row_height(&mut self, height: f32) {
1259        if height.is_finite() {
1260            self.row_height = height.max(MIN_PIVOT_ROW_HEIGHT);
1261            self.clamp_scroll_to_bounds();
1262        }
1263    }
1264
1265    /// Current width of every pivot value column, in logical pixels.
1266    #[must_use]
1267    pub fn column_width(&self) -> f32 {
1268        self.value_col_width
1269    }
1270
1271    /// Set the width of every pivot value column.
1272    ///
1273    /// Non-finite values are ignored and finite values are clamped to the
1274    /// minimum supported width.
1275    pub fn set_column_width(&mut self, width: f32) {
1276        if width.is_finite() {
1277            self.value_col_width = width.max(MIN_PIVOT_COLUMN_WIDTH);
1278            self.clamp_scroll_to_bounds();
1279        }
1280    }
1281
1282    /// Current width of the row-label area, in logical pixels. In the flat
1283    /// (tabular) row layout this area is divided evenly among the row-field
1284    /// sub-columns.
1285    #[must_use]
1286    pub fn row_header_area_width(&self) -> f32 {
1287        self.row_header_width
1288    }
1289
1290    /// Set the width of the row-label area (drag its right edge in the UI).
1291    ///
1292    /// Non-finite values are ignored and finite values are clamped to the
1293    /// minimum supported width.
1294    pub fn set_row_header_width(&mut self, width: f32) {
1295        if width.is_finite() {
1296            self.row_header_width = width.max(MIN_PIVOT_ROW_HEADER_WIDTH);
1297            self.clamp_scroll_to_bounds();
1298        }
1299    }
1300
1301    /// Number of stacked header rows: one caption row plus one row per
1302    /// column-field level (minimum one).
1303    #[must_use]
1304    pub fn header_levels(&self) -> usize {
1305        1 + self.result.col_depth.max(1)
1306    }
1307
1308    /// Total header block height in pixels.
1309    #[must_use]
1310    pub fn header_height(&self) -> f32 {
1311        self.header_levels() as f32 * self.header_row_height
1312    }
1313
1314    /// Content size of the data region (all rows × all columns), in pixels.
1315    #[must_use]
1316    pub fn content_size(&self) -> (f32, f32) {
1317        (
1318            self.visible_cols.len() as f32 * self.value_col_width,
1319            self.visible_rows.len() as f32 * self.row_height,
1320        )
1321    }
1322
1323    pub(crate) fn scrollbar_reserved(&self) -> (f32, f32) {
1324        let (cw, ch) = self.content_size();
1325        let vw = f32::from(self.bounds.size.width) - self.row_header_width;
1326        let vh = f32::from(self.bounds.size.height) - self.header_height();
1327        let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
1328        let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
1329        (reserved_w, reserved_h)
1330    }
1331
1332    pub(crate) fn max_scroll(&self) -> (f32, f32) {
1333        let (cw, ch) = self.content_size();
1334        let (rw, rh) = self.scrollbar_reserved();
1335        let vw = f32::from(self.bounds.size.width) - self.row_header_width - rw;
1336        let vh = f32::from(self.bounds.size.height) - self.header_height() - rh;
1337        ((cw - vw).max(0.0), (ch - vh).max(0.0))
1338    }
1339
1340    /// Clamp the scroll offset into the valid range after layout changes.
1341    pub(crate) fn clamp_scroll_to_bounds(&mut self) {
1342        let (mx, my) = self.max_scroll();
1343        let s = self.scroll_handle.offset();
1344        let nx = f32::from(s.x).clamp(0.0, mx);
1345        let ny = f32::from(s.y).clamp(0.0, my);
1346        if nx != f32::from(s.x) || ny != f32::from(s.y) {
1347            self.scroll_handle.set_offset(Point {
1348                x: px(nx),
1349                y: px(ny),
1350            });
1351        }
1352    }
1353
1354    fn clamp_selection(&mut self) {
1355        let (nr, nc) = (self.visible_rows.len(), self.visible_cols.len());
1356        if let Some((r1, c1, r2, c2)) = self.selection {
1357            if nr == 0 || nc == 0 || r1 >= nr || c1 >= nc {
1358                self.selection = None;
1359                self.select_anchor = None;
1360            } else {
1361                self.selection = Some((r1, c1, r2.min(nr - 1), c2.min(nc - 1)));
1362            }
1363        }
1364    }
1365
1366    /// Resolve a grid-relative pointer position.
1367    #[must_use]
1368    pub fn hit_test(&self, pos: Point<Pixels>) -> PivotHitResult {
1369        let x = f32::from(pos.x);
1370        let y = f32::from(pos.y);
1371        let bw = f32::from(self.bounds.size.width);
1372        let bh = f32::from(self.bounds.size.height);
1373        if x < 0.0 || y < 0.0 || x > bw || y > bh {
1374            return PivotHitResult::None;
1375        }
1376        let hdr_h = self.header_height();
1377        let (mx, my) = self.max_scroll();
1378        if my > 0.0 && x >= bw - SCROLLBAR_SIZE && y >= hdr_h {
1379            return PivotHitResult::VScrollbar;
1380        }
1381        if mx > 0.0 && y >= bh - SCROLLBAR_SIZE && x >= self.row_header_width {
1382            return PivotHitResult::HScrollbar;
1383        }
1384        let sx = f32::from(self.scroll_handle.offset().x);
1385        let sy = f32::from(self.scroll_handle.offset().y);
1386
1387        // The row-label area's right edge, draggable along its full height
1388        // (header and body). Checked before the header/body branches so it
1389        // wins over Corner / ColHeader / RowHeader at the boundary.
1390        if (x - self.row_header_width).abs() <= RESIZE_HIT_SLOP {
1391            return PivotHitResult::RowHeaderBorder;
1392        }
1393
1394        if y < hdr_h {
1395            if x < self.row_header_width {
1396                return PivotHitResult::Corner;
1397            }
1398            let level = ((y / self.header_row_height) as usize).min(self.header_levels() - 1);
1399            let cx = x - self.row_header_width + sx;
1400            if cx < 0.0 {
1401                return PivotHitResult::None;
1402            }
1403            let boundary = (cx / self.value_col_width).round() as usize;
1404            if boundary > 0
1405                && boundary <= self.visible_cols.len()
1406                && (cx - boundary as f32 * self.value_col_width).abs() <= RESIZE_HIT_SLOP
1407            {
1408                return PivotHitResult::ColBorder { col: boundary - 1 };
1409            }
1410            let col = (cx / self.value_col_width) as usize;
1411            if col >= self.visible_cols.len() {
1412                return PivotHitResult::None;
1413            }
1414            // Chevron: painted at the start of a group's run, at header
1415            // level `depth + 1`.
1416            if level >= 1 {
1417                let depth = level - 1;
1418                if let Some((_, run_start)) = self.col_group_run_at(col, depth) {
1419                    let run_x0 = run_start as f32 * self.value_col_width;
1420                    let within = cx - run_x0;
1421                    let is_group = self
1422                        .col_ancestor_at(col, depth)
1423                        .map(|n| !self.result.col_nodes[n].is_leaf())
1424                        .unwrap_or(false);
1425                    if is_group && (2.0..=2.0 + CHEVRON_SIZE).contains(&within) {
1426                        return PivotHitResult::ColChevron {
1427                            col: run_start,
1428                            depth,
1429                        };
1430                    }
1431                }
1432            }
1433            return PivotHitResult::ColHeader { level, col };
1434        }
1435
1436        if x < self.row_header_width {
1437            let ry = y - hdr_h + sy;
1438            if ry < 0.0 {
1439                return PivotHitResult::None;
1440            }
1441            let boundary = (ry / self.row_height).round() as usize;
1442            if boundary > 0
1443                && boundary <= self.visible_rows.len()
1444                && (ry - boundary as f32 * self.row_height).abs() <= RESIZE_HIT_SLOP
1445            {
1446                return PivotHitResult::RowBorder { row: boundary - 1 };
1447            }
1448            let row = (ry / self.row_height) as usize;
1449            if row >= self.visible_rows.len() {
1450                return PivotHitResult::None;
1451            }
1452            let vr = self.visible_rows[row];
1453            if matches!(vr.kind, VisibleRowKind::GroupHeader { .. }) {
1454                let indent = vr.depth as f32 * ROW_INDENT + 4.0;
1455                if x >= indent && x <= indent + CHEVRON_SIZE {
1456                    return PivotHitResult::RowChevron { row };
1457                }
1458            }
1459            return PivotHitResult::RowHeader { row };
1460        }
1461
1462        let cx = x - self.row_header_width + sx;
1463        let ry = y - hdr_h + sy;
1464        if cx < 0.0 || ry < 0.0 {
1465            return PivotHitResult::None;
1466        }
1467        let col = (cx / self.value_col_width) as usize;
1468        let row = (ry / self.row_height) as usize;
1469        if row < self.visible_rows.len() && col < self.visible_cols.len() {
1470            PivotHitResult::Cell { row, col }
1471        } else {
1472            PivotHitResult::None
1473        }
1474    }
1475
1476    /// For a visible column and a header depth: the group node shown there
1477    /// plus the first visible column of its contiguous run. `None` when the
1478    /// column has no ancestor at that depth (e.g. the grand-total column).
1479    pub(crate) fn col_group_run_at(&self, col: usize, depth: usize) -> Option<(usize, usize)> {
1480        let node = self.col_ancestor_at(col, depth)?;
1481        let mut start = col;
1482        while start > 0 && self.col_ancestor_at(start - 1, depth) == Some(node) {
1483            start -= 1;
1484        }
1485        Some((node, start))
1486    }
1487
1488    /// The column-axis node displayed at `depth` for visible column `col`.
1489    pub(crate) fn col_ancestor_at(&self, col: usize, depth: usize) -> Option<usize> {
1490        let vc = self.visible_cols.get(col)?;
1491        if vc.key == TOTAL_KEY {
1492            return None;
1493        }
1494        let mut id = vc.key;
1495        let mut d = self.result.col_nodes.get(id)?.depth;
1496        if depth > d {
1497            return None;
1498        }
1499        while d > depth {
1500            id = self.result.col_nodes[id].parent?;
1501            d -= 1;
1502        }
1503        Some(id)
1504    }
1505
1506    // ------------------------------------------------------------------
1507    // Mouse / keyboard behavior (called by the widget)
1508    // ------------------------------------------------------------------
1509
1510    /// Handle a left mouse-down at a grid-relative position.
1511    pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
1512        let hit = self.hit_test(pos);
1513        match hit {
1514            PivotHitResult::VScrollbar => {
1515                self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
1516                self.scroll_to_vbar(f32::from(pos.y));
1517            }
1518            PivotHitResult::HScrollbar => {
1519                self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
1520                self.scroll_to_hbar(f32::from(pos.x));
1521            }
1522            PivotHitResult::RowBorder { row } => {
1523                self.resize_drag = Some(PivotResizeDrag::Row {
1524                    boundary: row + 1,
1525                    start_y: f32::from(pos.y),
1526                    start_height: self.row_height,
1527                });
1528            }
1529            PivotHitResult::ColBorder { col } => {
1530                self.resize_drag = Some(PivotResizeDrag::Column {
1531                    boundary: col + 1,
1532                    start_x: f32::from(pos.x),
1533                    start_width: self.value_col_width,
1534                });
1535            }
1536            PivotHitResult::RowHeaderBorder => {
1537                self.resize_drag = Some(PivotResizeDrag::RowHeader {
1538                    start_x: f32::from(pos.x),
1539                    start_width: self.row_header_width,
1540                });
1541            }
1542            PivotHitResult::RowChevron { row } => {
1543                if let Some(vr) = self.visible_rows.get(row).copied() {
1544                    self.toggle_row_group(vr.key);
1545                }
1546            }
1547            PivotHitResult::ColChevron { col, depth } => {
1548                if let Some((node, _)) = self.col_group_run_at(col, depth) {
1549                    self.toggle_col_group(node);
1550                } else if let Some(vc) = self.visible_cols.get(col) {
1551                    if vc.key != TOTAL_KEY {
1552                        self.toggle_col_group(vc.key);
1553                    }
1554                }
1555            }
1556            PivotHitResult::Corner => self.cycle_label_sort(),
1557            PivotHitResult::ColHeader { level, col } => {
1558                // Innermost header level sorts rows by that column; higher
1559                // group levels toggle the group under the pointer; the
1560                // caption row is inert.
1561                if level == 0 {
1562                    return;
1563                }
1564                if level == self.header_levels() - 1 {
1565                    if let Some(vc) = self.visible_cols.get(col).copied() {
1566                        self.cycle_sort_by_column(vc.key);
1567                    }
1568                } else {
1569                    let depth = level - 1;
1570                    if let Some((node, _)) = self.col_group_run_at(col, depth) {
1571                        if !self.result.col_nodes[node].is_leaf() {
1572                            self.toggle_col_group(node);
1573                        }
1574                    }
1575                }
1576            }
1577            PivotHitResult::RowHeader { row } => {
1578                if self.visible_cols.is_empty() {
1579                    return;
1580                }
1581                let last = self.visible_cols.len() - 1;
1582                self.selection = Some((row, 0, row, last));
1583                self.select_anchor = Some((row, 0));
1584            }
1585            PivotHitResult::Cell { row, col } => {
1586                if shift {
1587                    let (ar, ac) = self.select_anchor.unwrap_or((row, col));
1588                    self.selection = Some(norm_rect(ar, ac, row, col));
1589                } else {
1590                    self.selection = Some((row, col, row, col));
1591                    self.select_anchor = Some((row, col));
1592                    self.is_selecting = true;
1593                }
1594            }
1595            PivotHitResult::None => {
1596                self.selection = None;
1597                self.select_anchor = None;
1598            }
1599        }
1600    }
1601
1602    /// Handle pointer movement (hover, drag-selection, scrollbar drags).
1603    pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, left_down: bool) {
1604        if let Some(drag) = self.resize_drag {
1605            if !left_down {
1606                self.resize_drag = None;
1607            } else {
1608                match drag {
1609                    PivotResizeDrag::Row {
1610                        boundary,
1611                        start_y,
1612                        start_height,
1613                    } => {
1614                        let delta = (f32::from(pos.y) - start_y) / boundary as f32;
1615                        self.set_row_height(start_height + delta);
1616                        self.hover_hit = Some(PivotHitResult::RowBorder { row: boundary - 1 });
1617                    }
1618                    PivotResizeDrag::Column {
1619                        boundary,
1620                        start_x,
1621                        start_width,
1622                    } => {
1623                        let delta = (f32::from(pos.x) - start_x) / boundary as f32;
1624                        self.set_column_width(start_width + delta);
1625                        self.hover_hit = Some(PivotHitResult::ColBorder { col: boundary - 1 });
1626                    }
1627                    PivotResizeDrag::RowHeader {
1628                        start_x,
1629                        start_width,
1630                    } => {
1631                        let delta = f32::from(pos.x) - start_x;
1632                        self.set_row_header_width(start_width + delta);
1633                        self.hover_hit = Some(PivotHitResult::RowHeaderBorder);
1634                    }
1635                }
1636                return;
1637            }
1638        }
1639        if let Some(axis) = self.scrollbar_drag {
1640            if left_down {
1641                match axis {
1642                    ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
1643                    ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
1644                }
1645                return;
1646            }
1647            self.scrollbar_drag = None;
1648        }
1649        let hit = self.hit_test(pos);
1650        self.hover_hit = Some(hit);
1651        if self.is_selecting && left_down {
1652            if let PivotHitResult::Cell { row, col } = hit {
1653                if let Some((ar, ac)) = self.select_anchor {
1654                    self.selection = Some(norm_rect(ar, ac, row, col));
1655                }
1656            }
1657        } else if self.is_selecting {
1658            self.is_selecting = false;
1659        }
1660    }
1661
1662    /// Handle mouse-up: end any drag.
1663    pub fn handle_mouse_up(&mut self) {
1664        self.is_selecting = false;
1665        self.scrollbar_drag = None;
1666        self.resize_drag = None;
1667    }
1668
1669    /// Apply a scroll-wheel delta, clamped to the content.
1670    pub fn apply_scroll_delta(&mut self, dx: f32, dy: f32) {
1671        let (mx, my) = self.max_scroll();
1672        let s = self.scroll_handle.offset();
1673        let nx = (f32::from(s.x) - dx).clamp(0.0, mx);
1674        let ny = (f32::from(s.y) - dy).clamp(0.0, my);
1675        self.scroll_handle.set_offset(Point {
1676            x: px(nx),
1677            y: px(ny),
1678        });
1679    }
1680
1681    pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
1682        let (_, my) = self.max_scroll();
1683        let hdr = self.header_height();
1684        let (_, rh) = self.scrollbar_reserved();
1685        let track_h = f32::from(self.bounds.size.height) - hdr - rh;
1686        let (_, ch) = self.content_size();
1687        if track_h <= 0.0 || ch <= 0.0 {
1688            return;
1689        }
1690        let thumb_h = ((track_h * (track_h / ch)).max(20.0)).min(track_h);
1691        let range = (track_h - thumb_h).max(0.0);
1692        let rel = (mouse_y - hdr - thumb_h * 0.5).clamp(0.0, range);
1693        let frac = if range > 0.0 { rel / range } else { 0.0 };
1694        let x = self.scroll_handle.offset().x;
1695        self.scroll_handle.set_offset(Point {
1696            x,
1697            y: px(frac * my),
1698        });
1699    }
1700
1701    pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
1702        let (mx, _) = self.max_scroll();
1703        let (rw, _) = self.scrollbar_reserved();
1704        let track_x = self.row_header_width;
1705        let track_w = f32::from(self.bounds.size.width) - self.row_header_width - rw;
1706        let (cw, _) = self.content_size();
1707        if track_w <= 0.0 || cw <= 0.0 {
1708            return;
1709        }
1710        let thumb_w = ((track_w * (track_w / cw)).max(20.0)).min(track_w);
1711        let range = (track_w - thumb_w).max(0.0);
1712        let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
1713        let frac = if range > 0.0 { rel / range } else { 0.0 };
1714        let y = self.scroll_handle.offset().y;
1715        self.scroll_handle.set_offset(Point {
1716            x: px(frac * mx),
1717            y,
1718        });
1719    }
1720
1721    /// Handle a keystroke: copy bindings, escape, arrow-key selection moves.
1722    pub fn handle_key(&mut self, ks: &gpui::Keystroke, cx: &mut App) {
1723        if self.key_bindings.copy.matches(ks) {
1724            self.copy_selection(false, cx);
1725            return;
1726        }
1727        if self.key_bindings.copy_with_headers.matches(ks) {
1728            self.copy_selection(true, cx);
1729            return;
1730        }
1731        match ks.key.as_str() {
1732            "escape" => {
1733                self.selection = None;
1734                self.select_anchor = None;
1735                self.filter_popover = None;
1736                self.menu = None;
1737            }
1738            "up" => self.move_selection(0, -1),
1739            "down" => self.move_selection(0, 1),
1740            "left" => self.move_selection(-1, 0),
1741            "right" => self.move_selection(1, 0),
1742            _ => {}
1743        }
1744    }
1745
1746    fn move_selection(&mut self, dx: i32, dy: i32) {
1747        let nr = self.visible_rows.len() as i32;
1748        let nc = self.visible_cols.len() as i32;
1749        if nr == 0 || nc == 0 {
1750            return;
1751        }
1752        let (r, c) = match self.selection {
1753            Some((r1, c1, ..)) => (r1 as i32, c1 as i32),
1754            None => (0, 0),
1755        };
1756        let r = (r + dy).clamp(0, nr - 1) as usize;
1757        let c = (c + dx).clamp(0, nc - 1) as usize;
1758        self.selection = Some((r, c, r, c));
1759        self.select_anchor = Some((r, c));
1760    }
1761
1762    // ------------------------------------------------------------------
1763    // Copy / export
1764    // ------------------------------------------------------------------
1765
1766    /// Copy the selected rectangle (or the whole visible pivot when nothing
1767    /// is selected) to the clipboard as TSV.
1768    pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
1769        let text = self.selection_text(with_headers, '\t');
1770        if !text.is_empty() {
1771            cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
1772        }
1773    }
1774
1775    /// Build the copy text for the current selection (whole pivot if none),
1776    /// using `sep` between fields.
1777    #[must_use]
1778    pub fn selection_text(&self, with_headers: bool, sep: char) -> String {
1779        use std::fmt::Write as _;
1780        if self.visible_rows.is_empty() || self.visible_cols.is_empty() {
1781            return String::new();
1782        }
1783        let (r1, c1, r2, c2) = self.selection.unwrap_or((
1784            0,
1785            0,
1786            self.visible_rows.len() - 1,
1787            self.visible_cols.len() - 1,
1788        ));
1789        let mut out = String::new();
1790        if with_headers {
1791            for c in c1..=c2 {
1792                out.push(sep);
1793                out.push_str(&self.col_label(&self.visible_cols[c]));
1794            }
1795            out.push('\n');
1796        }
1797        for r in r1..=r2 {
1798            let vr = &self.visible_rows[r];
1799            if with_headers {
1800                let _ = write!(out, "{}{}", "  ".repeat(vr.depth), self.row_label(vr));
1801                out.push(sep);
1802            }
1803            for c in c1..=c2 {
1804                if c > c1 {
1805                    out.push(sep);
1806                }
1807                let cell = self.cell_value(vr, &self.visible_cols[c]);
1808                if !matches!(cell, CellValue::None) {
1809                    out.push_str(&format_cell(cell, &self.value_fmt).0);
1810                }
1811            }
1812            out.push('\n');
1813        }
1814        out
1815    }
1816
1817    /// Export the **fully expanded** pivot as CSV: one column per row field
1818    /// (hierarchical labels), one column per leaf column, plus totals as
1819    /// configured. Ignores the current collapse state.
1820    #[must_use]
1821    pub fn to_csv(&self) -> String {
1822        let res = &self.result;
1823        let mut out = String::new();
1824        let col_leaves = res.col_leaves();
1825        let want_grand_col = self.config.show_column_grand_total && !col_leaves.is_empty();
1826
1827        let mut header: Vec<String> = if res.row_depth == 0 {
1828            vec![String::new()]
1829        } else {
1830            res.row_field_names.clone()
1831        };
1832        if col_leaves.is_empty() {
1833            header.push(res.value_caption.clone());
1834        } else {
1835            for &leaf in &col_leaves {
1836                header.push(node_path(&res.col_nodes, leaf).join(" / "));
1837            }
1838        }
1839        if want_grand_col {
1840            header.push("Grand Total".into());
1841        }
1842        push_csv_line(&mut out, &header);
1843
1844        let value_cols: Vec<usize> = if col_leaves.is_empty() {
1845            vec![TOTAL_KEY]
1846        } else {
1847            col_leaves
1848        };
1849        let fmt_value = |cell: &CellValue| {
1850            if matches!(cell, CellValue::None) {
1851                String::new()
1852            } else {
1853                format_cell(cell, &self.value_fmt).0
1854            }
1855        };
1856        let emit_line = |out: &mut String, labels: Vec<String>, row_key: usize| {
1857            let mut fields = labels;
1858            for &ck in &value_cols {
1859                fields.push(fmt_value(res.value(row_key, ck)));
1860            }
1861            if want_grand_col {
1862                fields.push(fmt_value(res.value(row_key, TOTAL_KEY)));
1863            }
1864            push_csv_line(out, &fields);
1865        };
1866
1867        let row_leaves = res.row_leaves();
1868        if row_leaves.is_empty() && res.row_depth == 0 && !res.values.is_empty() {
1869            emit_line(&mut out, vec!["Total".into()], TOTAL_KEY);
1870        }
1871        for &leaf in &row_leaves {
1872            let mut labels = node_path(&res.row_nodes, leaf);
1873            while labels.len() < res.row_depth {
1874                labels.push(String::new());
1875            }
1876            emit_line(&mut out, labels, leaf);
1877        }
1878        if self.config.show_row_grand_total && !row_leaves.is_empty() {
1879            let mut labels = vec!["Grand Total".to_owned()];
1880            while labels.len() < res.row_depth.max(1) {
1881                labels.push(String::new());
1882            }
1883            emit_line(&mut out, labels, TOTAL_KEY);
1884        }
1885        out
1886    }
1887}
1888
1889fn default_value_format() -> ResolvedColumnFormat {
1890    crate::config::GridConfig::default().resolve(usize::MAX, ColumnKind::Decimal)
1891}
1892
1893fn norm_rect(r1: usize, c1: usize, r2: usize, c2: usize) -> (usize, usize, usize, usize) {
1894    (r1.min(r2), c1.min(c2), r1.max(r2), c1.max(c2))
1895}
1896
1897fn push_csv_line(out: &mut String, fields: &[String]) {
1898    for (i, f) in fields.iter().enumerate() {
1899        if i > 0 {
1900            out.push(',');
1901        }
1902        if f.contains(',') || f.contains('"') || f.contains('\n') {
1903            out.push('"');
1904            out.push_str(&f.replace('"', "\"\""));
1905            out.push('"');
1906        } else {
1907            out.push_str(f);
1908        }
1909    }
1910    out.push('\n');
1911}
1912
1913/// The label path from the root down to `node`, e.g. `["Europe", "Widget"]`.
1914pub(crate) fn node_path(nodes: &[PivotNode], node: usize) -> Vec<String> {
1915    let mut path = Vec::new();
1916    let mut current = Some(node);
1917    while let Some(id) = current {
1918        path.push(nodes[id].label.clone());
1919        current = nodes[id].parent;
1920    }
1921    path.reverse();
1922    path
1923}
1924
1925/// The full grouping path (root → `key`) as public path components.
1926/// [`TOTAL_KEY`] and out-of-range keys yield an empty path.
1927pub(crate) fn path_components(
1928    nodes: &[PivotNode],
1929    fields: &[usize],
1930    columns: &[Column],
1931    blank_label: &str,
1932    key: usize,
1933) -> Vec<PivotPathComponent> {
1934    if key == TOTAL_KEY || key >= nodes.len() {
1935        return Vec::new();
1936    }
1937    let mut chain = Vec::new();
1938    let mut current = Some(key);
1939    while let Some(id) = current {
1940        chain.push(id);
1941        current = nodes[id].parent;
1942    }
1943    chain.reverse();
1944    chain
1945        .into_iter()
1946        .map(|id| {
1947            let node = &nodes[id];
1948            let field_index = fields.get(node.depth).copied().unwrap_or(usize::MAX);
1949            PivotPathComponent {
1950                field_index,
1951                field_name: columns
1952                    .get(field_index)
1953                    .map(|c| c.name.clone())
1954                    .unwrap_or_default(),
1955                label: node.label.clone(),
1956                group_value: node.sort_key.clone(),
1957                is_blank: node.label == blank_label,
1958            }
1959        })
1960        .collect()
1961}
1962
1963/// Filter `base` down to the rows whose grouping labels satisfy every
1964/// `(field, label)` constraint, using the same labeling rule as the engine
1965/// (null cells take `blank_label`, everything else its formatted value).
1966pub(crate) fn rows_matching_path(
1967    rows: &[Vec<CellValue>],
1968    formats: &[ResolvedColumnFormat],
1969    blank_label: &str,
1970    base: &[usize],
1971    constraints: &[(usize, String)],
1972) -> Vec<usize> {
1973    if constraints.is_empty() {
1974        return base.to_vec();
1975    }
1976    base.iter()
1977        .copied()
1978        .filter(|&r| {
1979            constraints.iter().all(|(field, label)| {
1980                let cell = rows
1981                    .get(r)
1982                    .and_then(|row| row.get(*field))
1983                    .unwrap_or(&CellValue::None);
1984                let cell_label = if matches!(cell, CellValue::None) {
1985                    blank_label.to_owned()
1986                } else {
1987                    format_cell(cell, &formats[*field]).0
1988                };
1989                cell_label == *label
1990            })
1991        })
1992        .collect()
1993}
1994
1995/// The flat-grid filter allow-set that selects a pivot group's rows. Blank
1996/// groups match the empty formatted value the grid produces for null cells
1997/// (plus the literal blank label, since the engine merges both into one
1998/// group).
1999pub(crate) fn drill_filter_set(label: &str, blank_label: &str) -> HashSet<String> {
2000    if label == blank_label {
2001        HashSet::from([String::new(), label.to_owned()])
2002    } else {
2003        HashSet::from([label.to_owned()])
2004    }
2005}
2006
2007/// Paths of every non-leaf node (used by collapse-all).
2008fn all_group_paths(nodes: &[PivotNode]) -> HashSet<Vec<String>> {
2009    nodes
2010        .iter()
2011        .enumerate()
2012        .filter(|(_, n)| !n.is_leaf())
2013        .map(|(id, _)| node_path(nodes, id))
2014        .collect()
2015}
2016
2017/// Flatten the row tree into display rows, honoring the collapse set.
2018pub(crate) fn flatten_rows(
2019    result: &PivotResult,
2020    collapsed: &HashSet<Vec<String>>,
2021    config: &PivotConfig,
2022) -> Vec<VisibleRow> {
2023    let mut out = Vec::new();
2024    if result.row_depth == 0 {
2025        // No row fields: a single total row (when there is anything to show).
2026        if !result.values.is_empty() {
2027            out.push(VisibleRow {
2028                key: TOTAL_KEY,
2029                depth: 0,
2030                kind: VisibleRowKind::Leaf,
2031                zebra: false,
2032            });
2033        }
2034        return out;
2035    }
2036    if config.flat_rows {
2037        // Flat/tabular layout: one row per innermost leaf combination, in the
2038        // current sort order (`row_leaves` walks the already-resorted tree),
2039        // with no group-header rows, indentation, or subtotals. Each field's
2040        // value is painted in its own row-header column (see `paint.rs`).
2041        // Zebra alternates across the whole flat list rather than resetting
2042        // per group.
2043        let mut zebra = false;
2044        for leaf in result.row_leaves() {
2045            out.push(VisibleRow {
2046                key: leaf,
2047                depth: result.row_nodes[leaf].depth,
2048                kind: VisibleRowKind::Leaf,
2049                zebra,
2050            });
2051            zebra = !zebra;
2052        }
2053        if config.show_row_grand_total && !out.is_empty() {
2054            out.push(VisibleRow {
2055                key: TOTAL_KEY,
2056                depth: 0,
2057                kind: VisibleRowKind::GrandTotal,
2058                zebra: false,
2059            });
2060        }
2061        return out;
2062    }
2063    fn walk(
2064        result: &PivotResult,
2065        collapsed: &HashSet<Vec<String>>,
2066        id: usize,
2067        path: &mut Vec<String>,
2068        zebra: &mut bool,
2069        out: &mut Vec<VisibleRow>,
2070    ) {
2071        let node = &result.row_nodes[id];
2072        path.push(node.label.clone());
2073        if node.is_leaf() {
2074            out.push(VisibleRow {
2075                key: id,
2076                depth: node.depth,
2077                kind: VisibleRowKind::Leaf,
2078                zebra: *zebra,
2079            });
2080            *zebra = !*zebra;
2081        } else if collapsed.contains(path) {
2082            *zebra = false;
2083            out.push(VisibleRow {
2084                key: id,
2085                depth: node.depth,
2086                kind: VisibleRowKind::GroupHeader { expanded: false },
2087                zebra: false,
2088            });
2089        } else {
2090            // Striping restarts inside every group.
2091            *zebra = false;
2092            out.push(VisibleRow {
2093                key: id,
2094                depth: node.depth,
2095                kind: VisibleRowKind::GroupHeader { expanded: true },
2096                zebra: false,
2097            });
2098            for &child in &node.children {
2099                walk(result, collapsed, child, path, zebra, out);
2100            }
2101            *zebra = false;
2102        }
2103        path.pop();
2104    }
2105    let mut path = Vec::new();
2106    let mut zebra = false;
2107    for &root in &result.row_roots {
2108        walk(result, collapsed, root, &mut path, &mut zebra, &mut out);
2109    }
2110    if config.show_row_grand_total && !out.is_empty() {
2111        out.push(VisibleRow {
2112            key: TOTAL_KEY,
2113            depth: 0,
2114            kind: VisibleRowKind::GrandTotal,
2115            zebra: false,
2116        });
2117    }
2118    out
2119}
2120
2121/// Flatten the column tree into display columns, honoring the collapse set.
2122pub(crate) fn flatten_cols(
2123    result: &PivotResult,
2124    collapsed: &HashSet<Vec<String>>,
2125    config: &PivotConfig,
2126) -> Vec<VisibleCol> {
2127    let mut out = Vec::new();
2128    if result.col_depth == 0 {
2129        if !result.values.is_empty() {
2130            out.push(VisibleCol {
2131                key: TOTAL_KEY,
2132                depth: 0,
2133                kind: VisibleColKind::Leaf,
2134            });
2135        }
2136        return out;
2137    }
2138    fn walk(
2139        result: &PivotResult,
2140        collapsed: &HashSet<Vec<String>>,
2141        config: &PivotConfig,
2142        id: usize,
2143        path: &mut Vec<String>,
2144        out: &mut Vec<VisibleCol>,
2145    ) {
2146        let node = &result.col_nodes[id];
2147        path.push(node.label.clone());
2148        if node.is_leaf() {
2149            out.push(VisibleCol {
2150                key: id,
2151                depth: node.depth,
2152                kind: VisibleColKind::Leaf,
2153            });
2154        } else if collapsed.contains(path) {
2155            out.push(VisibleCol {
2156                key: id,
2157                depth: node.depth,
2158                kind: VisibleColKind::Collapsed,
2159            });
2160        } else {
2161            for &child in &node.children {
2162                walk(result, collapsed, config, child, path, out);
2163            }
2164            if config.show_column_subtotals {
2165                out.push(VisibleCol {
2166                    key: id,
2167                    depth: node.depth,
2168                    kind: VisibleColKind::Subtotal,
2169                });
2170            }
2171        }
2172        path.pop();
2173    }
2174    let mut path = Vec::new();
2175    for &root in &result.col_roots {
2176        walk(result, collapsed, config, root, &mut path, &mut out);
2177    }
2178    if config.show_column_grand_total && !out.is_empty() {
2179        out.push(VisibleCol {
2180            key: TOTAL_KEY,
2181            depth: 0,
2182            kind: VisibleColKind::GrandTotal,
2183        });
2184    }
2185    out
2186}
2187
2188/// Sort both roots and every sibling list by the nodes' grouping value.
2189fn sort_axis_by_key(nodes: &mut [PivotNode], roots: &mut [usize], descending: bool) {
2190    let keys: Vec<CellValue> = nodes.iter().map(|n| n.sort_key.clone()).collect();
2191    sort_axis_by(nodes, roots, &keys, descending);
2192}
2193
2194/// Sort both roots and every sibling list by a per-node key vector.
2195fn sort_axis_by(
2196    nodes: &mut [PivotNode],
2197    roots: &mut [usize],
2198    keys: &[CellValue],
2199    descending: bool,
2200) {
2201    let cmp = |a: &usize, b: &usize| {
2202        let ord = compare_cells(&keys[*a], &keys[*b]);
2203        if descending {
2204            ord.reverse()
2205        } else {
2206            ord
2207        }
2208    };
2209    roots.sort_by(cmp);
2210    for node in nodes.iter_mut() {
2211        let mut children = std::mem::take(&mut node.children);
2212        children.sort_by(cmp);
2213        node.children = children;
2214    }
2215}
2216
2217#[cfg(test)]
2218#[allow(clippy::unwrap_used)]
2219mod tests {
2220    use super::*;
2221    use crate::config::GridConfig;
2222    use crate::data::ColumnKind;
2223    use CellValue::{Decimal, Integer, Text};
2224
2225    fn fixture_result(config: &PivotConfig) -> PivotResult {
2226        let columns = vec![
2227            Column::new("region", ColumnKind::Text, 100.0),
2228            Column::new("product", ColumnKind::Text, 100.0),
2229            Column::new("year", ColumnKind::Integer, 80.0),
2230            Column::new("amount", ColumnKind::Decimal, 100.0),
2231        ];
2232        let r = |region: &str, product: &str, year: i64, amount: f64| {
2233            vec![
2234                Text(region.into()),
2235                Text(product.into()),
2236                Integer(year),
2237                Decimal(amount),
2238            ]
2239        };
2240        let rows = vec![
2241            r("Europe", "Widget", 2023, 10.0),
2242            r("Europe", "Widget", 2024, 20.0),
2243            r("Europe", "Gadget", 2023, 5.0),
2244            r("Asia", "Widget", 2023, 7.0),
2245            r("Asia", "Gadget", 2024, 3.0),
2246        ];
2247        let formats = GridConfig::default().resolve_all(&columns);
2248        let all: Vec<usize> = (0..rows.len()).collect();
2249        compute_pivot(&columns, &rows, &all, config, &formats)
2250    }
2251
2252    fn two_level_config() -> PivotConfig {
2253        PivotConfig {
2254            row_fields: vec![0, 1],
2255            column_fields: vec![2],
2256            value_field: Some(3),
2257            ..PivotConfig::default()
2258        }
2259    }
2260
2261    #[test]
2262    fn flatten_rows_expanded_lists_headers_and_leaves() {
2263        let cfg = two_level_config();
2264        let result = fixture_result(&cfg);
2265        let rows = flatten_rows(&result, &HashSet::new(), &cfg);
2266        // Asia(hdr) Gadget Widget Europe(hdr) Gadget Widget GrandTotal
2267        assert_eq!(rows.len(), 7);
2268        assert_eq!(rows[0].kind, VisibleRowKind::GroupHeader { expanded: true });
2269        assert_eq!(rows[0].depth, 0);
2270        assert_eq!(rows[1].kind, VisibleRowKind::Leaf);
2271        assert_eq!(rows[1].depth, 1);
2272        assert_eq!(rows[6].kind, VisibleRowKind::GrandTotal);
2273    }
2274
2275    #[test]
2276    fn flatten_rows_collapse_hides_children() {
2277        let cfg = two_level_config();
2278        let result = fixture_result(&cfg);
2279        let mut collapsed = HashSet::new();
2280        collapsed.insert(vec!["Asia".to_owned()]);
2281        let rows = flatten_rows(&result, &collapsed, &cfg);
2282        // Asia(collapsed) Europe(hdr) Gadget Widget GrandTotal
2283        assert_eq!(rows.len(), 5);
2284        assert_eq!(
2285            rows[0].kind,
2286            VisibleRowKind::GroupHeader { expanded: false }
2287        );
2288        assert_eq!(rows[1].kind, VisibleRowKind::GroupHeader { expanded: true });
2289    }
2290
2291    #[test]
2292    fn flatten_rows_without_grand_total() {
2293        let mut cfg = two_level_config();
2294        cfg.show_row_grand_total = false;
2295        let result = fixture_result(&cfg);
2296        let rows = flatten_rows(&result, &HashSet::new(), &cfg);
2297        assert!(rows.iter().all(|r| r.kind != VisibleRowKind::GrandTotal));
2298    }
2299
2300    #[test]
2301    fn flatten_rows_flat_lists_leaf_combinations_without_hierarchy() {
2302        let mut cfg = two_level_config();
2303        cfg.flat_rows = true;
2304        let result = fixture_result(&cfg);
2305        let rows = flatten_rows(&result, &HashSet::new(), &cfg);
2306        // 4 distinct (region, product) leaves + grand total; no group headers.
2307        assert_eq!(rows.len(), 5);
2308        assert!(rows[..4].iter().all(|r| r.kind == VisibleRowKind::Leaf));
2309        assert!(rows
2310            .iter()
2311            .all(|r| !matches!(r.kind, VisibleRowKind::GroupHeader { .. })));
2312        assert_eq!(rows[4].kind, VisibleRowKind::GrandTotal);
2313        // Each leaf carries the full path (both row fields) for its tabular
2314        // row-header columns, in depth-first (sorted) order.
2315        let paths: Vec<Vec<String>> = rows[..4]
2316            .iter()
2317            .map(|r| node_path(&result.row_nodes, r.key))
2318            .collect();
2319        assert!(paths.iter().all(|p| p.len() == 2));
2320        assert_eq!(paths[0], vec!["Asia".to_owned(), "Gadget".to_owned()]);
2321        assert_eq!(paths[3], vec!["Europe".to_owned(), "Widget".to_owned()]);
2322    }
2323
2324    #[test]
2325    fn flatten_rows_flat_respects_grand_total_toggle() {
2326        let mut cfg = two_level_config();
2327        cfg.flat_rows = true;
2328        cfg.show_row_grand_total = false;
2329        let result = fixture_result(&cfg);
2330        let rows = flatten_rows(&result, &HashSet::new(), &cfg);
2331        assert_eq!(rows.len(), 4);
2332        assert!(rows.iter().all(|r| r.kind == VisibleRowKind::Leaf));
2333    }
2334
2335    #[test]
2336    fn flatten_cols_leaves_plus_grand_total() {
2337        let cfg = two_level_config();
2338        let result = fixture_result(&cfg);
2339        let cols = flatten_cols(&result, &HashSet::new(), &cfg);
2340        // 2023, 2024, Grand Total
2341        assert_eq!(cols.len(), 3);
2342        assert_eq!(cols[0].kind, VisibleColKind::Leaf);
2343        assert_eq!(cols[2].kind, VisibleColKind::GrandTotal);
2344    }
2345
2346    #[test]
2347    fn flatten_cols_collapsed_group_is_single_column() {
2348        let mut cfg = two_level_config();
2349        // Two column levels: year then product.
2350        cfg.row_fields = vec![0];
2351        cfg.column_fields = vec![2, 1];
2352        let result = fixture_result(&cfg);
2353        // Group labels follow the resolved column format, so derive the
2354        // collapse path from the first year group rather than hardcoding it.
2355        let first_year = result.col_roots[0];
2356        let mut collapsed = HashSet::new();
2357        collapsed.insert(vec![result.col_nodes[first_year].label.clone()]);
2358        let cols = flatten_cols(&result, &collapsed, &cfg);
2359        // 2023 collapsed → 1 col; 2024 expanded → its product leaves; + grand.
2360        assert_eq!(cols[0].kind, VisibleColKind::Collapsed);
2361        assert!(cols.len() >= 3);
2362    }
2363
2364    #[test]
2365    fn flatten_cols_subtotal_columns_follow_expanded_groups() {
2366        let mut cfg = two_level_config();
2367        cfg.row_fields = vec![0];
2368        cfg.column_fields = vec![2, 1];
2369        cfg.show_column_subtotals = true;
2370        let result = fixture_result(&cfg);
2371        let cols = flatten_cols(&result, &HashSet::new(), &cfg);
2372        let subtotal_count = cols
2373            .iter()
2374            .filter(|c| c.kind == VisibleColKind::Subtotal)
2375            .count();
2376        assert_eq!(subtotal_count, 2); // one per year group
2377                                       // Each subtotal column directly follows its group's leaves.
2378        let first_sub = cols
2379            .iter()
2380            .position(|c| c.kind == VisibleColKind::Subtotal)
2381            .unwrap();
2382        assert!(first_sub > 0);
2383        assert_eq!(cols[first_sub - 1].kind, VisibleColKind::Leaf);
2384    }
2385
2386    #[test]
2387    fn flatten_cols_no_column_fields_yields_single_value_column() {
2388        let mut cfg = two_level_config();
2389        cfg.column_fields = vec![];
2390        let result = fixture_result(&cfg);
2391        let cols = flatten_cols(&result, &HashSet::new(), &cfg);
2392        assert_eq!(cols.len(), 1);
2393        assert_eq!(cols[0].key, TOTAL_KEY);
2394    }
2395
2396    #[test]
2397    fn flatten_empty_result_is_empty() {
2398        let cfg = PivotConfig::default();
2399        let result = PivotResult::default();
2400        assert!(flatten_rows(&result, &HashSet::new(), &cfg).is_empty());
2401        assert!(flatten_cols(&result, &HashSet::new(), &cfg).is_empty());
2402    }
2403
2404    #[test]
2405    fn sort_axis_descending_reverses_roots_and_children() {
2406        let cfg = two_level_config();
2407        let mut result = fixture_result(&cfg);
2408        let labels = |result: &PivotResult| -> Vec<String> {
2409            result
2410                .row_roots
2411                .iter()
2412                .map(|&r| result.row_nodes[r].label.clone())
2413                .collect()
2414        };
2415        assert_eq!(labels(&result), vec!["Asia", "Europe"]);
2416        let mut roots = result.row_roots.clone();
2417        sort_axis_by_key(&mut result.row_nodes, &mut roots, true);
2418        result.row_roots = roots;
2419        assert_eq!(labels(&result), vec!["Europe", "Asia"]);
2420        let asia = result.row_roots[1];
2421        let child_labels: Vec<&str> = result.row_nodes[asia]
2422            .children
2423            .iter()
2424            .map(|&c| result.row_nodes[c].label.as_str())
2425            .collect();
2426        assert_eq!(child_labels, vec!["Widget", "Gadget"]);
2427    }
2428
2429    #[test]
2430    fn sort_axis_by_values_orders_missing_first_ascending() {
2431        let cfg = two_level_config();
2432        let mut result = fixture_result(&cfg);
2433        // Sort roots by their 2024 column: Europe=20, Asia=3.
2434        let y2024 = result
2435            .col_roots
2436            .iter()
2437            .copied()
2438            .find(|&c| result.col_nodes[c].sort_key == Integer(2024))
2439            .unwrap();
2440        let keys: Vec<CellValue> = (0..result.row_nodes.len())
2441            .map(|id| {
2442                result
2443                    .values
2444                    .get(&(id, y2024))
2445                    .cloned()
2446                    .unwrap_or(CellValue::None)
2447            })
2448            .collect();
2449        let mut roots = result.row_roots.clone();
2450        sort_axis_by(&mut result.row_nodes, &mut roots, &keys, false);
2451        let labels: Vec<&str> = roots
2452            .iter()
2453            .map(|&r| result.row_nodes[r].label.as_str())
2454            .collect();
2455        assert_eq!(labels, vec!["Asia", "Europe"]);
2456        sort_axis_by(&mut result.row_nodes, &mut roots, &keys, true);
2457        let labels: Vec<&str> = roots
2458            .iter()
2459            .map(|&r| result.row_nodes[r].label.as_str())
2460            .collect();
2461        assert_eq!(labels, vec!["Europe", "Asia"]);
2462    }
2463
2464    #[test]
2465    fn node_path_walks_to_root() {
2466        let cfg = two_level_config();
2467        let result = fixture_result(&cfg);
2468        let europe = result
2469            .row_nodes
2470            .iter()
2471            .position(|n| n.label == "Europe")
2472            .unwrap();
2473        let widget = result.row_nodes[europe]
2474            .children
2475            .iter()
2476            .copied()
2477            .find(|&c| result.row_nodes[c].label == "Widget")
2478            .unwrap();
2479        assert_eq!(
2480            node_path(&result.row_nodes, widget),
2481            vec!["Europe".to_owned(), "Widget".to_owned()]
2482        );
2483    }
2484
2485    #[test]
2486    fn all_group_paths_lists_only_non_leaves() {
2487        let cfg = two_level_config();
2488        let result = fixture_result(&cfg);
2489        let paths = all_group_paths(&result.row_nodes);
2490        assert_eq!(paths.len(), 2); // Asia, Europe
2491        assert!(paths.contains(&vec!["Asia".to_owned()]));
2492    }
2493
2494    #[test]
2495    fn csv_line_quotes_fields_with_commas_and_quotes() {
2496        let mut out = String::new();
2497        push_csv_line(
2498            &mut out,
2499            &["a,b".to_owned(), "plain".to_owned(), "q\"q".to_owned()],
2500        );
2501        assert_eq!(out, "\"a,b\",plain,\"q\"\"q\"\n");
2502    }
2503
2504    fn fixture_columns() -> Vec<Column> {
2505        vec![
2506            Column::new("region", ColumnKind::Text, 100.0),
2507            Column::new("product", ColumnKind::Text, 100.0),
2508            Column::new("year", ColumnKind::Integer, 80.0),
2509            Column::new("amount", ColumnKind::Decimal, 100.0),
2510        ]
2511    }
2512
2513    fn fixture_rows() -> Vec<Vec<CellValue>> {
2514        let r = |region: &str, product: &str, year: i64, amount: f64| {
2515            vec![
2516                Text(region.into()),
2517                Text(product.into()),
2518                Integer(year),
2519                Decimal(amount),
2520            ]
2521        };
2522        vec![
2523            r("Europe", "Widget", 2023, 10.0),
2524            r("Europe", "Widget", 2024, 20.0),
2525            r("Europe", "Gadget", 2023, 5.0),
2526            r("Asia", "Widget", 2023, 7.0),
2527            r("Asia", "Gadget", 2024, 3.0),
2528        ]
2529    }
2530
2531    #[test]
2532    fn path_components_walk_root_to_leaf_with_field_metadata() {
2533        let cfg = two_level_config();
2534        let result = fixture_result(&cfg);
2535        let columns = fixture_columns();
2536        let europe = result
2537            .row_nodes
2538            .iter()
2539            .position(|n| n.label == "Europe")
2540            .unwrap();
2541        let widget = result.row_nodes[europe]
2542            .children
2543            .iter()
2544            .copied()
2545            .find(|&c| result.row_nodes[c].label == "Widget")
2546            .unwrap();
2547        let path = path_components(
2548            &result.row_nodes,
2549            &cfg.row_fields,
2550            &columns,
2551            "(blank)",
2552            widget,
2553        );
2554        assert_eq!(path.len(), 2);
2555        assert_eq!(path[0].field_name, "region");
2556        assert_eq!(path[0].label, "Europe");
2557        assert_eq!(path[0].field_index, 0);
2558        assert_eq!(path[1].field_name, "product");
2559        assert_eq!(path[1].label, "Widget");
2560        assert!(!path[1].is_blank);
2561        // Totals have no path.
2562        assert!(path_components(
2563            &result.row_nodes,
2564            &cfg.row_fields,
2565            &columns,
2566            "(blank)",
2567            TOTAL_KEY
2568        )
2569        .is_empty());
2570    }
2571
2572    #[test]
2573    fn rows_matching_path_selects_only_driving_rows() {
2574        let rows = fixture_rows();
2575        let columns = fixture_columns();
2576        let formats = GridConfig::default().resolve_all(&columns);
2577        let base: Vec<usize> = (0..rows.len()).collect();
2578        // Europe × Widget → source rows 0 and 1.
2579        let constraints = vec![(0, "Europe".to_owned()), (1, "Widget".to_owned())];
2580        assert_eq!(
2581            rows_matching_path(&rows, &formats, "(blank)", &base, &constraints),
2582            vec![0, 1]
2583        );
2584        // No constraints → everything in the base set.
2585        assert_eq!(
2586            rows_matching_path(&rows, &formats, "(blank)", &base, &[]),
2587            base
2588        );
2589        // Constraints respect a pre-filtered base.
2590        assert_eq!(
2591            rows_matching_path(&rows, &formats, "(blank)", &[1, 2, 3], &constraints),
2592            vec![1]
2593        );
2594    }
2595
2596    #[test]
2597    fn rows_matching_path_buckets_nulls_under_blank_label() {
2598        let mut rows = fixture_rows();
2599        rows.push(vec![
2600            CellValue::None,
2601            Text("Widget".into()),
2602            Integer(2023),
2603            Decimal(2.0),
2604        ]);
2605        let columns = fixture_columns();
2606        let formats = GridConfig::default().resolve_all(&columns);
2607        let base: Vec<usize> = (0..rows.len()).collect();
2608        let constraints = vec![(0, "(blank)".to_owned())];
2609        assert_eq!(
2610            rows_matching_path(&rows, &formats, "(blank)", &base, &constraints),
2611            vec![5]
2612        );
2613    }
2614
2615    #[test]
2616    fn drill_filter_set_maps_blank_to_empty_formatted_value() {
2617        let set = drill_filter_set("(blank)", "(blank)");
2618        assert!(set.contains(""));
2619        assert!(set.contains("(blank)"));
2620        let set = drill_filter_set("Europe", "(blank)");
2621        assert_eq!(set, HashSet::from(["Europe".to_owned()]));
2622    }
2623}