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