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::{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
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum MenuAction {
20    SelectColumn,
21    CopyColumn,
22    CopyColumnWithHeaders,
23    SortAscending,
24    SortDescending,
25    ClearSort,
26    FilterPrompt,
27    ClearFilter,
28}
29
30#[derive(Clone, Debug)]
31pub enum MenuItem {
32    Action(MenuAction),
33    Custom { id: String, label: String },
34    Separator,
35}
36
37impl MenuItem {
38    /// Display label for the item, or `None` for separators.
39    #[must_use]
40    pub fn label(&self) -> Option<&str> {
41        match self {
42            Self::Action(a) => Some(label(*a)),
43            Self::Custom { label, .. } => Some(label.as_str()),
44            Self::Separator => None,
45        }
46    }
47
48    /// `true` for action/custom items that participate in hover/click.
49    #[must_use]
50    pub fn is_selectable(&self) -> bool {
51        !matches!(self, Self::Separator)
52    }
53}
54
55#[derive(Clone, Debug)]
56pub struct ContextMenu {
57    pub col: usize,
58    pub anchor: Point<Pixels>,
59    pub items: Vec<MenuItem>,
60    pub hovered: Option<usize>,
61    pub request: Option<ContextMenuRequest>,
62}
63
64impl ContextMenu {
65    /// Standard column-header menu. Constructed by state when the user
66    /// right-clicks a column header or sort button.
67    #[must_use]
68    pub fn standard(col: usize, anchor: Point<Pixels>) -> Self {
69        Self {
70            col,
71            anchor,
72            items: vec![
73                MenuItem::Action(MenuAction::SelectColumn),
74                MenuItem::Action(MenuAction::CopyColumn),
75                MenuItem::Action(MenuAction::CopyColumnWithHeaders),
76                MenuItem::Separator,
77                MenuItem::Action(MenuAction::SortAscending),
78                MenuItem::Action(MenuAction::SortDescending),
79                MenuItem::Action(MenuAction::ClearSort),
80                MenuItem::Separator,
81                MenuItem::Action(MenuAction::FilterPrompt),
82                MenuItem::Action(MenuAction::ClearFilter),
83            ],
84            hovered: None,
85            request: None,
86        }
87    }
88
89    /// Construct a custom menu from provider-supplied items plus the
90    /// captured request snapshot. `col` is used for built-in action
91    /// dispatch when the provider composes `BuiltIn` items.
92    #[must_use]
93    pub fn custom(
94        col: usize,
95        anchor: Point<Pixels>,
96        items: Vec<MenuItem>,
97        request: ContextMenuRequest,
98    ) -> Self {
99        Self {
100            col,
101            anchor,
102            items,
103            hovered: None,
104            request: Some(request),
105        }
106    }
107
108    /// Width needed to fit the longest label, with padding, bounded below by
109    /// [`MENU_MIN_WIDTH`].
110    #[must_use]
111    pub fn width_for(&self, char_width: f32) -> f32 {
112        let mut max_label_w = 0.0_f32;
113        for item in &self.items {
114            if let Some(text) = item.label() {
115                max_label_w = max_label_w.max(text.chars().count() as f32 * char_width);
116            }
117        }
118        MENU_MIN_WIDTH.max(max_label_w + MENU_PADDING_X * 2.0)
119    }
120
121    /// Total height including inner padding.
122    #[must_use]
123    pub fn total_height(&self) -> f32 {
124        self.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0
125    }
126}
127
128/// Maps an action to its user-facing label. Used by hit-testing, paint, and
129/// any overlay that needs to show the same string the menu shows.
130#[must_use]
131pub fn label(action: MenuAction) -> &'static str {
132    match action {
133        MenuAction::SelectColumn => "Select column",
134        MenuAction::CopyColumn => "Copy column",
135        MenuAction::CopyColumnWithHeaders => "Copy column with headers",
136        MenuAction::SortAscending => "Sort Ascending",
137        MenuAction::SortDescending => "Sort Descending",
138        MenuAction::ClearSort => "Clear sort",
139        MenuAction::FilterPrompt => "Filter...",
140        MenuAction::ClearFilter => "Clear filter",
141    }
142}
143
144/// Index of the hovered action under `x` (content-space) given the
145/// caller's full `y`. The caller supplies `y` because the menu overlay is
146/// drawn outside the bounds; we don't double-correct it here.
147#[must_use]
148pub fn hover_at(menu: &ContextMenu, x: f32, y: f32, char_width: f32) -> Option<usize> {
149    let w = menu.width_for(char_width);
150    let ax: f32 = menu.anchor.x.into();
151    let ay: f32 = menu.anchor.y.into();
152    if x < ax || x > ax + w || y < ay {
153        return None;
154    }
155    let rel_y = y - ay - MENU_INNER_PAD;
156    if rel_y < 0.0 {
157        return None;
158    }
159    let idx = (rel_y / MENU_ITEM_HEIGHT) as usize;
160    if idx >= menu.items.len() {
161        return None;
162    }
163    for (cur_row, item) in menu.items.iter().enumerate() {
164        if cur_row == idx {
165            return match item {
166                MenuItem::Action(_) | MenuItem::Custom { .. } => action_index(&menu.items, idx),
167                MenuItem::Separator => None,
168            };
169        }
170    }
171    None
172}
173
174fn action_index(items: &[MenuItem], row: usize) -> Option<usize> {
175    let mut action_idx = 0;
176    for (i, item) in items.iter().enumerate() {
177        if item.is_selectable() {
178            if i == row {
179                return Some(action_idx);
180            }
181            action_idx += 1;
182        }
183    }
184    None
185}
186
187/// Stable palette for menu chrome.
188#[must_use]
189pub fn background() -> Hsla {
190    Hsla {
191        h: 0.0,
192        s: 0.0,
193        l: 1.0,
194        a: 1.0,
195    }
196}
197
198#[cfg(test)]
199#[allow(
200    clippy::unwrap_used,
201    clippy::expect_used,
202    clippy::field_reassign_with_default
203)]
204mod tests {
205    use super::*;
206    use gpui::px;
207
208    fn menu_at(x: f32, y: f32) -> ContextMenu {
209        ContextMenu::standard(7, point_from(x, y))
210    }
211
212    fn point_from(x: f32, y: f32) -> Point<Pixels> {
213        Point { x: px(x), y: px(y) }
214    }
215
216    fn anchor_y(m: &ContextMenu) -> f32 {
217        f32::from(m.anchor.y)
218    }
219
220    #[test]
221    fn standard_menu_item_sequence_is_stable() {
222        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
223        let kinds: Vec<&'static str> = m
224            .items
225            .iter()
226            .map(|i| match i {
227                MenuItem::Action(MenuAction::SelectColumn) => "SelectColumn",
228                MenuItem::Action(MenuAction::CopyColumn) => "CopyColumn",
229                MenuItem::Action(MenuAction::CopyColumnWithHeaders) => "CopyColumnWithHeaders",
230                MenuItem::Separator => "Separator",
231                MenuItem::Action(MenuAction::SortAscending) => "SortAscending",
232                MenuItem::Action(MenuAction::SortDescending) => "SortDescending",
233                MenuItem::Action(MenuAction::ClearSort) => "ClearSort",
234                MenuItem::Action(MenuAction::FilterPrompt) => "FilterPrompt",
235                MenuItem::Action(MenuAction::ClearFilter) => "ClearFilter",
236                MenuItem::Custom { .. } => "Custom",
237            })
238            .collect();
239        assert_eq!(
240            kinds,
241            [
242                "SelectColumn",
243                "CopyColumn",
244                "CopyColumnWithHeaders",
245                "Separator",
246                "SortAscending",
247                "SortDescending",
248                "ClearSort",
249                "Separator",
250                "FilterPrompt",
251                "ClearFilter",
252            ],
253        );
254    }
255
256    #[test]
257    fn at_least_two_separators_break_three_groups() {
258        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
259        let separators = m
260            .items
261            .iter()
262            .filter(|i| matches!(i, MenuItem::Separator))
263            .count();
264        assert_eq!(separators, 2);
265    }
266
267    #[test]
268    fn every_menu_action_has_non_empty_label() {
269        for a in [
270            MenuAction::SelectColumn,
271            MenuAction::CopyColumn,
272            MenuAction::CopyColumnWithHeaders,
273            MenuAction::SortAscending,
274            MenuAction::SortDescending,
275            MenuAction::ClearSort,
276            MenuAction::FilterPrompt,
277            MenuAction::ClearFilter,
278        ] {
279            assert!(!label(a).is_empty(), "{a:?} has empty label");
280        }
281    }
282
283    #[test]
284    fn width_respects_min_width() {
285        let m = menu_at(0.0, 0.0);
286        assert!(m.width_for(1.0) >= MENU_MIN_WIDTH);
287    }
288
289    #[test]
290    fn width_grows_with_longest_label() {
291        let m = menu_at(0.0, 0.0);
292        let narrow = m.width_for(1.0);
293        let wide = m.width_for(20.0);
294        assert!(wide > narrow);
295    }
296
297    #[test]
298    fn total_height_matches_items_and_padding() {
299        let m = menu_at(0.0, 0.0);
300        let expected = m.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0;
301        assert_eq!(m.total_height(), expected);
302    }
303
304    #[test]
305    fn hover_returns_none_outside_x_bounds() {
306        let m = menu_at(100.0, 100.0);
307        let right = m.width_for(8.0);
308        assert_eq!(hover_at(&m, 99.0, 110.0, 8.0), None);
309        assert_eq!(hover_at(&m, 100.0 + right + 1.0, 110.0, 8.0), None);
310    }
311
312    #[test]
313    fn hover_returns_none_above_anchor() {
314        let m = menu_at(100.0, 100.0);
315        assert_eq!(hover_at(&m, 110.0, 99.0, 8.0), None);
316    }
317
318    #[test]
319    fn hover_on_first_action_returns_action_index_zero() {
320        let m = menu_at(100.0, 100.0);
321        let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
322        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
323    }
324
325    #[test]
326    fn hover_on_separator_returns_none() {
327        let m = menu_at(100.0, 100.0);
328        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 3.0 * MENU_ITEM_HEIGHT;
329        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
330    }
331
332    #[test]
333    fn hover_below_last_item_is_none() {
334        let m = menu_at(100.0, 100.0);
335        let y: f32 = anchor_y(&m) + 1000.0;
336        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
337    }
338
339    fn custom_menu_with_items(x: f32, y: f32, items: Vec<MenuItem>) -> ContextMenu {
340        ContextMenu {
341            col: 0,
342            anchor: point_from(x, y),
343            items,
344            hovered: None,
345            request: None,
346        }
347    }
348
349    #[test]
350    fn custom_item_contributes_to_width() {
351        let long_label = "A very long custom menu item label";
352        let items = vec![
353            MenuItem::Custom {
354                id: "a".into(),
355                label: long_label.into(),
356            },
357            MenuItem::Separator,
358        ];
359        let m = custom_menu_with_items(0.0, 0.0, items);
360        let w = m.width_for(8.0);
361        let expected = long_label.chars().count() as f32 * 8.0 + MENU_PADDING_X * 2.0;
362        assert_eq!(w, expected);
363    }
364
365    #[test]
366    fn custom_item_is_selectable_and_hoverable() {
367        let items = vec![
368            MenuItem::Custom {
369                id: "first".into(),
370                label: "First".into(),
371            },
372            MenuItem::Separator,
373            MenuItem::Custom {
374                id: "third".into(),
375                label: "Third".into(),
376            },
377        ];
378        let m = custom_menu_with_items(100.0, 100.0, items);
379        // First custom item at index 0.
380        let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
381        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
382        // Separator at row 1 returns None.
383        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 1.0 * MENU_ITEM_HEIGHT;
384        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
385        // Third item (second custom) at row 2 -> action index 1.
386        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 2.0 * MENU_ITEM_HEIGHT;
387        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(1));
388    }
389
390    #[test]
391    fn menu_item_label_helper() {
392        assert_eq!(
393            MenuItem::Action(MenuAction::SortAscending).label(),
394            Some("Sort Ascending")
395        );
396        assert_eq!(
397            MenuItem::Custom {
398                id: "x".into(),
399                label: "Hello".into()
400            }
401            .label(),
402            Some("Hello")
403        );
404        assert_eq!(MenuItem::Separator.label(), None);
405    }
406
407    #[test]
408    fn menu_item_is_selectable() {
409        assert!(MenuItem::Action(MenuAction::ClearFilter).is_selectable());
410        assert!(MenuItem::Custom {
411            id: "x".into(),
412            label: "y".into()
413        }
414        .is_selectable());
415        assert!(!MenuItem::Separator.is_selectable());
416    }
417}