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#[derive(Clone, Debug)]
149pub struct ContextMenuRequest {
150    pub target: ContextMenuTarget,
151    pub selection: Option<ContextMenuSelection>,
152    pub selected_cells: Vec<SelectedCellContext>,
153    pub selected_rows: Vec<SelectedRowContext>,
154}
155
156impl ContextMenuRequest {
157    /// The specific cell under the cursor when the menu opened, if the
158    /// right-click landed on a data cell.
159    #[must_use]
160    pub fn clicked_cell(&self) -> Option<&SelectedCellContext> {
161        match &self.target {
162            ContextMenuTarget::Cell {
163                display_row_index,
164                column_index,
165                ..
166            } => self.selected_cells.iter().find(|c| {
167                c.display_row_index == *display_row_index && c.column_index == *column_index
168            }),
169            _ => None,
170        }
171    }
172
173    /// The row under the cursor when the menu opened, if the right-click
174    /// landed on a cell or row header.
175    #[must_use]
176    pub fn clicked_row(&self) -> Option<&SelectedRowContext> {
177        let row = self.target.display_row_index()?;
178        self.selected_rows
179            .iter()
180            .find(|r| r.display_row_index == row)
181    }
182
183    /// All selected cells in the effective selection.
184    #[must_use]
185    pub fn selected_cells(&self) -> &[SelectedCellContext] {
186        &self.selected_cells
187    }
188
189    /// All selected rows in the effective selection.
190    #[must_use]
191    pub fn selected_rows(&self) -> &[SelectedRowContext] {
192        &self.selected_rows
193    }
194}
195
196/// Public menu item returned by a [`ContextMenuProvider`]. Distinct from the
197/// internal `MenuItem` used by the rendering pipeline.
198#[derive(Clone, Debug)]
199pub enum ContextMenuItem {
200    /// A built-in action (sort, copy, filter, etc.). Allows providers to
201    /// compose standard column-header actions alongside custom ones.
202    BuiltIn(MenuAction),
203    /// A custom action with a consumer-defined `id` and display label.
204    Action { id: String, label: String },
205    /// A visual separator.
206    Separator,
207}
208
209impl ContextMenuItem {
210    /// Convenience constructor for a custom action.
211    #[must_use]
212    pub fn action(id: impl Into<String>, label: impl Into<String>) -> Self {
213        Self::Action {
214            id: id.into(),
215            label: label.into(),
216        }
217    }
218
219    /// Convenience constructor for a separator.
220    #[must_use]
221    pub fn separator() -> Self {
222        Self::Separator
223    }
224
225    /// The standard built-in column-header menu items, in the same order
226    /// the grid uses when no provider is registered. Providers can prepend
227    /// or append custom items around this list.
228    #[must_use]
229    pub fn standard_column_header_items() -> Vec<Self> {
230        vec![
231            Self::BuiltIn(MenuAction::SelectColumn),
232            Self::BuiltIn(MenuAction::CopyColumn),
233            Self::BuiltIn(MenuAction::CopyColumnWithHeaders),
234            Self::Separator,
235            Self::BuiltIn(MenuAction::SortAscending),
236            Self::BuiltIn(MenuAction::SortDescending),
237            Self::BuiltIn(MenuAction::ClearSort),
238            Self::Separator,
239            Self::BuiltIn(MenuAction::FilterPrompt),
240            Self::BuiltIn(MenuAction::ClearFilter),
241        ]
242    }
243}
244
245/// Trait implemented by consumers to supply custom right-click menu items
246/// and handle clicks on those items.
247///
248/// The provider is registered on
249/// [`crate::grid::SqllyDataTableBuilder::context_menu_provider`]. When
250/// registered, the provider fully controls the right-click menu for all
251/// targets (cells, row headers, column headers). When no provider is
252/// registered, the built-in column-header menu is used unchanged.
253///
254/// `menu_items` is called only on right-click, so normal render/scroll
255/// performance is unaffected. `on_action` is called when the user clicks a
256/// custom menu item, with `&mut GridState` and `&mut gpui::App` available
257/// for clipboard, selection, or application-level side effects.
258pub trait ContextMenuProvider: 'static {
259    /// Build the menu items for the given right-click context.
260    fn menu_items(&self, request: &ContextMenuRequest) -> Vec<ContextMenuItem>;
261
262    /// Handle a click on a custom action item. `action_id` matches the `id`
263    /// supplied in [`ContextMenuItem::Action`]. Built-in items
264    /// ([`ContextMenuItem::BuiltIn`]) are handled by the grid and do not
265    /// reach this method.
266    #[allow(unused_variables)]
267    fn on_action(
268        &self,
269        action_id: &str,
270        request: &ContextMenuRequest,
271        state: &mut GridState,
272        cx: &mut gpui::App,
273    ) {
274    }
275}
276
277/// Type-erased handle wrapping an `Arc<dyn ContextMenuProvider>`. Stored on
278/// `GridState` and cloned into event closures.
279#[derive(Clone)]
280pub(crate) struct ContextMenuProviderHandle(Arc<dyn ContextMenuProvider>);
281
282impl ContextMenuProviderHandle {
283    pub(crate) fn new(provider: impl ContextMenuProvider + 'static) -> Self {
284        Self(Arc::new(provider))
285    }
286}
287
288impl fmt::Debug for ContextMenuProviderHandle {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        f.debug_struct("ContextMenuProviderHandle")
291            .finish_non_exhaustive()
292    }
293}
294
295impl std::ops::Deref for ContextMenuProviderHandle {
296    type Target = dyn ContextMenuProvider;
297
298    fn deref(&self) -> &Self::Target {
299        &*self.0
300    }
301}
302
303/// Deferred custom context-menu action, flushed at the top of `render`.
304#[derive(Clone, Debug)]
305pub(crate) struct PendingCustomContextMenuAction {
306    pub id: String,
307    pub request: ContextMenuRequest,
308}
309
310#[cfg(test)]
311#[allow(clippy::unwrap_used, clippy::expect_used)]
312mod tests {
313    use super::*;
314
315    fn row(name: &str, values: &[CellValue]) -> SelectedRowContext {
316        let columns = vec![
317            ColumnContext {
318                index: 0,
319                name: "id".into(),
320                kind: ColumnKind::Integer,
321            },
322            ColumnContext {
323                index: 1,
324                name: name.into(),
325                kind: ColumnKind::Text,
326            },
327        ];
328        SelectedRowContext {
329            display_row_index: 0,
330            source_row_index: 0,
331            values: values.to_vec(),
332            columns,
333        }
334    }
335
336    #[test]
337    fn value_at_returns_by_ordinal() {
338        let r = row(
339            "name",
340            &[CellValue::Integer(7), CellValue::Text("hi".into())],
341        );
342        assert_eq!(r.value_at(0), Some(&CellValue::Integer(7)));
343        assert_eq!(r.value_at(1), Some(&CellValue::Text("hi".into())));
344        assert_eq!(r.value_at(2), None);
345    }
346
347    #[test]
348    fn value_by_name_exact_case_sensitive() {
349        let r = row(
350            "Name",
351            &[CellValue::Integer(7), CellValue::Text("hi".into())],
352        );
353        assert_eq!(r.value_by_name("Name"), Some(&CellValue::Text("hi".into())));
354        assert_eq!(r.value_by_name("name"), None);
355        assert_eq!(r.value_by_name("NAME"), None);
356    }
357
358    #[test]
359    fn value_by_name_first_duplicate_wins() {
360        let columns = vec![
361            ColumnContext {
362                index: 0,
363                name: "dup".into(),
364                kind: ColumnKind::Integer,
365            },
366            ColumnContext {
367                index: 1,
368                name: "dup".into(),
369                kind: ColumnKind::Integer,
370            },
371        ];
372        let r = SelectedRowContext {
373            display_row_index: 0,
374            source_row_index: 0,
375            values: vec![CellValue::Integer(1), CellValue::Integer(2)],
376            columns,
377        };
378        assert_eq!(r.value_by_name("dup"), Some(&CellValue::Integer(1)));
379        assert_eq!(r.column_index("dup"), Some(0));
380    }
381
382    #[test]
383    fn named_values_iterates_all_columns() {
384        let r = row(
385            "name",
386            &[CellValue::Integer(7), CellValue::Text("hi".into())],
387        );
388        let pairs: Vec<_> = r.named_values().collect();
389        assert_eq!(pairs.len(), 2);
390        assert_eq!(pairs[0].0, "id");
391        assert_eq!(pairs[0].1, &CellValue::Integer(7));
392        assert_eq!(pairs[1].0, "name");
393        assert_eq!(pairs[1].1, &CellValue::Text("hi".into()));
394    }
395
396    #[test]
397    fn context_menu_target_column_index() {
398        assert_eq!(
399            ContextMenuTarget::Cell {
400                display_row_index: 0,
401                source_row_index: 0,
402                column_index: 3
403            }
404            .column_index(),
405            Some(3)
406        );
407        assert_eq!(
408            ContextMenuTarget::RowHeader {
409                display_row_index: 0,
410                source_row_index: 0
411            }
412            .column_index(),
413            None
414        );
415    }
416
417    #[test]
418    fn context_menu_target_display_row_index() {
419        assert_eq!(
420            ContextMenuTarget::Cell {
421                display_row_index: 5,
422                source_row_index: 2,
423                column_index: 0
424            }
425            .display_row_index(),
426            Some(5)
427        );
428        assert_eq!(
429            ContextMenuTarget::ColumnHeader { column_index: 1 }.display_row_index(),
430            None
431        );
432    }
433
434    #[test]
435    fn standard_column_header_items_match_builtin_order() {
436        let items = ContextMenuItem::standard_column_header_items();
437        assert_eq!(items.len(), 10);
438        assert!(matches!(
439            items[0],
440            ContextMenuItem::BuiltIn(MenuAction::SelectColumn)
441        ));
442        assert!(matches!(items[3], ContextMenuItem::Separator));
443        assert!(matches!(
444            items[9],
445            ContextMenuItem::BuiltIn(MenuAction::ClearFilter)
446        ));
447    }
448
449    #[test]
450    fn clicked_cell_finds_target_cell() {
451        let request = ContextMenuRequest {
452            target: ContextMenuTarget::Cell {
453                display_row_index: 1,
454                source_row_index: 2,
455                column_index: 0,
456            },
457            selection: None,
458            selected_cells: vec![
459                SelectedCellContext {
460                    display_row_index: 0,
461                    source_row_index: 0,
462                    column_index: 0,
463                    column_name: "a".into(),
464                    value: CellValue::Integer(1),
465                },
466                SelectedCellContext {
467                    display_row_index: 1,
468                    source_row_index: 2,
469                    column_index: 0,
470                    column_name: "a".into(),
471                    value: CellValue::Integer(3),
472                },
473            ],
474            selected_rows: vec![],
475        };
476        let clicked = request.clicked_cell().unwrap();
477        assert_eq!(clicked.source_row_index, 2);
478        assert_eq!(clicked.value, CellValue::Integer(3));
479    }
480
481    #[test]
482    fn clicked_cell_none_for_column_header_target() {
483        let request = ContextMenuRequest {
484            target: ContextMenuTarget::ColumnHeader { column_index: 0 },
485            selection: None,
486            selected_cells: vec![],
487            selected_rows: vec![],
488        };
489        assert!(request.clicked_cell().is_none());
490    }
491
492    #[test]
493    fn clicked_row_finds_target_for_row_header() {
494        let request = ContextMenuRequest {
495            target: ContextMenuTarget::RowHeader {
496                display_row_index: 1,
497                source_row_index: 2,
498            },
499            selection: None,
500            selected_cells: vec![],
501            selected_rows: vec![
502                SelectedRowContext {
503                    display_row_index: 0,
504                    source_row_index: 0,
505                    values: vec![],
506                    columns: vec![],
507                },
508                SelectedRowContext {
509                    display_row_index: 1,
510                    source_row_index: 2,
511                    values: vec![],
512                    columns: vec![],
513                },
514            ],
515        };
516        assert_eq!(request.clicked_row().unwrap().source_row_index, 2);
517    }
518
519    #[test]
520    fn clicked_row_none_for_column_header() {
521        let request = ContextMenuRequest {
522            target: ContextMenuTarget::ColumnHeader { column_index: 0 },
523            selection: None,
524            selected_cells: vec![],
525            selected_rows: vec![],
526        };
527        assert!(request.clicked_row().is_none());
528    }
529}