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}