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#[must_use]
279pub fn background() -> Hsla {
280    Hsla {
281        h: 0.0,
282        s: 0.0,
283        l: 1.0,
284        a: 1.0,
285    }
286}
287
288#[cfg(test)]
289#[allow(
290    clippy::unwrap_used,
291    clippy::expect_used,
292    clippy::field_reassign_with_default
293)]
294mod tests {
295    use super::*;
296
297    fn menu_at(x: f32, y: f32) -> ContextMenu {
298        ContextMenu::standard(7, point_from(x, y))
299    }
300
301    fn point_from(x: f32, y: f32) -> Point<Pixels> {
302        Point { x: px(x), y: px(y) }
303    }
304
305    fn anchor_y(m: &ContextMenu) -> f32 {
306        f32::from(m.anchor.y)
307    }
308
309    #[test]
310    fn standard_menu_item_sequence_is_stable() {
311        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
312        let kinds: Vec<&'static str> = m
313            .items
314            .iter()
315            .map(|i| match i {
316                MenuItem::Action(MenuAction::SelectColumn) => "SelectColumn",
317                MenuItem::Action(MenuAction::CopyColumn) => "CopyColumn",
318                MenuItem::Action(MenuAction::CopyColumnWithHeaders) => "CopyColumnWithHeaders",
319                MenuItem::Separator => "Separator",
320                MenuItem::Action(MenuAction::SortAscending) => "SortAscending",
321                MenuItem::Action(MenuAction::SortDescending) => "SortDescending",
322                MenuItem::Action(MenuAction::ClearSort) => "ClearSort",
323                MenuItem::Action(MenuAction::GroupBy) => "GroupBy",
324                MenuItem::Action(MenuAction::ClearGrouping) => "ClearGrouping",
325                MenuItem::Action(MenuAction::FilterPrompt) => "FilterPrompt",
326                MenuItem::Action(MenuAction::ClearFilter) => "ClearFilter",
327                MenuItem::Custom { .. } => "Custom",
328            })
329            .collect();
330        assert_eq!(
331            kinds,
332            [
333                "SelectColumn",
334                "CopyColumn",
335                "CopyColumnWithHeaders",
336                "Separator",
337                "SortAscending",
338                "SortDescending",
339                "ClearSort",
340                "Separator",
341                "GroupBy",
342                "ClearGrouping",
343                "Separator",
344                "FilterPrompt",
345                "ClearFilter",
346            ],
347        );
348    }
349
350    #[test]
351    fn separators_break_menu_into_action_groups() {
352        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
353        let separators = m
354            .items
355            .iter()
356            .filter(|i| matches!(i, MenuItem::Separator))
357            .count();
358        assert_eq!(separators, 3);
359    }
360
361    #[test]
362    fn every_menu_action_has_non_empty_label() {
363        for a in [
364            MenuAction::SelectColumn,
365            MenuAction::CopyColumn,
366            MenuAction::CopyColumnWithHeaders,
367            MenuAction::SortAscending,
368            MenuAction::SortDescending,
369            MenuAction::ClearSort,
370            MenuAction::GroupBy,
371            MenuAction::ClearGrouping,
372            MenuAction::FilterPrompt,
373            MenuAction::ClearFilter,
374        ] {
375            assert!(!label(a).is_empty(), "{a:?} has empty label");
376        }
377    }
378
379    #[test]
380    fn width_respects_min_width() {
381        let m = menu_at(0.0, 0.0);
382        assert!(m.width_for(1.0) >= MENU_MIN_WIDTH);
383    }
384
385    #[test]
386    fn width_grows_with_longest_label() {
387        let m = menu_at(0.0, 0.0);
388        let narrow = m.width_for(1.0);
389        let wide = m.width_for(20.0);
390        assert!(wide > narrow);
391    }
392
393    #[test]
394    fn total_height_matches_items_and_padding() {
395        let m = menu_at(0.0, 0.0);
396        let expected = m.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0;
397        assert_eq!(m.total_height(), expected);
398    }
399
400    #[test]
401    fn hover_returns_none_outside_x_bounds() {
402        let m = menu_at(100.0, 100.0);
403        let right = m.width_for(8.0);
404        assert_eq!(hover_at(&m, 99.0, 110.0, 8.0), None);
405        assert_eq!(hover_at(&m, 100.0 + right + 1.0, 110.0, 8.0), None);
406    }
407
408    #[test]
409    fn hover_returns_none_above_anchor() {
410        let m = menu_at(100.0, 100.0);
411        assert_eq!(hover_at(&m, 110.0, 99.0, 8.0), None);
412    }
413
414    #[test]
415    fn hover_on_first_action_returns_action_index_zero() {
416        let m = menu_at(100.0, 100.0);
417        let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
418        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
419    }
420
421    #[test]
422    fn hover_on_separator_returns_none() {
423        let m = menu_at(100.0, 100.0);
424        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 3.0 * MENU_ITEM_HEIGHT;
425        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
426    }
427
428    #[test]
429    fn hover_below_last_item_is_none() {
430        let m = menu_at(100.0, 100.0);
431        let y: f32 = anchor_y(&m) + 1000.0;
432        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
433    }
434
435    fn custom_menu_with_items(x: f32, y: f32, items: Vec<MenuItem>) -> ContextMenu {
436        ContextMenu {
437            col: 0,
438            anchor: point_from(x, y),
439            items,
440            hovered: None,
441            request: None,
442        }
443    }
444
445    #[test]
446    fn custom_item_contributes_to_width() {
447        let long_label = "A very long custom menu item label";
448        let items = vec![
449            MenuItem::Custom {
450                id: "a".into(),
451                label: long_label.into(),
452            },
453            MenuItem::Separator,
454        ];
455        let m = custom_menu_with_items(0.0, 0.0, items);
456        let w = m.width_for(8.0);
457        let expected = long_label.chars().count() as f32 * 8.0 + MENU_PADDING_X * 2.0;
458        assert_eq!(w, expected);
459    }
460
461    #[test]
462    fn custom_item_is_selectable_and_hoverable() {
463        let items = vec![
464            MenuItem::Custom {
465                id: "first".into(),
466                label: "First".into(),
467            },
468            MenuItem::Separator,
469            MenuItem::Custom {
470                id: "third".into(),
471                label: "Third".into(),
472            },
473        ];
474        let m = custom_menu_with_items(100.0, 100.0, items);
475        // First custom item at index 0.
476        let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
477        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
478        // Separator at row 1 returns None.
479        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 1.0 * MENU_ITEM_HEIGHT;
480        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
481        // Third item (second custom) at row 2 -> action index 1.
482        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 2.0 * MENU_ITEM_HEIGHT;
483        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(1));
484    }
485
486    #[test]
487    fn menu_item_label_helper() {
488        assert_eq!(
489            MenuItem::Action(MenuAction::SortAscending).label(),
490            Some("Sort Ascending")
491        );
492        assert_eq!(
493            MenuItem::Custom {
494                id: "x".into(),
495                label: "Hello".into()
496            }
497            .label(),
498            Some("Hello")
499        );
500        assert_eq!(MenuItem::Separator.label(), None);
501    }
502
503    #[test]
504    fn menu_item_is_selectable() {
505        assert!(MenuItem::Action(MenuAction::ClearFilter).is_selectable());
506        assert!(MenuItem::Custom {
507            id: "x".into(),
508            label: "y".into()
509        }
510        .is_selectable());
511        assert!(!MenuItem::Separator.is_selectable());
512    }
513
514    // --- resolved_position: never-clip + up/down flip ------------------------
515
516    /// With a large window and the anchor near the top, the menu opens straight
517    /// down from the anchor (position unchanged).
518    #[test]
519    fn resolved_opens_down_when_room_below() {
520        let m = menu_at(50.0, 30.0);
521        // grid origin at window (0,0), big window.
522        let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
523        assert_eq!(f32::from(p.x), 50.0);
524        assert_eq!(f32::from(p.y), 30.0);
525    }
526
527    /// When the full menu would not fit below the anchor within the window, it
528    /// flips up: its bottom sits at the anchor (top = anchor_y - height).
529    #[test]
530    fn resolved_flips_up_when_no_room_below() {
531        let m = menu_at(50.0, 590.0);
532        let h = m.total_height();
533        // Window only 600 tall; anchor at y=590 leaves no room for the menu.
534        let p = m.resolved_position(0.0, 0.0, 2000.0, 600.0, 8.0);
535        assert_eq!(f32::from(p.y), 590.0 - h);
536    }
537
538    /// The menu is clamped to the *window* width, not the grid width — proving
539    /// it is never clipped by a grid area smaller than the window. Grid is only
540    /// 300 wide but the window is 2000 wide, so a menu near the grid's right
541    /// edge still extends past the grid without being pulled in.
542    #[test]
543    fn resolved_not_clipped_by_grid_only_by_window() {
544        let m = menu_at(280.0, 30.0);
545        let w = m.width_for(8.0);
546        // Grid origin at window (0,0); grid is 300 wide (sw), window 2000 wide.
547        let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
548        // Stays at the anchor x — not shifted to fit inside the 300px grid.
549        assert_eq!(f32::from(p.x), 280.0);
550        // And its right edge is allowed to exceed the grid width (300).
551        assert!(f32::from(p.x) + w > 300.0);
552    }
553
554    /// When the anchor is close to the window's right edge, the menu shifts
555    /// left so its right edge stays inside the window.
556    #[test]
557    fn resolved_shifts_left_at_window_right_edge() {
558        let m = menu_at(1950.0, 30.0);
559        let w = m.width_for(8.0);
560        let vw = 2000.0;
561        let p = m.resolved_position(0.0, 0.0, vw, 2000.0, 8.0);
562        let right = f32::from(p.x) + w;
563        assert!(right <= vw, "menu right edge {right} must stay within {vw}");
564        assert_eq!(f32::from(p.x), vw - w - MENU_SCREEN_MARGIN);
565    }
566
567    /// The grid origin offset is honored: a grid placed at a window offset
568    /// shifts the absolute placement but the returned value stays grid-relative.
569    #[test]
570    fn resolved_accounts_for_grid_origin() {
571        let m = menu_at(10.0, 10.0);
572        // Grid origin at (100, 200) in the window; plenty of room below.
573        let p = m.resolved_position(100.0, 200.0, 2000.0, 2000.0, 8.0);
574        // Grid-relative result is unchanged because absolute (110,210) fits.
575        assert_eq!(f32::from(p.x), 10.0);
576        assert_eq!(f32::from(p.y), 10.0);
577    }
578}