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
7/// Height, padding, and minimum width used to lay the menu out. Public so the
8/// state module's hit-testing math can stay in sync with paint.
9pub const MENU_FONT_SIZE: f32 = 14.0;
10pub const MENU_ITEM_HEIGHT: f32 = MENU_FONT_SIZE + 8.0;
11pub const MENU_PADDING_X: f32 = 12.0;
12pub const MENU_MIN_WIDTH: f32 = 180.0;
13pub const MENU_BORDER: f32 = 1.0;
14pub const MENU_INNER_PAD: f32 = 4.0;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum MenuAction {
18    SelectColumn,
19    CopyColumn,
20    CopyColumnWithHeaders,
21    SortAscending,
22    SortDescending,
23    ClearSort,
24    FilterPrompt,
25    ClearFilter,
26}
27
28#[derive(Clone, Debug)]
29pub enum MenuItem {
30    Action(MenuAction),
31    Separator,
32}
33
34#[derive(Clone, Debug)]
35pub struct ContextMenu {
36    pub col: usize,
37    pub anchor: Point<Pixels>,
38    pub items: Vec<MenuItem>,
39    pub hovered: Option<usize>,
40}
41
42impl ContextMenu {
43    /// Standard column-header menu. Constructed by state when the user
44    /// right-clicks a column header or sort button.
45    #[must_use]
46    pub fn standard(col: usize, anchor: Point<Pixels>) -> Self {
47        Self {
48            col,
49            anchor,
50            items: vec![
51                MenuItem::Action(MenuAction::SelectColumn),
52                MenuItem::Action(MenuAction::CopyColumn),
53                MenuItem::Action(MenuAction::CopyColumnWithHeaders),
54                MenuItem::Separator,
55                MenuItem::Action(MenuAction::SortAscending),
56                MenuItem::Action(MenuAction::SortDescending),
57                MenuItem::Action(MenuAction::ClearSort),
58                MenuItem::Separator,
59                MenuItem::Action(MenuAction::FilterPrompt),
60                MenuItem::Action(MenuAction::ClearFilter),
61            ],
62            hovered: None,
63        }
64    }
65
66    /// Width needed to fit the longest label, with padding, bounded below by
67    /// [`MENU_MIN_WIDTH`].
68    #[must_use]
69    pub fn width_for(&self, char_width: f32) -> f32 {
70        let mut max_label_w = 0.0_f32;
71        for item in &self.items {
72            if let MenuItem::Action(a) = item {
73                max_label_w = max_label_w.max(label(*a).len() as f32 * char_width);
74            }
75        }
76        MENU_MIN_WIDTH.max(max_label_w + MENU_PADDING_X * 2.0)
77    }
78
79    /// Total height including inner padding.
80    #[must_use]
81    pub fn total_height(&self) -> f32 {
82        self.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0
83    }
84}
85
86/// Maps an action to its user-facing label. Used by hit-testing, paint, and
87/// any overlay that needs to show the same string the menu shows.
88#[must_use]
89pub fn label(action: MenuAction) -> &'static str {
90    match action {
91        MenuAction::SelectColumn => "Select column",
92        MenuAction::CopyColumn => "Copy column",
93        MenuAction::CopyColumnWithHeaders => "Copy column with headers",
94        MenuAction::SortAscending => "Sort Ascending",
95        MenuAction::SortDescending => "Sort Descending",
96        MenuAction::ClearSort => "Clear sort",
97        MenuAction::FilterPrompt => "Filter...",
98        MenuAction::ClearFilter => "Clear filter",
99    }
100}
101
102/// Index of the hovered action under `x` (content-space) given the
103/// caller's full `y`. The caller supplies `y` because the menu overlay is
104/// drawn outside the bounds; we don't double-correct it here.
105#[must_use]
106pub fn hover_at(menu: &ContextMenu, x: f32, y: f32, char_width: f32) -> Option<usize> {
107    let w = menu.width_for(char_width);
108    let ax: f32 = menu.anchor.x.into();
109    let ay: f32 = menu.anchor.y.into();
110    if x < ax || x > ax + w || y < ay {
111        return None;
112    }
113    let rel_y = y - ay - MENU_INNER_PAD;
114    if rel_y < 0.0 {
115        return None;
116    }
117    let idx = (rel_y / MENU_ITEM_HEIGHT) as usize;
118    if idx >= menu.items.len() {
119        return None;
120    }
121    for (cur_row, item) in menu.items.iter().enumerate() {
122        if cur_row == idx {
123            return match item {
124                MenuItem::Action(_) => action_index(&menu.items, idx),
125                MenuItem::Separator => None,
126            };
127        }
128    }
129    None
130}
131
132fn action_index(items: &[MenuItem], row: usize) -> Option<usize> {
133    let mut action_idx = 0;
134    for (i, item) in items.iter().enumerate() {
135        if matches!(item, MenuItem::Action(_)) {
136            if i == row {
137                return Some(action_idx);
138            }
139            action_idx += 1;
140        }
141    }
142    None
143}
144
145/// Stable palette for menu chrome.
146#[must_use]
147pub fn background() -> Hsla {
148    Hsla {
149        h: 0.0,
150        s: 0.0,
151        l: 1.0,
152        a: 1.0,
153    }
154}
155
156#[cfg(test)]
157#[allow(
158    clippy::unwrap_used,
159    clippy::expect_used,
160    clippy::field_reassign_with_default
161)]
162mod tests {
163    use super::*;
164    use gpui::px;
165
166    fn menu_at(x: f32, y: f32) -> ContextMenu {
167        ContextMenu::standard(7, point_from(x, y))
168    }
169
170    fn point_from(x: f32, y: f32) -> Point<Pixels> {
171        Point { x: px(x), y: px(y) }
172    }
173
174    fn anchor_y(m: &ContextMenu) -> f32 {
175        f32::from(m.anchor.y)
176    }
177
178    #[test]
179    fn standard_menu_item_sequence_is_stable() {
180        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
181        let kinds: Vec<&'static str> = m
182            .items
183            .iter()
184            .map(|i| match i {
185                MenuItem::Action(MenuAction::SelectColumn) => "SelectColumn",
186                MenuItem::Action(MenuAction::CopyColumn) => "CopyColumn",
187                MenuItem::Action(MenuAction::CopyColumnWithHeaders) => "CopyColumnWithHeaders",
188                MenuItem::Separator => "Separator",
189                MenuItem::Action(MenuAction::SortAscending) => "SortAscending",
190                MenuItem::Action(MenuAction::SortDescending) => "SortDescending",
191                MenuItem::Action(MenuAction::ClearSort) => "ClearSort",
192                MenuItem::Action(MenuAction::FilterPrompt) => "FilterPrompt",
193                MenuItem::Action(MenuAction::ClearFilter) => "ClearFilter",
194            })
195            .collect();
196        assert_eq!(
197            kinds,
198            [
199                "SelectColumn",
200                "CopyColumn",
201                "CopyColumnWithHeaders",
202                "Separator",
203                "SortAscending",
204                "SortDescending",
205                "ClearSort",
206                "Separator",
207                "FilterPrompt",
208                "ClearFilter",
209            ],
210        );
211    }
212
213    #[test]
214    fn at_least_two_separators_break_three_groups() {
215        let m = ContextMenu::standard(0, point_from(0.0, 0.0));
216        let separators = m
217            .items
218            .iter()
219            .filter(|i| matches!(i, MenuItem::Separator))
220            .count();
221        assert_eq!(separators, 2);
222    }
223
224    #[test]
225    fn every_menu_action_has_non_empty_label() {
226        for a in [
227            MenuAction::SelectColumn,
228            MenuAction::CopyColumn,
229            MenuAction::CopyColumnWithHeaders,
230            MenuAction::SortAscending,
231            MenuAction::SortDescending,
232            MenuAction::ClearSort,
233            MenuAction::FilterPrompt,
234            MenuAction::ClearFilter,
235        ] {
236            assert!(!label(a).is_empty(), "{a:?} has empty label");
237        }
238    }
239
240    #[test]
241    fn width_respects_min_width() {
242        let m = menu_at(0.0, 0.0);
243        assert!(m.width_for(1.0) >= MENU_MIN_WIDTH);
244    }
245
246    #[test]
247    fn width_grows_with_longest_label() {
248        let m = menu_at(0.0, 0.0);
249        let narrow = m.width_for(1.0);
250        let wide = m.width_for(20.0);
251        assert!(wide > narrow);
252    }
253
254    #[test]
255    fn total_height_matches_items_and_padding() {
256        let m = menu_at(0.0, 0.0);
257        let expected = m.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0;
258        assert_eq!(m.total_height(), expected);
259    }
260
261    #[test]
262    fn hover_returns_none_outside_x_bounds() {
263        let m = menu_at(100.0, 100.0);
264        let right = m.width_for(8.0);
265        assert_eq!(hover_at(&m, 99.0, 110.0, 8.0), None);
266        assert_eq!(hover_at(&m, 100.0 + right + 1.0, 110.0, 8.0), None);
267    }
268
269    #[test]
270    fn hover_returns_none_above_anchor() {
271        let m = menu_at(100.0, 100.0);
272        assert_eq!(hover_at(&m, 110.0, 99.0, 8.0), None);
273    }
274
275    #[test]
276    fn hover_on_first_action_returns_action_index_zero() {
277        let m = menu_at(100.0, 100.0);
278        let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
279        assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
280    }
281
282    #[test]
283    fn hover_on_separator_returns_none() {
284        let m = menu_at(100.0, 100.0);
285        let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 3.0 * MENU_ITEM_HEIGHT;
286        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
287    }
288
289    #[test]
290    fn hover_below_last_item_is_none() {
291        let m = menu_at(100.0, 100.0);
292        let y: f32 = anchor_y(&m) + 1000.0;
293        assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
294    }
295}