1use gpui::{px, Hsla, Pixels, Point};
6
7use crate::grid::context_menu::ContextMenuRequest;
8
9pub 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;
17pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let ax = grid_ox + f32::from(self.anchor.x);
161 let ay = grid_oy + f32::from(self.anchor.y);
162
163 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 let opens_down = ay + menu_h + MENU_SCREEN_MARGIN <= vh;
177 let mut my = if opens_down {
178 ay
179 } else {
180 ay - menu_h
182 };
183 if my < MENU_SCREEN_MARGIN {
186 my = MENU_SCREEN_MARGIN;
187 }
188
189 Point {
191 x: px(mx - grid_ox),
192 y: px(my - grid_oy),
193 }
194 }
195}
196
197#[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#[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#[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#[deprecated(
279 since = "3.0.0",
280 note = "menu chrome is themed; use `GridTheme::menu_bg` instead"
281)]
282#[must_use]
283pub fn background() -> Hsla {
284 Hsla {
285 h: 0.0,
286 s: 0.0,
287 l: 1.0,
288 a: 1.0,
289 }
290}
291
292#[cfg(test)]
293#[allow(
294 clippy::unwrap_used,
295 clippy::expect_used,
296 clippy::field_reassign_with_default
297)]
298mod tests {
299 use super::*;
300
301 fn menu_at(x: f32, y: f32) -> ContextMenu {
302 ContextMenu::standard(7, point_from(x, y))
303 }
304
305 fn point_from(x: f32, y: f32) -> Point<Pixels> {
306 Point { x: px(x), y: px(y) }
307 }
308
309 fn anchor_y(m: &ContextMenu) -> f32 {
310 f32::from(m.anchor.y)
311 }
312
313 #[test]
314 fn standard_menu_item_sequence_is_stable() {
315 let m = ContextMenu::standard(0, point_from(0.0, 0.0));
316 let kinds: Vec<&'static str> = m
317 .items
318 .iter()
319 .map(|i| match i {
320 MenuItem::Action(MenuAction::SelectColumn) => "SelectColumn",
321 MenuItem::Action(MenuAction::CopyColumn) => "CopyColumn",
322 MenuItem::Action(MenuAction::CopyColumnWithHeaders) => "CopyColumnWithHeaders",
323 MenuItem::Separator => "Separator",
324 MenuItem::Action(MenuAction::SortAscending) => "SortAscending",
325 MenuItem::Action(MenuAction::SortDescending) => "SortDescending",
326 MenuItem::Action(MenuAction::ClearSort) => "ClearSort",
327 MenuItem::Action(MenuAction::GroupBy) => "GroupBy",
328 MenuItem::Action(MenuAction::ClearGrouping) => "ClearGrouping",
329 MenuItem::Action(MenuAction::FilterPrompt) => "FilterPrompt",
330 MenuItem::Action(MenuAction::ClearFilter) => "ClearFilter",
331 MenuItem::Custom { .. } => "Custom",
332 })
333 .collect();
334 assert_eq!(
335 kinds,
336 [
337 "SelectColumn",
338 "CopyColumn",
339 "CopyColumnWithHeaders",
340 "Separator",
341 "SortAscending",
342 "SortDescending",
343 "ClearSort",
344 "Separator",
345 "GroupBy",
346 "ClearGrouping",
347 "Separator",
348 "FilterPrompt",
349 "ClearFilter",
350 ],
351 );
352 }
353
354 #[test]
355 fn separators_break_menu_into_action_groups() {
356 let m = ContextMenu::standard(0, point_from(0.0, 0.0));
357 let separators = m
358 .items
359 .iter()
360 .filter(|i| matches!(i, MenuItem::Separator))
361 .count();
362 assert_eq!(separators, 3);
363 }
364
365 #[test]
366 fn every_menu_action_has_non_empty_label() {
367 for a in [
368 MenuAction::SelectColumn,
369 MenuAction::CopyColumn,
370 MenuAction::CopyColumnWithHeaders,
371 MenuAction::SortAscending,
372 MenuAction::SortDescending,
373 MenuAction::ClearSort,
374 MenuAction::GroupBy,
375 MenuAction::ClearGrouping,
376 MenuAction::FilterPrompt,
377 MenuAction::ClearFilter,
378 ] {
379 assert!(!label(a).is_empty(), "{a:?} has empty label");
380 }
381 }
382
383 #[test]
384 fn width_respects_min_width() {
385 let m = menu_at(0.0, 0.0);
386 assert!(m.width_for(1.0) >= MENU_MIN_WIDTH);
387 }
388
389 #[test]
390 fn width_grows_with_longest_label() {
391 let m = menu_at(0.0, 0.0);
392 let narrow = m.width_for(1.0);
393 let wide = m.width_for(20.0);
394 assert!(wide > narrow);
395 }
396
397 #[test]
398 fn total_height_matches_items_and_padding() {
399 let m = menu_at(0.0, 0.0);
400 let expected = m.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0;
401 assert_eq!(m.total_height(), expected);
402 }
403
404 #[test]
405 fn hover_returns_none_outside_x_bounds() {
406 let m = menu_at(100.0, 100.0);
407 let right = m.width_for(8.0);
408 assert_eq!(hover_at(&m, 99.0, 110.0, 8.0), None);
409 assert_eq!(hover_at(&m, 100.0 + right + 1.0, 110.0, 8.0), None);
410 }
411
412 #[test]
413 fn hover_returns_none_above_anchor() {
414 let m = menu_at(100.0, 100.0);
415 assert_eq!(hover_at(&m, 110.0, 99.0, 8.0), None);
416 }
417
418 #[test]
419 fn hover_on_first_action_returns_action_index_zero() {
420 let m = menu_at(100.0, 100.0);
421 let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
422 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
423 }
424
425 #[test]
426 fn hover_on_separator_returns_none() {
427 let m = menu_at(100.0, 100.0);
428 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 3.0 * MENU_ITEM_HEIGHT;
429 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
430 }
431
432 #[test]
433 fn hover_below_last_item_is_none() {
434 let m = menu_at(100.0, 100.0);
435 let y: f32 = anchor_y(&m) + 1000.0;
436 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
437 }
438
439 fn custom_menu_with_items(x: f32, y: f32, items: Vec<MenuItem>) -> ContextMenu {
440 ContextMenu {
441 col: 0,
442 anchor: point_from(x, y),
443 items,
444 hovered: None,
445 request: None,
446 }
447 }
448
449 #[test]
450 fn custom_item_contributes_to_width() {
451 let long_label = "A very long custom menu item label";
452 let items = vec![
453 MenuItem::Custom {
454 id: "a".into(),
455 label: long_label.into(),
456 },
457 MenuItem::Separator,
458 ];
459 let m = custom_menu_with_items(0.0, 0.0, items);
460 let w = m.width_for(8.0);
461 let expected = long_label.chars().count() as f32 * 8.0 + MENU_PADDING_X * 2.0;
462 assert_eq!(w, expected);
463 }
464
465 #[test]
466 fn custom_item_is_selectable_and_hoverable() {
467 let items = vec![
468 MenuItem::Custom {
469 id: "first".into(),
470 label: "First".into(),
471 },
472 MenuItem::Separator,
473 MenuItem::Custom {
474 id: "third".into(),
475 label: "Third".into(),
476 },
477 ];
478 let m = custom_menu_with_items(100.0, 100.0, items);
479 let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
481 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
482 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 1.0 * MENU_ITEM_HEIGHT;
484 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
485 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 2.0 * MENU_ITEM_HEIGHT;
487 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(1));
488 }
489
490 #[test]
491 fn menu_item_label_helper() {
492 assert_eq!(
493 MenuItem::Action(MenuAction::SortAscending).label(),
494 Some("Sort Ascending")
495 );
496 assert_eq!(
497 MenuItem::Custom {
498 id: "x".into(),
499 label: "Hello".into()
500 }
501 .label(),
502 Some("Hello")
503 );
504 assert_eq!(MenuItem::Separator.label(), None);
505 }
506
507 #[test]
508 fn menu_item_is_selectable() {
509 assert!(MenuItem::Action(MenuAction::ClearFilter).is_selectable());
510 assert!(MenuItem::Custom {
511 id: "x".into(),
512 label: "y".into()
513 }
514 .is_selectable());
515 assert!(!MenuItem::Separator.is_selectable());
516 }
517
518 #[test]
523 fn resolved_opens_down_when_room_below() {
524 let m = menu_at(50.0, 30.0);
525 let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
527 assert_eq!(f32::from(p.x), 50.0);
528 assert_eq!(f32::from(p.y), 30.0);
529 }
530
531 #[test]
534 fn resolved_flips_up_when_no_room_below() {
535 let m = menu_at(50.0, 590.0);
536 let h = m.total_height();
537 let p = m.resolved_position(0.0, 0.0, 2000.0, 600.0, 8.0);
539 assert_eq!(f32::from(p.y), 590.0 - h);
540 }
541
542 #[test]
547 fn resolved_not_clipped_by_grid_only_by_window() {
548 let m = menu_at(280.0, 30.0);
549 let w = m.width_for(8.0);
550 let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
552 assert_eq!(f32::from(p.x), 280.0);
554 assert!(f32::from(p.x) + w > 300.0);
556 }
557
558 #[test]
561 fn resolved_shifts_left_at_window_right_edge() {
562 let m = menu_at(1950.0, 30.0);
563 let w = m.width_for(8.0);
564 let vw = 2000.0;
565 let p = m.resolved_position(0.0, 0.0, vw, 2000.0, 8.0);
566 let right = f32::from(p.x) + w;
567 assert!(right <= vw, "menu right edge {right} must stay within {vw}");
568 assert_eq!(f32::from(p.x), vw - w - MENU_SCREEN_MARGIN);
569 }
570
571 #[test]
574 fn resolved_accounts_for_grid_origin() {
575 let m = menu_at(10.0, 10.0);
576 let p = m.resolved_position(100.0, 200.0, 2000.0, 2000.0, 8.0);
578 assert_eq!(f32::from(p.x), 10.0);
580 assert_eq!(f32::from(p.y), 10.0);
581 }
582}