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