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