Skip to main content

sqlly_datatable/grid/
menu.rs

1//! Context menu — column-header right-click interaction. Layout, hover
2//! resolution, and action labels live here so paint code only consumes the
3//! menu snapshot.
4
5use gpui::{px, Hsla, Pixels, Point};
6
7use crate::grid::context_menu::ContextMenuRequest;
8
9/// Height, padding, and minimum width used to lay the menu out. Public so the
10/// state module's hit-testing math can stay in sync with paint.
11pub const MENU_FONT_SIZE: f32 = 14.0;
12pub const MENU_ITEM_HEIGHT: f32 = MENU_FONT_SIZE + 8.0;
13pub const MENU_PADDING_X: f32 = 12.0;
14pub const MENU_MIN_WIDTH: f32 = 180.0;
15pub const MENU_BORDER: f32 = 1.0;
16pub const MENU_INNER_PAD: f32 = 4.0;
17/// Gap kept between the menu and the window edge when it must be nudged
18/// on-screen. Small so the menu still visually hugs the pointer.
19pub const MENU_SCREEN_MARGIN: f32 = 4.0;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum MenuAction {
23    SelectColumn,
24    CopyColumn,
25    CopyColumnWithHeaders,
26    SortAscending,
27    SortDescending,
28    ClearSort,
29    GroupBy,
30    ClearGrouping,
31    FilterPrompt,
32    ClearFilter,
33}
34
35#[derive(Clone, Debug)]
36pub enum MenuItem {
37    Action(MenuAction),
38    Custom { id: String, label: String },
39    Separator,
40}
41
42impl MenuItem {
43    /// Display label for the item, or `None` for separators.
44    #[must_use]
45    pub fn label(&self) -> Option<&str> {
46        match self {
47            Self::Action(a) => Some(label(*a)),
48            Self::Custom { label, .. } => Some(label.as_str()),
49            Self::Separator => None,
50        }
51    }
52
53    /// `true` for action/custom items that participate in hover/click.
54    #[must_use]
55    pub fn is_selectable(&self) -> bool {
56        !matches!(self, Self::Separator)
57    }
58}
59
60#[derive(Clone, Debug)]
61pub struct ContextMenu {
62    pub col: usize,
63    pub anchor: Point<Pixels>,
64    pub items: Vec<MenuItem>,
65    pub hovered: Option<usize>,
66    pub request: Option<ContextMenuRequest>,
67}
68
69impl ContextMenu {
70    /// Standard column-header menu. Constructed by state when the user
71    /// right-clicks a column header or sort button.
72    #[must_use]
73    pub fn standard(col: usize, anchor: Point<Pixels>) -> Self {
74        Self {
75            col,
76            anchor,
77            items: vec![
78                MenuItem::Action(MenuAction::SelectColumn),
79                MenuItem::Action(MenuAction::CopyColumn),
80                MenuItem::Action(MenuAction::CopyColumnWithHeaders),
81                MenuItem::Separator,
82                MenuItem::Action(MenuAction::SortAscending),
83                MenuItem::Action(MenuAction::SortDescending),
84                MenuItem::Action(MenuAction::ClearSort),
85                MenuItem::Separator,
86                MenuItem::Action(MenuAction::GroupBy),
87                MenuItem::Action(MenuAction::ClearGrouping),
88                MenuItem::Separator,
89                MenuItem::Action(MenuAction::FilterPrompt),
90                MenuItem::Action(MenuAction::ClearFilter),
91            ],
92            hovered: None,
93            request: None,
94        }
95    }
96
97    /// Construct a custom menu from provider-supplied items plus the
98    /// captured request snapshot. `col` is used for built-in action
99    /// dispatch when the provider composes `BuiltIn` items.
100    #[must_use]
101    pub fn custom(
102        col: usize,
103        anchor: Point<Pixels>,
104        items: Vec<MenuItem>,
105        request: ContextMenuRequest,
106    ) -> Self {
107        Self {
108            col,
109            anchor,
110            items,
111            hovered: None,
112            request: Some(request),
113        }
114    }
115
116    /// Width needed to fit the longest label, with padding, bounded below by
117    /// [`MENU_MIN_WIDTH`].
118    #[must_use]
119    pub fn width_for(&self, char_width: f32) -> f32 {
120        let mut max_label_w = 0.0_f32;
121        for item in &self.items {
122            if let Some(text) = item.label() {
123                max_label_w = max_label_w.max(text.chars().count() as f32 * char_width);
124            }
125        }
126        MENU_MIN_WIDTH.max(max_label_w + MENU_PADDING_X * 2.0)
127    }
128
129    /// Total height including inner padding.
130    #[must_use]
131    pub fn total_height(&self) -> f32 {
132        self.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0
133    }
134
135    /// Resolve the menu's final top-left corner in **grid-relative** space,
136    /// given the grid origin in window space (`grid_ox`, `grid_oy`) and the
137    /// window viewport size (`vw`, `vh`).
138    ///
139    /// The menu must never be clipped by the grid area — only the window edge
140    /// constrains it. Vertically it always opens *downward* from the anchor
141    /// unless the full menu would not fit below the anchor within the window,
142    /// in which case it flips to open *upward* (anchored so its bottom sits at
143    /// the anchor). Horizontally it shifts left to stay on-screen.
144    ///
145    /// Returned in grid-relative coordinates so it composes directly with
146    /// [`hover_at`] and the widget's grid-relative pointer math. Paint adds the
147    /// grid origin back to reach absolute window space.
148    #[must_use]
149    pub fn resolved_position(
150        &self,
151        grid_ox: f32,
152        grid_oy: f32,
153        vw: f32,
154        vh: f32,
155        char_width: f32,
156    ) -> Point<Pixels> {
157        let menu_w = self.width_for(char_width);
158        let menu_h = self.total_height();
159        // Desired top-left in absolute window space.
160        let ax = grid_ox + f32::from(self.anchor.x);
161        let ay = grid_oy + f32::from(self.anchor.y);
162
163        // Horizontal: keep the whole menu inside the window, never clipped by
164        // the grid. Prefer the anchor x; shift left if the right edge spills
165        // past the window; never let the left edge go off-screen.
166        let mut mx = ax;
167        if mx + menu_w > vw {
168            mx = vw - menu_w - MENU_SCREEN_MARGIN;
169        }
170        if mx < MENU_SCREEN_MARGIN {
171            mx = MENU_SCREEN_MARGIN;
172        }
173
174        // Vertical: open down by default. Flip up only when there is literally
175        // no room for the full menu below the anchor within the window.
176        let opens_down = ay + menu_h + MENU_SCREEN_MARGIN <= vh;
177        let mut my = if opens_down {
178            ay
179        } else {
180            // Open upward: anchor the menu's bottom at the click point.
181            ay - menu_h
182        };
183        // Final safety: never let the menu run off the top of the window. This
184        // can only trigger for a menu taller than the whole viewport.
185        if my < MENU_SCREEN_MARGIN {
186            my = MENU_SCREEN_MARGIN;
187        }
188
189        // Convert back to grid-relative space for downstream consumers.
190        Point {
191            x: px(mx - grid_ox),
192            y: px(my - grid_oy),
193        }
194    }
195}
196
197/// Maps an action to its user-facing label. Used by hit-testing, paint, and
198/// any overlay that needs to show the same string the menu shows.
199#[must_use]
200pub fn label(action: MenuAction) -> &'static str {
201    match action {
202        MenuAction::SelectColumn => "Select column",
203        MenuAction::CopyColumn => "Copy column",
204        MenuAction::CopyColumnWithHeaders => "Copy column with headers",
205        MenuAction::SortAscending => "Sort Ascending",
206        MenuAction::SortDescending => "Sort Descending",
207        MenuAction::ClearSort => "Clear sort",
208        MenuAction::GroupBy => "Group by this column",
209        MenuAction::ClearGrouping => "Clear grouping",
210        MenuAction::FilterPrompt => "Filter...",
211        MenuAction::ClearFilter => "Clear filter",
212    }
213}
214
215/// Index of the hovered action under `x` (content-space) given the
216/// caller's full `y`. The caller supplies `y` because the menu overlay is
217/// drawn outside the bounds; we don't double-correct it here.
218///
219/// Uses the menu's stored anchor. When the menu has been repositioned to stay
220/// on-screen (flip up / shift left), callers must use [`hover_at_anchor`] with
221/// the resolved top-left so hit-testing matches paint.
222#[must_use]
223pub fn hover_at(menu: &ContextMenu, x: f32, y: f32, char_width: f32) -> Option<usize> {
224    hover_at_anchor(menu, menu.anchor, x, y, char_width)
225}
226
227/// Like [`hover_at`] but tests against an explicit resolved top-left `anchor`
228/// (grid-relative) rather than the menu's stored anchor. Keeps hover/click
229/// hit-testing aligned with the on-screen position produced by
230/// [`ContextMenu::resolved_position`].
231#[must_use]
232pub fn hover_at_anchor(
233    menu: &ContextMenu,
234    anchor: Point<Pixels>,
235    x: f32,
236    y: f32,
237    char_width: f32,
238) -> Option<usize> {
239    let w = menu.width_for(char_width);
240    let ax: f32 = anchor.x.into();
241    let ay: f32 = anchor.y.into();
242    if x < ax || x > ax + w || y < ay {
243        return None;
244    }
245    let rel_y = y - ay - MENU_INNER_PAD;
246    if rel_y < 0.0 {
247        return None;
248    }
249    let idx = (rel_y / MENU_ITEM_HEIGHT) as usize;
250    if idx >= menu.items.len() {
251        return None;
252    }
253    for (cur_row, item) in menu.items.iter().enumerate() {
254        if cur_row == idx {
255            return match item {
256                MenuItem::Action(_) | MenuItem::Custom { .. } => action_index(&menu.items, idx),
257                MenuItem::Separator => None,
258            };
259        }
260    }
261    None
262}
263
264fn action_index(items: &[MenuItem], row: usize) -> Option<usize> {
265    let mut action_idx = 0;
266    for (i, item) in items.iter().enumerate() {
267        if item.is_selectable() {
268            if i == row {
269                return Some(action_idx);
270            }
271            action_idx += 1;
272        }
273    }
274    None
275}
276
277/// Stable palette for menu chrome.
278#[deprecated(
279    since = "3.0.0",
280    note = "menu chrome is themed; use `GridTheme::menu_bg` instead"
281)]
282#[must_use]
283pub fn background() -> Hsla {
284    Hsla {
285        h: 0.0,
286        s: 0.0,
287        l: 1.0,
288        a: 1.0,
289    }
290}
291
292#[cfg(test)]
293#[allow(
294    clippy::unwrap_used,
295    clippy::expect_used,
296    clippy::field_reassign_with_default
297)]
298mod tests {
299    use super::*;
300
301    fn menu_at(x: f32, y: f32) -> ContextMenu {
302        ContextMenu::standard(7, point_from(x, y))
303    }
304
305    fn point_from(x: f32, y: f32) -> Point<Pixels> {
306        Point { x: px(x), y: px(y) }
307    }
308
309    fn anchor_y(m: &ContextMenu) -> f32 {
310        f32::from(m.anchor.y)
311    }
312
313    #[test]
314    fn standard_menu_item_sequence_is_stable() {
315        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
316        let kinds: Vec<&'static str> = m
317            .items
318            .iter()
319            .map(|i| match i {
320                MenuItem::Action(MenuAction::SelectColumn) => "SelectColumn",
321                MenuItem::Action(MenuAction::CopyColumn) => "CopyColumn",
322                MenuItem::Action(MenuAction::CopyColumnWithHeaders) => "CopyColumnWithHeaders",
323                MenuItem::Separator => "Separator",
324                MenuItem::Action(MenuAction::SortAscending) => "SortAscending",
325                MenuItem::Action(MenuAction::SortDescending) => "SortDescending",
326                MenuItem::Action(MenuAction::ClearSort) => "ClearSort",
327                MenuItem::Action(MenuAction::GroupBy) => "GroupBy",
328                MenuItem::Action(MenuAction::ClearGrouping) => "ClearGrouping",
329                MenuItem::Action(MenuAction::FilterPrompt) => "FilterPrompt",
330                MenuItem::Action(MenuAction::ClearFilter) => "ClearFilter",
331                MenuItem::Custom { .. } => "Custom",
332            })
333            .collect();
334        assert_eq!(
335            kinds,
336            [
337                "SelectColumn",
338                "CopyColumn",
339                "CopyColumnWithHeaders",
340                "Separator",
341                "SortAscending",
342                "SortDescending",
343                "ClearSort",
344                "Separator",
345                "GroupBy",
346                "ClearGrouping",
347                "Separator",
348                "FilterPrompt",
349                "ClearFilter",
350            ],
351        );
352    }
353
354    #[test]
355    fn separators_break_menu_into_action_groups() {
356        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
357        let separators = m
358            .items
359            .iter()
360            .filter(|i| matches!(i, MenuItem::Separator))
361            .count();
362        assert_eq!(separators, 3);
363    }
364
365    #[test]
366    fn every_menu_action_has_non_empty_label() {
367        for a in [
368            MenuAction::SelectColumn,
369            MenuAction::CopyColumn,
370            MenuAction::CopyColumnWithHeaders,
371            MenuAction::SortAscending,
372            MenuAction::SortDescending,
373            MenuAction::ClearSort,
374            MenuAction::GroupBy,
375            MenuAction::ClearGrouping,
376            MenuAction::FilterPrompt,
377            MenuAction::ClearFilter,
378        ] {
379            assert!(!label(a).is_empty(), "{a:?} has empty label");
380        }
381    }
382
383    #[test]
384    fn width_respects_min_width() {
385        let m = menu_at(0.0, 0.0);
386        assert!(m.width_for(1.0) >= MENU_MIN_WIDTH);
387    }
388
389    #[test]
390    fn width_grows_with_longest_label() {
391        let m = menu_at(0.0, 0.0);
392        let narrow = m.width_for(1.0);
393        let wide = m.width_for(20.0);
394        assert!(wide > narrow);
395    }
396
397    #[test]
398    fn total_height_matches_items_and_padding() {
399        let m = menu_at(0.0, 0.0);
400        let expected = m.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0;
401        assert_eq!(m.total_height(), expected);
402    }
403
404    #[test]
405    fn hover_returns_none_outside_x_bounds() {
406        let m = menu_at(100.0, 100.0);
407        let right = m.width_for(8.0);
408        assert_eq!(hover_at(&m, 99.0, 110.0, 8.0), None);
409        assert_eq!(hover_at(&m, 100.0 + right + 1.0, 110.0, 8.0), None);
410    }
411
412    #[test]
413    fn hover_returns_none_above_anchor() {
414        let m = menu_at(100.0, 100.0);
415        assert_eq!(hover_at(&m, 110.0, 99.0, 8.0), None);
416    }
417
418    #[test]
419    fn hover_on_first_action_returns_action_index_zero() {
420        let m = menu_at(100.0, 100.0);
421        let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
422        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
423    }
424
425    #[test]
426    fn hover_on_separator_returns_none() {
427        let m = menu_at(100.0, 100.0);
428        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 3.0 * MENU_ITEM_HEIGHT;
429        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
430    }
431
432    #[test]
433    fn hover_below_last_item_is_none() {
434        let m = menu_at(100.0, 100.0);
435        let y: f32 = anchor_y(&m) + 1000.0;
436        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
437    }
438
439    fn custom_menu_with_items(x: f32, y: f32, items: Vec<MenuItem>) -> ContextMenu {
440        ContextMenu {
441            col: 0,
442            anchor: point_from(x, y),
443            items,
444            hovered: None,
445            request: None,
446        }
447    }
448
449    #[test]
450    fn custom_item_contributes_to_width() {
451        let long_label = "A very long custom menu item label";
452        let items = vec![
453            MenuItem::Custom {
454                id: "a".into(),
455                label: long_label.into(),
456            },
457            MenuItem::Separator,
458        ];
459        let m = custom_menu_with_items(0.0, 0.0, items);
460        let w = m.width_for(8.0);
461        let expected = long_label.chars().count() as f32 * 8.0 + MENU_PADDING_X * 2.0;
462        assert_eq!(w, expected);
463    }
464
465    #[test]
466    fn custom_item_is_selectable_and_hoverable() {
467        let items = vec![
468            MenuItem::Custom {
469                id: "first".into(),
470                label: "First".into(),
471            },
472            MenuItem::Separator,
473            MenuItem::Custom {
474                id: "third".into(),
475                label: "Third".into(),
476            },
477        ];
478        let m = custom_menu_with_items(100.0, 100.0, items);
479        // First custom item at index 0.
480        let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
481        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
482        // Separator at row 1 returns None.
483        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 1.0 * MENU_ITEM_HEIGHT;
484        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
485        // Third item (second custom) at row 2 -> action index 1.
486        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 2.0 * MENU_ITEM_HEIGHT;
487        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(1));
488    }
489
490    #[test]
491    fn menu_item_label_helper() {
492        assert_eq!(
493            MenuItem::Action(MenuAction::SortAscending).label(),
494            Some("Sort Ascending")
495        );
496        assert_eq!(
497            MenuItem::Custom {
498                id: "x".into(),
499                label: "Hello".into()
500            }
501            .label(),
502            Some("Hello")
503        );
504        assert_eq!(MenuItem::Separator.label(), None);
505    }
506
507    #[test]
508    fn menu_item_is_selectable() {
509        assert!(MenuItem::Action(MenuAction::ClearFilter).is_selectable());
510        assert!(MenuItem::Custom {
511            id: "x".into(),
512            label: "y".into()
513        }
514        .is_selectable());
515        assert!(!MenuItem::Separator.is_selectable());
516    }
517
518    // --- resolved_position: never-clip + up/down flip ------------------------
519
520    /// With a large window and the anchor near the top, the menu opens straight
521    /// down from the anchor (position unchanged).
522    #[test]
523    fn resolved_opens_down_when_room_below() {
524        let m = menu_at(50.0, 30.0);
525        // grid origin at window (0,0), big window.
526        let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
527        assert_eq!(f32::from(p.x), 50.0);
528        assert_eq!(f32::from(p.y), 30.0);
529    }
530
531    /// When the full menu would not fit below the anchor within the window, it
532    /// flips up: its bottom sits at the anchor (top = anchor_y - height).
533    #[test]
534    fn resolved_flips_up_when_no_room_below() {
535        let m = menu_at(50.0, 590.0);
536        let h = m.total_height();
537        // Window only 600 tall; anchor at y=590 leaves no room for the menu.
538        let p = m.resolved_position(0.0, 0.0, 2000.0, 600.0, 8.0);
539        assert_eq!(f32::from(p.y), 590.0 - h);
540    }
541
542    /// The menu is clamped to the *window* width, not the grid width — proving
543    /// it is never clipped by a grid area smaller than the window. Grid is only
544    /// 300 wide but the window is 2000 wide, so a menu near the grid's right
545    /// edge still extends past the grid without being pulled in.
546    #[test]
547    fn resolved_not_clipped_by_grid_only_by_window() {
548        let m = menu_at(280.0, 30.0);
549        let w = m.width_for(8.0);
550        // Grid origin at window (0,0); grid is 300 wide (sw), window 2000 wide.
551        let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
552        // Stays at the anchor x — not shifted to fit inside the 300px grid.
553        assert_eq!(f32::from(p.x), 280.0);
554        // And its right edge is allowed to exceed the grid width (300).
555        assert!(f32::from(p.x) + w > 300.0);
556    }
557
558    /// When the anchor is close to the window's right edge, the menu shifts
559    /// left so its right edge stays inside the window.
560    #[test]
561    fn resolved_shifts_left_at_window_right_edge() {
562        let m = menu_at(1950.0, 30.0);
563        let w = m.width_for(8.0);
564        let vw = 2000.0;
565        let p = m.resolved_position(0.0, 0.0, vw, 2000.0, 8.0);
566        let right = f32::from(p.x) + w;
567        assert!(right <= vw, "menu right edge {right} must stay within {vw}");
568        assert_eq!(f32::from(p.x), vw - w - MENU_SCREEN_MARGIN);
569    }
570
571    /// The grid origin offset is honored: a grid placed at a window offset
572    /// shifts the absolute placement but the returned value stays grid-relative.
573    #[test]
574    fn resolved_accounts_for_grid_origin() {
575        let m = menu_at(10.0, 10.0);
576        // Grid origin at (100, 200) in the window; plenty of room below.
577        let p = m.resolved_position(100.0, 200.0, 2000.0, 2000.0, 8.0);
578        // Grid-relative result is unchanged because absolute (110,210) fits.
579        assert_eq!(f32::from(p.x), 10.0);
580        assert_eq!(f32::from(p.y), 10.0);
581    }
582}