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