Skip to main content

sqlly_datatable/grid/
context_menu.rs

1//! Public context-menu extensibility types.
2//!
3//! Consumers implement [`ContextMenuProvider`] and register it on
4//! [`crate::grid::SqllyDataTableBuilder`] to fully control the right-click
5//! menu. When a provider is registered the built-in column-header menu is
6//! suppressed; consumers can compose built-in items via
7//! [`ContextMenuItem::standard_column_header_items`].
8//!
9//! The provider receives an owned [`ContextMenuRequest`] snapshot captured
10//! at menu-open time. The snapshot survives until the user clicks a menu
11//! item, so the provider's [`ContextMenuProvider::on_action`] sees exactly
12//! what was selected/right-clicked when the menu opened — even if grid state
13//! (sort, filter, selection) changed in the interim.
14
15use std::fmt;
16use std::sync::Arc;
17
18use crate::data::{CellValue, ColumnKind};
19use crate::grid::menu::MenuAction;
20use crate::grid::state::GridState;
21
22/// What was right-clicked. Maps directly from the grid's hit-test result.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum ContextMenuTarget {
25    /// A data cell.
26    Cell {
27        display_row_index: usize,
28        source_row_index: usize,
29        column_index: usize,
30    },
31    /// The row-number gutter on the left edge.
32    RowHeader {
33        display_row_index: usize,
34        source_row_index: usize,
35    },
36    /// A column header cell (excluding the sort button area).
37    ColumnHeader { column_index: usize },
38    /// The sort/indicator button inside a column header.
39    SortButton { column_index: usize },
40}
41
42impl ContextMenuTarget {
43    /// Returns the column index for targets that carry one, or `None` for
44    /// row-header targets.
45    #[must_use]
46    pub fn column_index(&self) -> Option<usize> {
47        match self {
48            Self::Cell { column_index, .. } => Some(*column_index),
49            Self::ColumnHeader { column_index } => Some(*column_index),
50            Self::SortButton { column_index } => Some(*column_index),
51            Self::RowHeader { .. } => None,
52        }
53    }
54
55    /// Returns the display row index for targets that carry one, or `None`
56    /// for column-header/sort-button targets.
57    #[must_use]
58    pub fn display_row_index(&self) -> Option<usize> {
59        match self {
60            Self::Cell {
61                display_row_index, ..
62            } => Some(*display_row_index),
63            Self::RowHeader {
64                display_row_index, ..
65            } => Some(*display_row_index),
66            Self::ColumnHeader { .. } | Self::SortButton { .. } => None,
67        }
68    }
69}
70
71/// Normalized inclusive selection range captured at menu-open time.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct ContextMenuSelection {
74    pub row_start: usize,
75    pub row_end: usize,
76    pub column_start: usize,
77    pub column_end: usize,
78}
79
80/// Metadata and value for a single selected cell.
81#[derive(Clone, Debug)]
82pub struct SelectedCellContext {
83    pub display_row_index: usize,
84    pub source_row_index: usize,
85    pub column_index: usize,
86    pub column_name: String,
87    pub value: CellValue,
88}
89
90/// Metadata for a column, included in [`SelectedRowContext`].
91#[derive(Clone, Debug)]
92pub struct ColumnContext {
93    pub index: usize,
94    pub name: String,
95    pub kind: ColumnKind,
96}
97
98/// Full selected row: all cell values plus column metadata for name-based
99/// lookup helpers.
100#[derive(Clone, Debug)]
101pub struct SelectedRowContext {
102    pub display_row_index: usize,
103    pub source_row_index: usize,
104    pub values: Vec<CellValue>,
105    pub columns: Vec<ColumnContext>,
106}
107
108impl SelectedRowContext {
109    /// Value at the given ordinal column index.
110    #[must_use]
111    pub fn value_at(&self, column_index: usize) -> Option<&CellValue> {
112        self.values.get(column_index)
113    }
114
115    /// Value for the first column whose name matches `column_name` exactly
116    /// (case-sensitive). If duplicate names exist, the first match wins.
117    #[must_use]
118    pub fn value_by_name(&self, column_name: &str) -> Option<&CellValue> {
119        self.column_index(column_name)
120            .and_then(|i| self.values.get(i))
121    }
122
123    /// Iterator over `(column_name, value)` pairs for every column in this
124    /// row.
125    pub fn named_values(&self) -> impl Iterator<Item = (&str, &CellValue)> {
126        self.columns
127            .iter()
128            .filter_map(move |col| self.values.get(col.index).map(|v| (col.name.as_str(), v)))
129    }
130
131    /// Ordinal column index for the first column whose name matches
132    /// `column_name` exactly (case-sensitive). First duplicate wins.
133    #[must_use]
134    pub fn column_index(&self, column_name: &str) -> Option<usize> {
135        self.columns
136            .iter()
137            .find(|c| c.name == column_name)
138            .map(|c| c.index)
139    }
140}
141
142/// Owned snapshot of the right-click context, captured at menu-open time.
143///
144/// All indices are display indices (post sort/filter) unless prefixed with
145/// `source_`. The `selected_cells` and `selected_rows` vectors contain one
146/// entry per cell/row in the effective selection; for large selections this
147/// clones owned data.
148///
149/// For column-oriented targets (`ColumnHeader`, `SortButton`, or a
150/// `Selection::Column`), `selected_rows` is left empty — a column right-click
151/// is column-oriented (`clicked_row()` is `None`), so the column's values are
152/// exposed through `selected_cells` and full per-row snapshots are skipped to
153/// avoid O(rows x cols) cloning on large datasets.
154#[derive(Clone, Debug)]
155pub struct ContextMenuRequest {
156    pub target: ContextMenuTarget,
157    pub selection: Option<ContextMenuSelection>,
158    pub selected_cells: Vec<SelectedCellContext>,
159    pub selected_rows: Vec<SelectedRowContext>,
160}
161
162impl ContextMenuRequest {
163    /// The specific cell under the cursor when the menu opened, if the
164    /// right-click landed on a data cell.
165    #[must_use]
166    pub fn clicked_cell(&self) -> Option<&SelectedCellContext> {
167        match &self.target {
168            ContextMenuTarget::Cell {
169                display_row_index,
170                column_index,
171                ..
172            } => self.selected_cells.iter().find(|c| {
173                c.display_row_index == *display_row_index && c.column_index == *column_index
174            }),
175            _ => None,
176        }
177    }
178
179    /// The row under the cursor when the menu opened, if the right-click
180    /// landed on a cell or row header.
181    #[must_use]
182    pub fn clicked_row(&self) -> Option<&SelectedRowContext> {
183        let row = self.target.display_row_index()?;
184        self.selected_rows
185            .iter()
186            .find(|r| r.display_row_index == row)
187    }
188
189    /// All selected cells in the effective selection.
190    #[must_use]
191    pub fn selected_cells(&self) -> &[SelectedCellContext] {
192        &self.selected_cells
193    }
194
195    /// All selected rows in the effective selection.
196    #[must_use]
197    pub fn selected_rows(&self) -> &[SelectedRowContext] {
198        &self.selected_rows
199    }
200}
201
202/// Public menu item returned by a [`ContextMenuProvider`]. Distinct from the
203/// internal `MenuItem` used by the rendering pipeline.
204#[derive(Clone, Debug)]
205pub enum ContextMenuItem {
206    /// A built-in action (sort, copy, filter, etc.). Allows providers to
207    /// compose standard column-header actions alongside custom ones.
208    BuiltIn(MenuAction),
209    /// A custom action with a consumer-defined `id` and display label.
210    Action { id: String, label: String },
211    /// A visual separator.
212    Separator,
213}
214
215impl ContextMenuItem {
216    /// Convenience constructor for a custom action.
217    #[must_use]
218    pub fn action(id: impl Into<String>, label: impl Into<String>) -> Self {
219        Self::Action {
220            id: id.into(),
221            label: label.into(),
222        }
223    }
224
225    /// Convenience constructor for a separator.
226    #[must_use]
227    pub fn separator() -> Self {
228        Self::Separator
229    }
230
231    /// The standard built-in column-header menu items, in the same order
232    /// the grid uses when no provider is registered. Providers can prepend
233    /// or append custom items around this list.
234    #[must_use]
235    pub fn standard_column_header_items() -> Vec<Self> {
236        vec![
237            Self::BuiltIn(MenuAction::SelectColumn),
238            Self::BuiltIn(MenuAction::CopyColumn),
239            Self::BuiltIn(MenuAction::CopyColumnWithHeaders),
240            Self::Separator,
241            Self::BuiltIn(MenuAction::SortAscending),
242            Self::BuiltIn(MenuAction::SortDescending),
243            Self::BuiltIn(MenuAction::ClearSort),
244            Self::Separator,
245            Self::BuiltIn(MenuAction::FilterPrompt),
246            Self::BuiltIn(MenuAction::ClearFilter),
247        ]
248    }
249}
250
251/// Trait implemented by consumers to supply custom right-click menu items
252/// and handle clicks on those items.
253///
254/// The provider is registered on
255/// [`crate::grid::SqllyDataTableBuilder::context_menu_provider`]. When
256/// registered, the provider fully controls the right-click menu for all
257/// targets (cells, row headers, column headers). When no provider is
258/// registered, the built-in column-header menu is used unchanged.
259///
260/// `menu_items` is called only on right-click, so normal render/scroll
261/// performance is unaffected. `on_action` is called when the user clicks a
262/// custom menu item, with `&mut GridState` and `&mut gpui::App` available
263/// for clipboard, selection, or application-level side effects.
264pub trait ContextMenuProvider: 'static {
265    /// Build the menu items for the given right-click context.
266    fn menu_items(&self, request: &ContextMenuRequest) -> Vec<ContextMenuItem>;
267
268    /// Handle a click on a custom action item. `action_id` matches the `id`
269    /// supplied in [`ContextMenuItem::Action`]. Built-in items
270    /// ([`ContextMenuItem::BuiltIn`]) are handled by the grid and do not
271    /// reach this method.
272    #[allow(unused_variables)]
273    fn on_action(
274        &self,
275        action_id: &str,
276        request: &ContextMenuRequest,
277        state: &mut GridState,
278        cx: &mut gpui::App,
279    ) {
280    }
281}
282
283/// Type-erased handle wrapping an `Arc<dyn ContextMenuProvider>`. Stored on
284/// `GridState` and cloned into event closures.
285#[derive(Clone)]
286pub(crate) struct ContextMenuProviderHandle(Arc<dyn ContextMenuProvider>);
287
288impl ContextMenuProviderHandle {
289    pub(crate) fn new(provider: impl ContextMenuProvider + 'static) -> Self {
290        Self(Arc::new(provider))
291    }
292}
293
294impl fmt::Debug for ContextMenuProviderHandle {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        f.debug_struct("ContextMenuProviderHandle")
297            .finish_non_exhaustive()
298    }
299}
300
301impl std::ops::Deref for ContextMenuProviderHandle {
302    type Target = dyn ContextMenuProvider;
303
304    fn deref(&self) -> &Self::Target {
305        &*self.0
306    }
307}
308
309/// Deferred custom context-menu action, flushed at the top of `render`.
310#[derive(Clone, Debug)]
311pub(crate) struct PendingCustomContextMenuAction {
312    pub id: String,
313    pub request: ContextMenuRequest,
314}
315
316#[cfg(test)]
317#[allow(clippy::unwrap_used, clippy::expect_used)]
318mod tests {
319    use super::*;
320
321    fn row(name: &str, values: &[CellValue]) -> SelectedRowContext {
322        let columns = vec![
323            ColumnContext {
324                index: 0,
325                name: "id".into(),
326                kind: ColumnKind::Integer,
327            },
328            ColumnContext {
329                index: 1,
330                name: name.into(),
331                kind: ColumnKind::Text,
332            },
333        ];
334        SelectedRowContext {
335            display_row_index: 0,
336            source_row_index: 0,
337            values: values.to_vec(),
338            columns,
339        }
340    }
341
342    #[test]
343    fn value_at_returns_by_ordinal() {
344        let r = row(
345            "name",
346            &[CellValue::Integer(7), CellValue::Text("hi".into())],
347        );
348        assert_eq!(r.value_at(0), Some(&CellValue::Integer(7)));
349        assert_eq!(r.value_at(1), Some(&CellValue::Text("hi".into())));
350        assert_eq!(r.value_at(2), None);
351    }
352
353    #[test]
354    fn value_by_name_exact_case_sensitive() {
355        let r = row(
356            "Name",
357            &[CellValue::Integer(7), CellValue::Text("hi".into())],
358        );
359        assert_eq!(r.value_by_name("Name"), Some(&CellValue::Text("hi".into())));
360        assert_eq!(r.value_by_name("name"), None);
361        assert_eq!(r.value_by_name("NAME"), None);
362    }
363
364    #[test]
365    fn value_by_name_first_duplicate_wins() {
366        let columns = vec![
367            ColumnContext {
368                index: 0,
369                name: "dup".into(),
370                kind: ColumnKind::Integer,
371            },
372            ColumnContext {
373                index: 1,
374                name: "dup".into(),
375                kind: ColumnKind::Integer,
376            },
377        ];
378        let r = SelectedRowContext {
379            display_row_index: 0,
380            source_row_index: 0,
381            values: vec![CellValue::Integer(1), CellValue::Integer(2)],
382            columns,
383        };
384        assert_eq!(r.value_by_name("dup"), Some(&CellValue::Integer(1)));
385        assert_eq!(r.column_index("dup"), Some(0));
386    }
387
388    #[test]
389    fn named_values_iterates_all_columns() {
390        let r = row(
391            "name",
392            &[CellValue::Integer(7), CellValue::Text("hi".into())],
393        );
394        let pairs: Vec<_> = r.named_values().collect();
395        assert_eq!(pairs.len(), 2);
396        assert_eq!(pairs[0].0, "id");
397        assert_eq!(pairs[0].1, &CellValue::Integer(7));
398        assert_eq!(pairs[1].0, "name");
399        assert_eq!(pairs[1].1, &CellValue::Text("hi".into()));
400    }
401
402    #[test]
403    fn context_menu_target_column_index() {
404        assert_eq!(
405            ContextMenuTarget::Cell {
406                display_row_index: 0,
407                source_row_index: 0,
408                column_index: 3
409            }
410            .column_index(),
411            Some(3)
412        );
413        assert_eq!(
414            ContextMenuTarget::RowHeader {
415                display_row_index: 0,
416                source_row_index: 0
417            }
418            .column_index(),
419            None
420        );
421    }
422
423    #[test]
424    fn context_menu_target_display_row_index() {
425        assert_eq!(
426            ContextMenuTarget::Cell {
427                display_row_index: 5,
428                source_row_index: 2,
429                column_index: 0
430            }
431            .display_row_index(),
432            Some(5)
433        );
434        assert_eq!(
435            ContextMenuTarget::ColumnHeader { column_index: 1 }.display_row_index(),
436            None
437        );
438    }
439
440    #[test]
441    fn standard_column_header_items_match_builtin_order() {
442        let items = ContextMenuItem::standard_column_header_items();
443        assert_eq!(items.len(), 10);
444        assert!(matches!(
445            items[0],
446            ContextMenuItem::BuiltIn(MenuAction::SelectColumn)
447        ));
448        assert!(matches!(items[3], ContextMenuItem::Separator));
449        assert!(matches!(
450            items[9],
451            ContextMenuItem::BuiltIn(MenuAction::ClearFilter)
452        ));
453    }
454
455    #[test]
456    fn clicked_cell_finds_target_cell() {
457        let request = ContextMenuRequest {
458            target: ContextMenuTarget::Cell {
459                display_row_index: 1,
460                source_row_index: 2,
461                column_index: 0,
462            },
463            selection: None,
464            selected_cells: vec![
465                SelectedCellContext {
466                    display_row_index: 0,
467                    source_row_index: 0,
468                    column_index: 0,
469                    column_name: "a".into(),
470                    value: CellValue::Integer(1),
471                },
472                SelectedCellContext {
473                    display_row_index: 1,
474                    source_row_index: 2,
475                    column_index: 0,
476                    column_name: "a".into(),
477                    value: CellValue::Integer(3),
478                },
479            ],
480            selected_rows: vec![],
481        };
482        let clicked = request.clicked_cell().unwrap();
483        assert_eq!(clicked.source_row_index, 2);
484        assert_eq!(clicked.value, CellValue::Integer(3));
485    }
486
487    #[test]
488    fn clicked_cell_none_for_column_header_target() {
489        let request = ContextMenuRequest {
490            target: ContextMenuTarget::ColumnHeader { column_index: 0 },
491            selection: None,
492            selected_cells: vec![],
493            selected_rows: vec![],
494        };
495        assert!(request.clicked_cell().is_none());
496    }
497
498    #[test]
499    fn clicked_row_finds_target_for_row_header() {
500        let request = ContextMenuRequest {
501            target: ContextMenuTarget::RowHeader {
502                display_row_index: 1,
503                source_row_index: 2,
504            },
505            selection: None,
506            selected_cells: vec![],
507            selected_rows: vec![
508                SelectedRowContext {
509                    display_row_index: 0,
510                    source_row_index: 0,
511                    values: vec![],
512                    columns: vec![],
513                },
514                SelectedRowContext {
515                    display_row_index: 1,
516                    source_row_index: 2,
517                    values: vec![],
518                    columns: vec![],
519                },
520            ],
521        };
522        assert_eq!(request.clicked_row().unwrap().source_row_index, 2);
523    }
524
525    #[test]
526    fn clicked_row_none_for_column_header() {
527        let request = ContextMenuRequest {
528            target: ContextMenuTarget::ColumnHeader { column_index: 0 },
529            selection: None,
530            selected_cells: vec![],
531            selected_rows: vec![],
532        };
533        assert!(request.clicked_row().is_none());
534    }
535}