Skip to main content

sqlly_datatable/pivot/
context_menu.rs

1//! Public right-click extensibility for the pivot view.
2//!
3//! Mirrors the flat grid's [`crate::grid::ContextMenuProvider`] pattern:
4//! consumers implement [`PivotContextMenuProvider`], register it via
5//! [`crate::grid::SqllyDataTableBuilder::pivot_context_menu_provider`], and
6//! fully control the pivot's right-click menu. The provider receives a
7//! [`PivotContextMenuRequest`] snapshot describing exactly what was clicked —
8//! the row/column group paths, the aggregated value, and the source rows
9//! that drive it.
10//!
11//! When no provider is registered the pivot shows a small built-in menu
12//! ("Show source rows in Grid", "Copy value", "Copy pivot as CSV"). The
13//! built-in action ids ([`PIVOT_ACTION_SHOW_SOURCE_ROWS`],
14//! [`PIVOT_ACTION_COPY_VALUE`], [`PIVOT_ACTION_COPY_CSV`]) are always
15//! handled by the pivot itself, so providers can compose them into their own
16//! menus by returning items with those ids.
17
18use std::fmt;
19use std::sync::Arc;
20
21use crate::data::CellValue;
22use crate::pivot::aggregation::AggregationFn;
23use crate::pivot::config::PivotConfig;
24use crate::pivot::state::PivotState;
25
26/// Built-in action id: filter the flat grid to this cell's driving source
27/// rows and switch to the Grid tab (same as double-clicking the cell).
28pub const PIVOT_ACTION_SHOW_SOURCE_ROWS: &str = "pivot.show-source-rows";
29/// Built-in action id: copy the clicked cell's formatted value.
30pub const PIVOT_ACTION_COPY_VALUE: &str = "pivot.copy-value";
31/// Built-in action id: copy the fully expanded pivot as CSV.
32pub const PIVOT_ACTION_COPY_CSV: &str = "pivot.copy-csv";
33
34/// One level of a row/column grouping path, outermost first.
35#[derive(Clone, Debug, PartialEq)]
36pub struct PivotPathComponent {
37    /// Source column index of the grouping field.
38    pub field_index: usize,
39    /// Source column name of the grouping field.
40    pub field_name: String,
41    /// The formatted group label at this level (or the configured blank
42    /// label).
43    pub label: String,
44    /// The raw grouping value the group was built from.
45    pub group_value: CellValue,
46    /// `true` when this group is the null/"(blank)" bucket.
47    pub is_blank: bool,
48}
49
50/// Full context for a right-clicked pivot value cell.
51#[derive(Clone, Debug)]
52pub struct PivotCellContext {
53    /// Row grouping path (empty on the grand-total row, or when no row
54    /// fields are assigned).
55    pub row_path: Vec<PivotPathComponent>,
56    /// Column grouping path (empty on the grand-total column, or when no
57    /// column fields are assigned).
58    pub col_path: Vec<PivotPathComponent>,
59    /// The aggregated value at this intersection.
60    pub value: CellValue,
61    /// The value as the pivot displays it.
62    pub formatted_value: String,
63    /// `true` when the cell sits on the grand-total row.
64    pub is_row_grand_total: bool,
65    /// `true` when the cell sits in the grand-total column.
66    pub is_col_grand_total: bool,
67    /// `true` when the cell is a row group subtotal (a group-header or
68    /// collapsed-group line rather than an innermost row).
69    pub is_row_subtotal: bool,
70    /// `true` when the cell is a column subtotal ("Total" column or a
71    /// collapsed column group).
72    pub is_col_subtotal: bool,
73}
74
75/// What was right-clicked in the pivot.
76#[derive(Clone, Debug)]
77pub enum PivotMenuTarget {
78    /// A value cell (including subtotal and grand-total cells).
79    Cell(PivotCellContext),
80    /// A row label. `path` identifies the group; empty on the grand-total
81    /// row.
82    RowHeader {
83        /// Grouping path of the clicked row.
84        path: Vec<PivotPathComponent>,
85        /// `true` for the grand-total row label.
86        is_grand_total: bool,
87    },
88    /// A column header. `path` identifies the group; empty on the
89    /// grand-total column.
90    ColHeader {
91        /// Grouping path of the clicked column.
92        path: Vec<PivotPathComponent>,
93        /// `true` for the grand-total column header.
94        is_grand_total: bool,
95    },
96    /// The top-left corner block.
97    Corner,
98}
99
100impl PivotMenuTarget {
101    /// The clicked cell context, when the target is a value cell.
102    #[must_use]
103    pub fn cell(&self) -> Option<&PivotCellContext> {
104        match self {
105            Self::Cell(c) => Some(c),
106            _ => None,
107        }
108    }
109}
110
111/// Snapshot of the right-click context, captured at menu-open time. Owned
112/// data only — a clone can be moved into background work.
113#[derive(Clone, Debug)]
114pub struct PivotContextMenuRequest {
115    /// What was clicked.
116    pub target: PivotMenuTarget,
117    /// The active aggregation function.
118    pub aggregation: AggregationFn,
119    /// Source column index of the value field, if assigned.
120    pub value_field_index: Option<usize>,
121    /// Header caption for the value area, e.g. `"Sum of Amount"`.
122    pub value_caption: String,
123    /// The full pivot configuration at menu-open time.
124    pub config: PivotConfig,
125    /// Indices into the grid's source rows that drive the clicked target:
126    /// the rows aggregated into the clicked cell (for cells), the rows of
127    /// the clicked group (for row/column headers), or every row passing the
128    /// pivot's source filters (for the corner).
129    pub source_row_indices: Vec<usize>,
130}
131
132impl PivotContextMenuRequest {
133    /// The clicked cell context, when the right-click landed on a value
134    /// cell.
135    #[must_use]
136    pub fn clicked_cell(&self) -> Option<&PivotCellContext> {
137        self.target.cell()
138    }
139
140    /// Number of source rows driving the clicked target.
141    #[must_use]
142    pub fn source_row_count(&self) -> usize {
143        self.source_row_indices.len()
144    }
145}
146
147/// A menu item returned by a [`PivotContextMenuProvider`].
148#[derive(Clone, Debug, PartialEq)]
149pub enum PivotMenuItem {
150    /// An action with a consumer-defined `id` and display label. Items using
151    /// the built-in `pivot.*` action ids are handled by the pivot itself.
152    Action {
153        /// Identifier passed back to [`PivotContextMenuProvider::on_action`].
154        id: String,
155        /// Display label.
156        label: String,
157    },
158    /// A visual separator.
159    Separator,
160}
161
162impl PivotMenuItem {
163    /// Convenience constructor for an action item.
164    #[must_use]
165    pub fn action(id: impl Into<String>, label: impl Into<String>) -> Self {
166        Self::Action {
167            id: id.into(),
168            label: label.into(),
169        }
170    }
171
172    /// Convenience constructor for a separator.
173    #[must_use]
174    pub fn separator() -> Self {
175        Self::Separator
176    }
177
178    /// The built-in "Show source rows in Grid" item (drill-through; same as
179    /// double-clicking the cell).
180    #[must_use]
181    pub fn show_source_rows() -> Self {
182        Self::action(PIVOT_ACTION_SHOW_SOURCE_ROWS, "Show source rows in Grid")
183    }
184
185    /// The built-in "Copy value" item.
186    #[must_use]
187    pub fn copy_value() -> Self {
188        Self::action(PIVOT_ACTION_COPY_VALUE, "Copy value")
189    }
190
191    /// The built-in "Copy pivot as CSV" item.
192    #[must_use]
193    pub fn copy_csv() -> Self {
194        Self::action(PIVOT_ACTION_COPY_CSV, "Copy pivot as CSV")
195    }
196
197    /// The default menu shown when no provider is registered, for the given
198    /// target. Providers can reuse this and append their own items.
199    #[must_use]
200    pub fn standard_items(target: &PivotMenuTarget) -> Vec<Self> {
201        let mut items = Vec::new();
202        match target {
203            PivotMenuTarget::Cell(_) => {
204                items.push(Self::show_source_rows());
205                items.push(Self::copy_value());
206                items.push(Self::separator());
207            }
208            PivotMenuTarget::RowHeader { .. } | PivotMenuTarget::ColHeader { .. } => {
209                items.push(Self::show_source_rows());
210                items.push(Self::separator());
211            }
212            PivotMenuTarget::Corner => {}
213        }
214        items.push(Self::copy_csv());
215        items
216    }
217}
218
219/// Trait implemented by consumers to supply custom right-click menu items
220/// for the pivot and handle clicks on them.
221///
222/// Register on
223/// [`crate::grid::SqllyDataTableBuilder::pivot_context_menu_provider`] (or
224/// [`crate::grid::SqllyDataTable::set_pivot_context_menu_provider`]). When
225/// registered, the provider fully controls the menu; compose the built-in
226/// actions via [`PivotMenuItem::standard_items`] or the individual
227/// constructors. Items carrying the built-in `pivot.*` ids are executed by
228/// the pivot itself and do not reach [`PivotContextMenuProvider::on_action`].
229pub trait PivotContextMenuProvider: 'static {
230    /// Build the menu items for the given right-click context. Return an
231    /// empty vec to suppress the menu for this target.
232    fn menu_items(&self, request: &PivotContextMenuRequest) -> Vec<PivotMenuItem>;
233
234    /// Handle a click on a custom action item. `action_id` matches the `id`
235    /// supplied in [`PivotMenuItem::Action`].
236    #[allow(unused_variables)]
237    fn on_action(
238        &self,
239        action_id: &str,
240        request: &PivotContextMenuRequest,
241        state: &mut PivotState,
242        cx: &mut gpui::App,
243    ) {
244    }
245}
246
247/// Type-erased handle wrapping an `Arc<dyn PivotContextMenuProvider>`.
248#[derive(Clone)]
249pub(crate) struct PivotContextMenuProviderHandle(Arc<dyn PivotContextMenuProvider>);
250
251impl PivotContextMenuProviderHandle {
252    pub(crate) fn new(provider: impl PivotContextMenuProvider + 'static) -> Self {
253        Self(Arc::new(provider))
254    }
255}
256
257impl fmt::Debug for PivotContextMenuProviderHandle {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.debug_struct("PivotContextMenuProviderHandle")
260            .finish_non_exhaustive()
261    }
262}
263
264impl std::ops::Deref for PivotContextMenuProviderHandle {
265    type Target = dyn PivotContextMenuProvider;
266
267    fn deref(&self) -> &Self::Target {
268        &*self.0
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn standard_items_for_cell_include_drill_copy_and_csv() {
278        let cell = PivotCellContext {
279            row_path: vec![],
280            col_path: vec![],
281            value: CellValue::Integer(1),
282            formatted_value: "1".into(),
283            is_row_grand_total: false,
284            is_col_grand_total: false,
285            is_row_subtotal: false,
286            is_col_subtotal: false,
287        };
288        let items = PivotMenuItem::standard_items(&PivotMenuTarget::Cell(cell));
289        assert_eq!(items.len(), 4);
290        assert_eq!(items[0], PivotMenuItem::show_source_rows());
291        assert_eq!(items[1], PivotMenuItem::copy_value());
292        assert_eq!(items[2], PivotMenuItem::Separator);
293        assert_eq!(items[3], PivotMenuItem::copy_csv());
294    }
295
296    #[test]
297    fn standard_items_for_corner_only_offer_csv() {
298        let items = PivotMenuItem::standard_items(&PivotMenuTarget::Corner);
299        assert_eq!(items, vec![PivotMenuItem::copy_csv()]);
300    }
301
302    #[test]
303    fn standard_items_for_headers_offer_drill() {
304        let items = PivotMenuItem::standard_items(&PivotMenuTarget::RowHeader {
305            path: vec![],
306            is_grand_total: false,
307        });
308        assert_eq!(items[0], PivotMenuItem::show_source_rows());
309    }
310}