1use gpui::{Hsla, Pixels, Point};
6
7pub 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 #[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 #[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 #[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#[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#[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#[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}