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