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 FilterPrompt,
30 ClearFilter,
31}
32
33#[derive(Clone, Debug)]
34pub enum MenuItem {
35 Action(MenuAction),
36 Custom { id: String, label: String },
37 Separator,
38}
39
40impl MenuItem {
41 #[must_use]
43 pub fn label(&self) -> Option<&str> {
44 match self {
45 Self::Action(a) => Some(label(*a)),
46 Self::Custom { label, .. } => Some(label.as_str()),
47 Self::Separator => None,
48 }
49 }
50
51 #[must_use]
53 pub fn is_selectable(&self) -> bool {
54 !matches!(self, Self::Separator)
55 }
56}
57
58#[derive(Clone, Debug)]
59pub struct ContextMenu {
60 pub col: usize,
61 pub anchor: Point<Pixels>,
62 pub items: Vec<MenuItem>,
63 pub hovered: Option<usize>,
64 pub request: Option<ContextMenuRequest>,
65}
66
67impl ContextMenu {
68 #[must_use]
71 pub fn standard(col: usize, anchor: Point<Pixels>) -> Self {
72 Self {
73 col,
74 anchor,
75 items: vec![
76 MenuItem::Action(MenuAction::SelectColumn),
77 MenuItem::Action(MenuAction::CopyColumn),
78 MenuItem::Action(MenuAction::CopyColumnWithHeaders),
79 MenuItem::Separator,
80 MenuItem::Action(MenuAction::SortAscending),
81 MenuItem::Action(MenuAction::SortDescending),
82 MenuItem::Action(MenuAction::ClearSort),
83 MenuItem::Separator,
84 MenuItem::Action(MenuAction::FilterPrompt),
85 MenuItem::Action(MenuAction::ClearFilter),
86 ],
87 hovered: None,
88 request: None,
89 }
90 }
91
92 #[must_use]
96 pub fn custom(
97 col: usize,
98 anchor: Point<Pixels>,
99 items: Vec<MenuItem>,
100 request: ContextMenuRequest,
101 ) -> Self {
102 Self {
103 col,
104 anchor,
105 items,
106 hovered: None,
107 request: Some(request),
108 }
109 }
110
111 #[must_use]
114 pub fn width_for(&self, char_width: f32) -> f32 {
115 let mut max_label_w = 0.0_f32;
116 for item in &self.items {
117 if let Some(text) = item.label() {
118 max_label_w = max_label_w.max(text.chars().count() as f32 * char_width);
119 }
120 }
121 MENU_MIN_WIDTH.max(max_label_w + MENU_PADDING_X * 2.0)
122 }
123
124 #[must_use]
126 pub fn total_height(&self) -> f32 {
127 self.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0
128 }
129
130 #[must_use]
144 pub fn resolved_position(
145 &self,
146 grid_ox: f32,
147 grid_oy: f32,
148 vw: f32,
149 vh: f32,
150 char_width: f32,
151 ) -> Point<Pixels> {
152 let menu_w = self.width_for(char_width);
153 let menu_h = self.total_height();
154 let ax = grid_ox + f32::from(self.anchor.x);
156 let ay = grid_oy + f32::from(self.anchor.y);
157
158 let mut mx = ax;
162 if mx + menu_w > vw {
163 mx = vw - menu_w - MENU_SCREEN_MARGIN;
164 }
165 if mx < MENU_SCREEN_MARGIN {
166 mx = MENU_SCREEN_MARGIN;
167 }
168
169 let opens_down = ay + menu_h + MENU_SCREEN_MARGIN <= vh;
172 let mut my = if opens_down {
173 ay
174 } else {
175 ay - menu_h
177 };
178 if my < MENU_SCREEN_MARGIN {
181 my = MENU_SCREEN_MARGIN;
182 }
183
184 Point {
186 x: px(mx - grid_ox),
187 y: px(my - grid_oy),
188 }
189 }
190}
191
192#[must_use]
195pub fn label(action: MenuAction) -> &'static str {
196 match action {
197 MenuAction::SelectColumn => "Select column",
198 MenuAction::CopyColumn => "Copy column",
199 MenuAction::CopyColumnWithHeaders => "Copy column with headers",
200 MenuAction::SortAscending => "Sort Ascending",
201 MenuAction::SortDescending => "Sort Descending",
202 MenuAction::ClearSort => "Clear sort",
203 MenuAction::FilterPrompt => "Filter...",
204 MenuAction::ClearFilter => "Clear filter",
205 }
206}
207
208#[must_use]
216pub fn hover_at(menu: &ContextMenu, x: f32, y: f32, char_width: f32) -> Option<usize> {
217 hover_at_anchor(menu, menu.anchor, x, y, char_width)
218}
219
220#[must_use]
225pub fn hover_at_anchor(
226 menu: &ContextMenu,
227 anchor: Point<Pixels>,
228 x: f32,
229 y: f32,
230 char_width: f32,
231) -> Option<usize> {
232 let w = menu.width_for(char_width);
233 let ax: f32 = anchor.x.into();
234 let ay: f32 = anchor.y.into();
235 if x < ax || x > ax + w || y < ay {
236 return None;
237 }
238 let rel_y = y - ay - MENU_INNER_PAD;
239 if rel_y < 0.0 {
240 return None;
241 }
242 let idx = (rel_y / MENU_ITEM_HEIGHT) as usize;
243 if idx >= menu.items.len() {
244 return None;
245 }
246 for (cur_row, item) in menu.items.iter().enumerate() {
247 if cur_row == idx {
248 return match item {
249 MenuItem::Action(_) | MenuItem::Custom { .. } => action_index(&menu.items, idx),
250 MenuItem::Separator => None,
251 };
252 }
253 }
254 None
255}
256
257fn action_index(items: &[MenuItem], row: usize) -> Option<usize> {
258 let mut action_idx = 0;
259 for (i, item) in items.iter().enumerate() {
260 if item.is_selectable() {
261 if i == row {
262 return Some(action_idx);
263 }
264 action_idx += 1;
265 }
266 }
267 None
268}
269
270#[must_use]
272pub fn background() -> Hsla {
273 Hsla {
274 h: 0.0,
275 s: 0.0,
276 l: 1.0,
277 a: 1.0,
278 }
279}
280
281#[cfg(test)]
282#[allow(
283 clippy::unwrap_used,
284 clippy::expect_used,
285 clippy::field_reassign_with_default
286)]
287mod tests {
288 use super::*;
289
290 fn menu_at(x: f32, y: f32) -> ContextMenu {
291 ContextMenu::standard(7, point_from(x, y))
292 }
293
294 fn point_from(x: f32, y: f32) -> Point<Pixels> {
295 Point { x: px(x), y: px(y) }
296 }
297
298 fn anchor_y(m: &ContextMenu) -> f32 {
299 f32::from(m.anchor.y)
300 }
301
302 #[test]
303 fn standard_menu_item_sequence_is_stable() {
304 let m = ContextMenu::standard(0, point_from(0.0, 0.0));
305 let kinds: Vec<&'static str> = m
306 .items
307 .iter()
308 .map(|i| match i {
309 MenuItem::Action(MenuAction::SelectColumn) => "SelectColumn",
310 MenuItem::Action(MenuAction::CopyColumn) => "CopyColumn",
311 MenuItem::Action(MenuAction::CopyColumnWithHeaders) => "CopyColumnWithHeaders",
312 MenuItem::Separator => "Separator",
313 MenuItem::Action(MenuAction::SortAscending) => "SortAscending",
314 MenuItem::Action(MenuAction::SortDescending) => "SortDescending",
315 MenuItem::Action(MenuAction::ClearSort) => "ClearSort",
316 MenuItem::Action(MenuAction::FilterPrompt) => "FilterPrompt",
317 MenuItem::Action(MenuAction::ClearFilter) => "ClearFilter",
318 MenuItem::Custom { .. } => "Custom",
319 })
320 .collect();
321 assert_eq!(
322 kinds,
323 [
324 "SelectColumn",
325 "CopyColumn",
326 "CopyColumnWithHeaders",
327 "Separator",
328 "SortAscending",
329 "SortDescending",
330 "ClearSort",
331 "Separator",
332 "FilterPrompt",
333 "ClearFilter",
334 ],
335 );
336 }
337
338 #[test]
339 fn at_least_two_separators_break_three_groups() {
340 let m = ContextMenu::standard(0, point_from(0.0, 0.0));
341 let separators = m
342 .items
343 .iter()
344 .filter(|i| matches!(i, MenuItem::Separator))
345 .count();
346 assert_eq!(separators, 2);
347 }
348
349 #[test]
350 fn every_menu_action_has_non_empty_label() {
351 for a in [
352 MenuAction::SelectColumn,
353 MenuAction::CopyColumn,
354 MenuAction::CopyColumnWithHeaders,
355 MenuAction::SortAscending,
356 MenuAction::SortDescending,
357 MenuAction::ClearSort,
358 MenuAction::FilterPrompt,
359 MenuAction::ClearFilter,
360 ] {
361 assert!(!label(a).is_empty(), "{a:?} has empty label");
362 }
363 }
364
365 #[test]
366 fn width_respects_min_width() {
367 let m = menu_at(0.0, 0.0);
368 assert!(m.width_for(1.0) >= MENU_MIN_WIDTH);
369 }
370
371 #[test]
372 fn width_grows_with_longest_label() {
373 let m = menu_at(0.0, 0.0);
374 let narrow = m.width_for(1.0);
375 let wide = m.width_for(20.0);
376 assert!(wide > narrow);
377 }
378
379 #[test]
380 fn total_height_matches_items_and_padding() {
381 let m = menu_at(0.0, 0.0);
382 let expected = m.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0;
383 assert_eq!(m.total_height(), expected);
384 }
385
386 #[test]
387 fn hover_returns_none_outside_x_bounds() {
388 let m = menu_at(100.0, 100.0);
389 let right = m.width_for(8.0);
390 assert_eq!(hover_at(&m, 99.0, 110.0, 8.0), None);
391 assert_eq!(hover_at(&m, 100.0 + right + 1.0, 110.0, 8.0), None);
392 }
393
394 #[test]
395 fn hover_returns_none_above_anchor() {
396 let m = menu_at(100.0, 100.0);
397 assert_eq!(hover_at(&m, 110.0, 99.0, 8.0), None);
398 }
399
400 #[test]
401 fn hover_on_first_action_returns_action_index_zero() {
402 let m = menu_at(100.0, 100.0);
403 let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
404 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
405 }
406
407 #[test]
408 fn hover_on_separator_returns_none() {
409 let m = menu_at(100.0, 100.0);
410 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 3.0 * MENU_ITEM_HEIGHT;
411 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
412 }
413
414 #[test]
415 fn hover_below_last_item_is_none() {
416 let m = menu_at(100.0, 100.0);
417 let y: f32 = anchor_y(&m) + 1000.0;
418 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
419 }
420
421 fn custom_menu_with_items(x: f32, y: f32, items: Vec<MenuItem>) -> ContextMenu {
422 ContextMenu {
423 col: 0,
424 anchor: point_from(x, y),
425 items,
426 hovered: None,
427 request: None,
428 }
429 }
430
431 #[test]
432 fn custom_item_contributes_to_width() {
433 let long_label = "A very long custom menu item label";
434 let items = vec![
435 MenuItem::Custom {
436 id: "a".into(),
437 label: long_label.into(),
438 },
439 MenuItem::Separator,
440 ];
441 let m = custom_menu_with_items(0.0, 0.0, items);
442 let w = m.width_for(8.0);
443 let expected = long_label.chars().count() as f32 * 8.0 + MENU_PADDING_X * 2.0;
444 assert_eq!(w, expected);
445 }
446
447 #[test]
448 fn custom_item_is_selectable_and_hoverable() {
449 let items = vec![
450 MenuItem::Custom {
451 id: "first".into(),
452 label: "First".into(),
453 },
454 MenuItem::Separator,
455 MenuItem::Custom {
456 id: "third".into(),
457 label: "Third".into(),
458 },
459 ];
460 let m = custom_menu_with_items(100.0, 100.0, items);
461 let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
463 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
464 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 1.0 * MENU_ITEM_HEIGHT;
466 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
467 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 2.0 * MENU_ITEM_HEIGHT;
469 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(1));
470 }
471
472 #[test]
473 fn menu_item_label_helper() {
474 assert_eq!(
475 MenuItem::Action(MenuAction::SortAscending).label(),
476 Some("Sort Ascending")
477 );
478 assert_eq!(
479 MenuItem::Custom {
480 id: "x".into(),
481 label: "Hello".into()
482 }
483 .label(),
484 Some("Hello")
485 );
486 assert_eq!(MenuItem::Separator.label(), None);
487 }
488
489 #[test]
490 fn menu_item_is_selectable() {
491 assert!(MenuItem::Action(MenuAction::ClearFilter).is_selectable());
492 assert!(MenuItem::Custom {
493 id: "x".into(),
494 label: "y".into()
495 }
496 .is_selectable());
497 assert!(!MenuItem::Separator.is_selectable());
498 }
499
500 #[test]
505 fn resolved_opens_down_when_room_below() {
506 let m = menu_at(50.0, 30.0);
507 let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
509 assert_eq!(f32::from(p.x), 50.0);
510 assert_eq!(f32::from(p.y), 30.0);
511 }
512
513 #[test]
516 fn resolved_flips_up_when_no_room_below() {
517 let m = menu_at(50.0, 590.0);
518 let h = m.total_height();
519 let p = m.resolved_position(0.0, 0.0, 2000.0, 600.0, 8.0);
521 assert_eq!(f32::from(p.y), 590.0 - h);
522 }
523
524 #[test]
529 fn resolved_not_clipped_by_grid_only_by_window() {
530 let m = menu_at(280.0, 30.0);
531 let w = m.width_for(8.0);
532 let p = m.resolved_position(0.0, 0.0, 2000.0, 2000.0, 8.0);
534 assert_eq!(f32::from(p.x), 280.0);
536 assert!(f32::from(p.x) + w > 300.0);
538 }
539
540 #[test]
543 fn resolved_shifts_left_at_window_right_edge() {
544 let m = menu_at(1950.0, 30.0);
545 let w = m.width_for(8.0);
546 let vw = 2000.0;
547 let p = m.resolved_position(0.0, 0.0, vw, 2000.0, 8.0);
548 let right = f32::from(p.x) + w;
549 assert!(right <= vw, "menu right edge {right} must stay within {vw}");
550 assert_eq!(f32::from(p.x), vw - w - MENU_SCREEN_MARGIN);
551 }
552
553 #[test]
556 fn resolved_accounts_for_grid_origin() {
557 let m = menu_at(10.0, 10.0);
558 let p = m.resolved_position(100.0, 200.0, 2000.0, 2000.0, 8.0);
560 assert_eq!(f32::from(p.x), 10.0);
562 assert_eq!(f32::from(p.y), 10.0);
563 }
564}