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