1use gpui::{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;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum MenuAction {
20 SelectColumn,
21 CopyColumn,
22 CopyColumnWithHeaders,
23 SortAscending,
24 SortDescending,
25 ClearSort,
26 FilterPrompt,
27 ClearFilter,
28}
29
30#[derive(Clone, Debug)]
31pub enum MenuItem {
32 Action(MenuAction),
33 Custom { id: String, label: String },
34 Separator,
35}
36
37impl MenuItem {
38 #[must_use]
40 pub fn label(&self) -> Option<&str> {
41 match self {
42 Self::Action(a) => Some(label(*a)),
43 Self::Custom { label, .. } => Some(label.as_str()),
44 Self::Separator => None,
45 }
46 }
47
48 #[must_use]
50 pub fn is_selectable(&self) -> bool {
51 !matches!(self, Self::Separator)
52 }
53}
54
55#[derive(Clone, Debug)]
56pub struct ContextMenu {
57 pub col: usize,
58 pub anchor: Point<Pixels>,
59 pub items: Vec<MenuItem>,
60 pub hovered: Option<usize>,
61 pub request: Option<ContextMenuRequest>,
62}
63
64impl ContextMenu {
65 #[must_use]
68 pub fn standard(col: usize, anchor: Point<Pixels>) -> Self {
69 Self {
70 col,
71 anchor,
72 items: vec![
73 MenuItem::Action(MenuAction::SelectColumn),
74 MenuItem::Action(MenuAction::CopyColumn),
75 MenuItem::Action(MenuAction::CopyColumnWithHeaders),
76 MenuItem::Separator,
77 MenuItem::Action(MenuAction::SortAscending),
78 MenuItem::Action(MenuAction::SortDescending),
79 MenuItem::Action(MenuAction::ClearSort),
80 MenuItem::Separator,
81 MenuItem::Action(MenuAction::FilterPrompt),
82 MenuItem::Action(MenuAction::ClearFilter),
83 ],
84 hovered: None,
85 request: None,
86 }
87 }
88
89 #[must_use]
93 pub fn custom(
94 col: usize,
95 anchor: Point<Pixels>,
96 items: Vec<MenuItem>,
97 request: ContextMenuRequest,
98 ) -> Self {
99 Self {
100 col,
101 anchor,
102 items,
103 hovered: None,
104 request: Some(request),
105 }
106 }
107
108 #[must_use]
111 pub fn width_for(&self, char_width: f32) -> f32 {
112 let mut max_label_w = 0.0_f32;
113 for item in &self.items {
114 if let Some(text) = item.label() {
115 max_label_w = max_label_w.max(text.chars().count() as f32 * char_width);
116 }
117 }
118 MENU_MIN_WIDTH.max(max_label_w + MENU_PADDING_X * 2.0)
119 }
120
121 #[must_use]
123 pub fn total_height(&self) -> f32 {
124 self.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0
125 }
126}
127
128#[must_use]
131pub fn label(action: MenuAction) -> &'static str {
132 match action {
133 MenuAction::SelectColumn => "Select column",
134 MenuAction::CopyColumn => "Copy column",
135 MenuAction::CopyColumnWithHeaders => "Copy column with headers",
136 MenuAction::SortAscending => "Sort Ascending",
137 MenuAction::SortDescending => "Sort Descending",
138 MenuAction::ClearSort => "Clear sort",
139 MenuAction::FilterPrompt => "Filter...",
140 MenuAction::ClearFilter => "Clear filter",
141 }
142}
143
144#[must_use]
148pub fn hover_at(menu: &ContextMenu, x: f32, y: f32, char_width: f32) -> Option<usize> {
149 let w = menu.width_for(char_width);
150 let ax: f32 = menu.anchor.x.into();
151 let ay: f32 = menu.anchor.y.into();
152 if x < ax || x > ax + w || y < ay {
153 return None;
154 }
155 let rel_y = y - ay - MENU_INNER_PAD;
156 if rel_y < 0.0 {
157 return None;
158 }
159 let idx = (rel_y / MENU_ITEM_HEIGHT) as usize;
160 if idx >= menu.items.len() {
161 return None;
162 }
163 for (cur_row, item) in menu.items.iter().enumerate() {
164 if cur_row == idx {
165 return match item {
166 MenuItem::Action(_) | MenuItem::Custom { .. } => action_index(&menu.items, idx),
167 MenuItem::Separator => None,
168 };
169 }
170 }
171 None
172}
173
174fn action_index(items: &[MenuItem], row: usize) -> Option<usize> {
175 let mut action_idx = 0;
176 for (i, item) in items.iter().enumerate() {
177 if item.is_selectable() {
178 if i == row {
179 return Some(action_idx);
180 }
181 action_idx += 1;
182 }
183 }
184 None
185}
186
187#[must_use]
189pub fn background() -> Hsla {
190 Hsla {
191 h: 0.0,
192 s: 0.0,
193 l: 1.0,
194 a: 1.0,
195 }
196}
197
198#[cfg(test)]
199#[allow(
200 clippy::unwrap_used,
201 clippy::expect_used,
202 clippy::field_reassign_with_default
203)]
204mod tests {
205 use super::*;
206 use gpui::px;
207
208 fn menu_at(x: f32, y: f32) -> ContextMenu {
209 ContextMenu::standard(7, point_from(x, y))
210 }
211
212 fn point_from(x: f32, y: f32) -> Point<Pixels> {
213 Point { x: px(x), y: px(y) }
214 }
215
216 fn anchor_y(m: &ContextMenu) -> f32 {
217 f32::from(m.anchor.y)
218 }
219
220 #[test]
221 fn standard_menu_item_sequence_is_stable() {
222 let m = ContextMenu::standard(0, point_from(0.0, 0.0));
223 let kinds: Vec<&'static str> = m
224 .items
225 .iter()
226 .map(|i| match i {
227 MenuItem::Action(MenuAction::SelectColumn) => "SelectColumn",
228 MenuItem::Action(MenuAction::CopyColumn) => "CopyColumn",
229 MenuItem::Action(MenuAction::CopyColumnWithHeaders) => "CopyColumnWithHeaders",
230 MenuItem::Separator => "Separator",
231 MenuItem::Action(MenuAction::SortAscending) => "SortAscending",
232 MenuItem::Action(MenuAction::SortDescending) => "SortDescending",
233 MenuItem::Action(MenuAction::ClearSort) => "ClearSort",
234 MenuItem::Action(MenuAction::FilterPrompt) => "FilterPrompt",
235 MenuItem::Action(MenuAction::ClearFilter) => "ClearFilter",
236 MenuItem::Custom { .. } => "Custom",
237 })
238 .collect();
239 assert_eq!(
240 kinds,
241 [
242 "SelectColumn",
243 "CopyColumn",
244 "CopyColumnWithHeaders",
245 "Separator",
246 "SortAscending",
247 "SortDescending",
248 "ClearSort",
249 "Separator",
250 "FilterPrompt",
251 "ClearFilter",
252 ],
253 );
254 }
255
256 #[test]
257 fn at_least_two_separators_break_three_groups() {
258 let m = ContextMenu::standard(0, point_from(0.0, 0.0));
259 let separators = m
260 .items
261 .iter()
262 .filter(|i| matches!(i, MenuItem::Separator))
263 .count();
264 assert_eq!(separators, 2);
265 }
266
267 #[test]
268 fn every_menu_action_has_non_empty_label() {
269 for a in [
270 MenuAction::SelectColumn,
271 MenuAction::CopyColumn,
272 MenuAction::CopyColumnWithHeaders,
273 MenuAction::SortAscending,
274 MenuAction::SortDescending,
275 MenuAction::ClearSort,
276 MenuAction::FilterPrompt,
277 MenuAction::ClearFilter,
278 ] {
279 assert!(!label(a).is_empty(), "{a:?} has empty label");
280 }
281 }
282
283 #[test]
284 fn width_respects_min_width() {
285 let m = menu_at(0.0, 0.0);
286 assert!(m.width_for(1.0) >= MENU_MIN_WIDTH);
287 }
288
289 #[test]
290 fn width_grows_with_longest_label() {
291 let m = menu_at(0.0, 0.0);
292 let narrow = m.width_for(1.0);
293 let wide = m.width_for(20.0);
294 assert!(wide > narrow);
295 }
296
297 #[test]
298 fn total_height_matches_items_and_padding() {
299 let m = menu_at(0.0, 0.0);
300 let expected = m.items.len() as f32 * MENU_ITEM_HEIGHT + MENU_INNER_PAD * 2.0;
301 assert_eq!(m.total_height(), expected);
302 }
303
304 #[test]
305 fn hover_returns_none_outside_x_bounds() {
306 let m = menu_at(100.0, 100.0);
307 let right = m.width_for(8.0);
308 assert_eq!(hover_at(&m, 99.0, 110.0, 8.0), None);
309 assert_eq!(hover_at(&m, 100.0 + right + 1.0, 110.0, 8.0), None);
310 }
311
312 #[test]
313 fn hover_returns_none_above_anchor() {
314 let m = menu_at(100.0, 100.0);
315 assert_eq!(hover_at(&m, 110.0, 99.0, 8.0), None);
316 }
317
318 #[test]
319 fn hover_on_first_action_returns_action_index_zero() {
320 let m = menu_at(100.0, 100.0);
321 let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
322 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
323 }
324
325 #[test]
326 fn hover_on_separator_returns_none() {
327 let m = menu_at(100.0, 100.0);
328 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 3.0 * MENU_ITEM_HEIGHT;
329 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
330 }
331
332 #[test]
333 fn hover_below_last_item_is_none() {
334 let m = menu_at(100.0, 100.0);
335 let y: f32 = anchor_y(&m) + 1000.0;
336 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
337 }
338
339 fn custom_menu_with_items(x: f32, y: f32, items: Vec<MenuItem>) -> ContextMenu {
340 ContextMenu {
341 col: 0,
342 anchor: point_from(x, y),
343 items,
344 hovered: None,
345 request: None,
346 }
347 }
348
349 #[test]
350 fn custom_item_contributes_to_width() {
351 let long_label = "A very long custom menu item label";
352 let items = vec![
353 MenuItem::Custom {
354 id: "a".into(),
355 label: long_label.into(),
356 },
357 MenuItem::Separator,
358 ];
359 let m = custom_menu_with_items(0.0, 0.0, items);
360 let w = m.width_for(8.0);
361 let expected = long_label.chars().count() as f32 * 8.0 + MENU_PADDING_X * 2.0;
362 assert_eq!(w, expected);
363 }
364
365 #[test]
366 fn custom_item_is_selectable_and_hoverable() {
367 let items = vec![
368 MenuItem::Custom {
369 id: "first".into(),
370 label: "First".into(),
371 },
372 MenuItem::Separator,
373 MenuItem::Custom {
374 id: "third".into(),
375 label: "Third".into(),
376 },
377 ];
378 let m = custom_menu_with_items(100.0, 100.0, items);
379 let y: f32 = anchor_y(&m) + MENU_INNER_PAD;
381 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(0));
382 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 1.0 * MENU_ITEM_HEIGHT;
384 assert_eq!(hover_at(&m, 110.0, y, 8.0), None);
385 let y: f32 = anchor_y(&m) + MENU_INNER_PAD + 2.0 * MENU_ITEM_HEIGHT;
387 assert_eq!(hover_at(&m, 110.0, y, 8.0), Some(1));
388 }
389
390 #[test]
391 fn menu_item_label_helper() {
392 assert_eq!(
393 MenuItem::Action(MenuAction::SortAscending).label(),
394 Some("Sort Ascending")
395 );
396 assert_eq!(
397 MenuItem::Custom {
398 id: "x".into(),
399 label: "Hello".into()
400 }
401 .label(),
402 Some("Hello")
403 );
404 assert_eq!(MenuItem::Separator.label(), None);
405 }
406
407 #[test]
408 fn menu_item_is_selectable() {
409 assert!(MenuItem::Action(MenuAction::ClearFilter).is_selectable());
410 assert!(MenuItem::Custom {
411 id: "x".into(),
412 label: "y".into()
413 }
414 .is_selectable());
415 assert!(!MenuItem::Separator.is_selectable());
416 }
417}