Skip to main content

sqlly_datatable/pivot/
state.rs

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