ui/menu.rs
1//! [`Item`] — a row in a dropped menu — and [`card`], the panel that paints a
2//! list of them plus the panels its [`Item::Submenu`] rows drop.
3//!
4//! Every menu in the system is those two over the caller's own open state: the
5//! bar's dropped panel ([`crate::menubar`]), the `···` on a row, the picker
6//! under a chip. The state stays with the caller because only it knows what
7//! opening means — a [`crate::popover::Popup`] for one, a field for another.
8//!
9//! [`Cursor`] is the one piece of state the card asks for: which submenus are
10//! down and which row is live. Pointer and keyboard both move it, so the two
11//! can never disagree about which row an open submenu hangs off. What the
12//! pointer did comes back as a [`Hit`]; acting on it stays the caller's.
13
14use crate::{icons, popover};
15use gpui::{Context, MouseDownEvent, Pixels, Point, SharedString, Window, div, prelude::*, px};
16use std::{cell::Cell, rc::Rc};
17use theme::{TextStyle, Theme, Typeset};
18
19/// The leading glyph and the trailing check, at the size the rows are set in.
20const GLYPH: f32 = 13.0;
21
22/// How wide a panel sits.
23const PANEL_MIN: f32 = 180.0;
24/// How wide one holding a described row sits. A description is a sentence
25/// rather than a name, and at the narrow width it wraps to three lines and the
26/// menu reads as a paragraph with a title. One described row widens the panel,
27/// the way one icon opens the glyph gutter.
28const PANEL_MIN_DESCRIBED: f32 = 280.0;
29
30/// A row in a menu.
31///
32/// Deliberately not a struct with an `is_separator` flag: a separator has no
33/// label, no accelerator and nothing to enable, and every one of those fields
34/// would have to be answered anyway.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum Item {
37 Action {
38 label: SharedString,
39 /// A second line under the label, for a row whose name does not say
40 /// enough on its own — what a command will do, which file it will act
41 /// on. `None` keeps the row one line tall; nothing reserves space for
42 /// a description the way the glyph gutter reserves space for an icon,
43 /// because a row's two lines read as one block and a blank second line
44 /// would read as a gap.
45 description: Option<SharedString>,
46 /// The leading glyph's asset path — [`crate::icons`]' consts, or a
47 /// path the app resolved at runtime. A menu where no row has one keeps
48 /// no room for it.
49 icon: Option<SharedString>,
50 /// The accelerator to *print* — the binding itself is the app's, and
51 /// bezel never dispatches it. A menu that showed a keystroke it did not
52 /// own would be documenting a lie.
53 keystroke: Option<SharedString>,
54 /// The choice the menu is currently on, marked with a trailing check.
55 checked: bool,
56 enabled: bool,
57 },
58 /// A row that drops a menu of its own, the way a SwiftUI `Menu` nests
59 /// inside a `Menu`. It carries no accelerator and nothing to check: the
60 /// only thing choosing it does is open.
61 Submenu {
62 label: SharedString,
63 icon: Option<SharedString>,
64 enabled: bool,
65 items: Vec<Item>,
66 },
67 Separator,
68}
69
70impl Item {
71 pub fn action(label: impl Into<SharedString>) -> Self {
72 Item::Action {
73 label: label.into(),
74 description: None,
75 icon: None,
76 keystroke: None,
77 checked: false,
78 enabled: true,
79 }
80 }
81
82 pub fn submenu(label: impl Into<SharedString>, items: Vec<Item>) -> Self {
83 Item::Submenu {
84 label: label.into(),
85 icon: None,
86 enabled: true,
87 items,
88 }
89 }
90
91 /// No-ops on a separator, which has nothing to hang a glyph on.
92 pub fn with_icon(mut self, icon: impl Into<SharedString>) -> Self {
93 match &mut self {
94 Item::Action { icon: slot, .. } | Item::Submenu { icon: slot, .. } => {
95 *slot = Some(icon.into())
96 }
97 Item::Separator => {}
98 }
99 self
100 }
101
102 /// A second line under the label. No-ops on anything but an action row: a
103 /// submenu's second line is the panel it opens, and a separator has no
104 /// first line to put one under.
105 pub fn with_description(mut self, description: impl Into<SharedString>) -> Self {
106 if let Item::Action {
107 description: slot, ..
108 } = &mut self
109 {
110 *slot = Some(description.into());
111 }
112 self
113 }
114
115 /// No-ops on anything but an action row: a submenu's keystroke is the
116 /// arrow that opens it, and a separator has nothing to hang one on.
117 pub fn with_keystroke(mut self, keystroke: impl Into<SharedString>) -> Self {
118 if let Item::Action {
119 keystroke: slot, ..
120 } = &mut self
121 {
122 *slot = Some(keystroke.into());
123 }
124 self
125 }
126
127 /// Takes the flag, because what a menu is on is decided per render. No-ops
128 /// on a submenu, which is not itself a choice.
129 pub fn checked(mut self, checked: bool) -> Self {
130 if let Item::Action { checked: slot, .. } = &mut self {
131 *slot = checked;
132 }
133 self
134 }
135
136 pub fn disabled(mut self) -> Self {
137 match &mut self {
138 Item::Action { enabled, .. } | Item::Submenu { enabled, .. } => *enabled = false,
139 Item::Separator => {}
140 }
141 self
142 }
143
144 /// Whether the keyboard and the pointer can land here at all. A submenu
145 /// with nothing to choose in it counts as unlandable: opening it would drop
146 /// a panel that is a dead end.
147 pub fn selectable(&self) -> bool {
148 match self {
149 Item::Action { enabled, .. } => *enabled,
150 Item::Submenu { enabled, items, .. } => *enabled && items.iter().any(Item::selectable),
151 Item::Separator => false,
152 }
153 }
154
155 fn has_description(&self) -> bool {
156 matches!(
157 self,
158 Item::Action {
159 description: Some(_),
160 ..
161 }
162 )
163 }
164
165 fn has_icon(&self) -> bool {
166 matches!(
167 self,
168 Item::Action { icon: Some(_), .. } | Item::Submenu { icon: Some(_), .. }
169 )
170 }
171
172 /// The submenu this row opens, if it is one that can be opened.
173 fn opens(&self) -> Option<&[Item]> {
174 match self {
175 Item::Submenu {
176 enabled: true,
177 items,
178 ..
179 } => Some(items),
180 _ => None,
181 }
182 }
183}
184
185/// The item `path` names — one row index per level, outermost first.
186pub fn at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a Item> {
187 let (&row, above) = path.split_last()?;
188 items_at(items, above)?.get(row)
189}
190
191/// The menu `path` opens into: `items` itself for an empty path, else the rows
192/// of the submenu each index names. `None` as soon as one of them does not name
193/// an open-able submenu.
194pub fn items_at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a [Item]> {
195 let mut level = items;
196 for &row in path {
197 level = level.get(row)?.opens()?;
198 }
199 Some(level)
200}
201
202/// How many levels of `open` still name an open-able submenu — where both the
203/// painting and the out-click count stop when the chain has gone stale.
204fn open_depth(items: &[Item], open: &[usize]) -> usize {
205 let mut level = items;
206 for (depth, &row) in open.iter().enumerate() {
207 match level.get(row).and_then(Item::opens) {
208 Some(inner) => level = inner,
209 None => return depth,
210 }
211 }
212 open.len()
213}
214
215/// The next row the keyboard can land on, `delta` deciding the direction:
216/// separators and disabled rows are stepped straight over, and both ends wrap.
217/// `from` of `None` enters the menu at the edge the direction comes from.
218///
219/// [`popover::menu_step`] cannot do this — it counts rows and knows nothing
220/// about which of them can be landed on. `None` back means *nothing* in the menu
221/// is selectable, which is the one shape that would otherwise spin forever.
222pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
223 let count = items.len();
224 if count == 0 {
225 return None;
226 }
227 let step = if delta >= 0 { 1 } else { -1 };
228 let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
229 // Entering, the first candidate is the edge itself; moving, it is the row
230 // after the one you are on.
231 let mut at = match from {
232 None if step > 0 => 0,
233 None => count - 1,
234 Some(at) => wrap(at.min(count - 1)),
235 };
236 for _ in 0..count {
237 if items[at].selectable() {
238 return Some(at);
239 }
240 at = wrap(at);
241 }
242 None
243}
244
245// ---------------------------------------------------------------------------
246// Cursor
247// ---------------------------------------------------------------------------
248
249/// Where an open menu is being worked: `open` is the chain of submenu rows
250/// currently down, outermost first, and `row` is the live row in the menu that
251/// chain ends at.
252///
253/// One cursor for both input devices. A menu that tracked hover separately from
254/// the keyboard could have two rows lit and a submenu hanging off neither.
255#[derive(Clone, Debug, Default, PartialEq, Eq)]
256pub struct Cursor {
257 open: Vec<usize>,
258 row: Option<usize>,
259}
260
261impl Cursor {
262 /// The submenu rows currently down, outermost first.
263 pub fn open(&self) -> &[usize] {
264 &self.open
265 }
266
267 /// The live row in the innermost open menu.
268 pub fn row(&self) -> Option<usize> {
269 self.row
270 }
271
272 /// Whether a submenu is down — where a menubar's `left` closes a level
273 /// instead of crossing to the previous menu.
274 pub fn nested(&self) -> bool {
275 !self.open.is_empty()
276 }
277
278 /// The full path to the live row, outermost first.
279 pub fn path(&self) -> Option<Vec<usize>> {
280 let row = self.row?;
281 let mut path = self.open.clone();
282 path.push(row);
283 Some(path)
284 }
285
286 /// Nothing down, nothing live — a menu opens here, and closes back to it.
287 pub fn clear(&mut self) {
288 self.open.clear();
289 self.row = None;
290 }
291
292 /// The row lit in the panel at `depth`: the row holding the submenu open
293 /// above the innermost panel, the live row in it, nothing below.
294 pub fn lit(&self, depth: usize) -> Option<usize> {
295 match depth.cmp(&self.open.len()) {
296 std::cmp::Ordering::Less => Some(self.open[depth]),
297 std::cmp::Ordering::Equal => self.row,
298 std::cmp::Ordering::Greater => None,
299 }
300 }
301
302 /// Move within the innermost open panel.
303 pub fn step(&mut self, root: &[Item], delta: isize) {
304 let Some(items) = items_at(root, &self.open) else {
305 return;
306 };
307 self.row = next_selectable(items, self.row, delta);
308 }
309
310 /// Open the submenu under the live row and land on its first row. `false`
311 /// when the live row is not one — which is a menubar's cue that `right`
312 /// meant the next menu instead.
313 pub fn descend(&mut self, root: &[Item]) -> bool {
314 let Some(row) = self.row else { return false };
315 let Some(inner) = items_at(root, &self.open)
316 .and_then(|items| items.get(row))
317 .and_then(Item::opens)
318 else {
319 return false;
320 };
321 self.row = next_selectable(inner, None, 1);
322 self.open.push(row);
323 true
324 }
325
326 /// Close the innermost submenu, landing back on the row that opened it.
327 /// `false` at the top level, where closing is the whole menu's to do.
328 pub fn ascend(&mut self) -> bool {
329 match self.open.pop() {
330 Some(row) => {
331 self.row = Some(row);
332 true
333 }
334 None => false,
335 }
336 }
337
338 /// Put the cursor on the row `path` names, opening the chain above it and,
339 /// if it is a submenu row, itself — pointing at a submenu row is what opens
340 /// it. Nothing is lit inside the fresh panel until something moves into it.
341 ///
342 /// Answers whether that changed anything, because the pointer reports every
343 /// move and only a change is worth a repaint.
344 pub fn point_at(&mut self, root: &[Item], path: &[usize]) -> bool {
345 let (open, row) = match path.split_last() {
346 None => (Vec::new(), None),
347 Some((&row, above)) if at(root, path).and_then(Item::opens).is_some() => {
348 (above.iter().copied().chain([row]).collect(), None)
349 }
350 Some((&row, above)) => (above.to_vec(), Some(row)),
351 };
352 if self.open == open && self.row == row {
353 return false;
354 }
355 self.open = open;
356 self.row = row;
357 true
358 }
359}
360
361// ---------------------------------------------------------------------------
362// The panels
363// ---------------------------------------------------------------------------
364
365/// What the pointer did to a menu. Every variant is a request: the caller's
366/// [`Cursor`] and open state are what answer it.
367#[derive(Clone, Debug, PartialEq, Eq)]
368pub enum Hit {
369 /// The pointer is on this row, or a submenu row was clicked. Feed it to
370 /// [`Cursor::point_at`], which is also what opens a submenu.
371 Point(Vec<usize>),
372 /// An action row was chosen.
373 Choose(Vec<usize>),
374 /// A press outside every panel of the open tree. Reported here rather than
375 /// left to an `.on_mouse_down_out` on the card, which sees only its own
376 /// bounds and would read a click in a submenu as a click away.
377 Dismiss,
378}
379
380/// The panel a menu drops: every [`Item`] as a row, in a
381/// [`popover::popover_card`], with a further panel hanging off each submenu row
382/// the [`Cursor`] holds open. `id` prefixes the rows' element ids, so two menus
383/// open at once keep their hover state apart.
384///
385/// The pointer moves the cursor rather than lighting a row of its own, so a
386/// submenu can only ever hang off the row that is live. Everything the pointer
387/// does arrives as a [`Hit`], dismissal included.
388pub fn card<V: 'static>(
389 theme: &Theme,
390 id: impl Into<SharedString>,
391 items: &[Item],
392 cursor: &Cursor,
393 cx: &mut Context<V>,
394 on: impl Fn(&mut V, Hit, &mut Window, &mut Context<V>) + 'static,
395) -> gpui::Div {
396 let tree = Tree {
397 id: id.into(),
398 panels: 1 + open_depth(items, cursor.open()),
399 outside: Rc::new(Cell::new((None, 0))),
400 on: Rc::new(on),
401 };
402 tree.panel(theme, items, cursor, 0, &[], cx)
403}
404
405/// A gpui mouse listener, boxed: `Context::listener` borrows the context it is
406/// made from, and these are handed back out of a method that must not.
407type Listener<E> = Box<dyn Fn(&E, &mut Window, &mut gpui::App)>;
408
409/// The caller's [`Hit`] handler, shared by every panel of one open tree.
410type Reporter<V> = Rc<dyn Fn(&mut V, Hit, &mut Window, &mut Context<V>)>;
411
412/// The parts every panel of one open tree shares.
413struct Tree<V: 'static> {
414 id: SharedString,
415 /// How many panels will paint — the divisor of the out-click count below.
416 panels: usize,
417 /// The press each panel has already reported as outside itself, stamped
418 /// with where it landed. A press outside *all* of them is the one that
419 /// dismisses, and no panel alone can tell.
420 outside: Rc<Cell<(Option<Point<Pixels>>, usize)>>,
421 on: Reporter<V>,
422}
423
424impl<V: 'static> Tree<V> {
425 fn panel(
426 &self,
427 theme: &Theme,
428 items: &[Item],
429 cursor: &Cursor,
430 depth: usize,
431 prefix: &[usize],
432 cx: &mut Context<V>,
433 ) -> gpui::Div {
434 let lit = cursor.lit(depth);
435 let down = cursor.open().get(depth).copied();
436 // A menu where nothing carries a glyph keeps no room for one — a bar's
437 // menus would otherwise open with an empty column down their left.
438 let gutter = items.iter().any(Item::has_icon);
439 let described = items.iter().any(Item::has_description);
440 popover::popover_card(theme)
441 .min_w(px(if described {
442 PANEL_MIN_DESCRIBED
443 } else {
444 PANEL_MIN
445 }))
446 .on_mouse_down_out(self.dismissal(cx))
447 .children(items.iter().enumerate().map(|(row, item)| {
448 if matches!(item, Item::Separator) {
449 return popover::divider().into_any_element();
450 }
451 let path: Vec<usize> = prefix.iter().copied().chain([row]).collect();
452 let id = row_id(&self.id, &path);
453 let (label, icon, enabled) = match item {
454 Item::Action {
455 label,
456 icon,
457 enabled,
458 ..
459 }
460 | Item::Submenu {
461 label,
462 icon,
463 enabled,
464 ..
465 } => (label.clone(), icon.clone(), *enabled),
466 Item::Separator => unreachable!("separators returned above"),
467 };
468 let description = match item {
469 Item::Action { description, .. } => description.clone(),
470 _ => None,
471 };
472 let row = if enabled {
473 popover::menu_row(theme, lit == Some(row), None)
474 .id(id.clone())
475 .on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
476 .on_click(self.reports(
477 match item {
478 // Clicking a submenu row opens it; there is
479 // nothing else it could mean.
480 Item::Submenu { .. } => Hit::Point(path.clone()),
481 _ => Hit::Choose(path.clone()),
482 },
483 cx,
484 ))
485 } else {
486 disabled_row(theme).id(id.clone())
487 };
488 row.when(gutter, |row| row.child(glyph_slot(theme, icon, enabled)))
489 .child(
490 div()
491 .flex_1()
492 .min_w_0()
493 .flex()
494 .flex_col()
495 .child(label)
496 .children(
497 description.map(|description| {
498 description_line(theme, description, enabled)
499 }),
500 ),
501 )
502 .map(|row| match item {
503 Item::Action {
504 keystroke, checked, ..
505 } => row
506 .when(*checked, |row| {
507 row.child(
508 icons::icon(icons::glyph::Check)
509 .size(px(GLYPH))
510 .text_color(theme.text),
511 )
512 })
513 .when_some(keystroke.clone(), |row, keystroke| {
514 row.child(popover::kbd_hint(theme, &keystroke))
515 }),
516 _ => row.child(
517 icons::icon(icons::glyph::ChevronRight)
518 .size(px(GLYPH))
519 .text_color(theme.text_faint),
520 ),
521 })
522 .when_some(
523 item.opens().filter(|_| down == Some(path[depth])),
524 |parent, inner| {
525 let panel = self
526 .panel(theme, inner, cursor, depth + 1, &path, cx)
527 .into_any_element();
528 parent.relative().child(popover::anchored_submenu(
529 SharedString::from(format!("{id}-panel")),
530 panel,
531 ))
532 },
533 )
534 .into_any_element()
535 }))
536 }
537
538 /// One panel's share of the out-click test: it reports the press it did not
539 /// contain, and whichever panel completes the tally is the one that calls
540 /// it a dismissal. Order between them does not matter, only the count.
541 fn dismissal(&self, cx: &mut Context<V>) -> Listener<MouseDownEvent> {
542 let outside = self.outside.clone();
543 let panels = self.panels;
544 let on = self.on.clone();
545 Box::new(
546 cx.listener(move |view, event: &MouseDownEvent, window, cx| {
547 let (at, count) = outside.get();
548 let count = if at == Some(event.position) {
549 count + 1
550 } else {
551 1
552 };
553 outside.set((Some(event.position), count));
554 if count >= panels {
555 outside.set((None, 0));
556 on(view, Hit::Dismiss, window, cx);
557 }
558 }),
559 )
560 }
561
562 /// A listener that hands `hit` back to the caller, whatever the event was.
563 fn reports<E: 'static>(&self, hit: Hit, cx: &mut Context<V>) -> Listener<E> {
564 let on = self.on.clone();
565 Box::new(cx.listener(move |view, _: &E, window, cx| on(view, hit.clone(), window, cx)))
566 }
567}
568
569/// A row's element id: the card's, then the path, so rows of two panels — or of
570/// two menus open at once — never collide.
571fn row_id(id: &SharedString, path: &[usize]) -> SharedString {
572 let mut out = id.to_string();
573 for row in path {
574 out.push('-');
575 out.push_str(&row.to_string());
576 }
577 SharedString::from(out)
578}
579
580/// The leading column: the row's glyph, or the room one would have taken, so a
581/// menu of mixed rows keeps its labels on one edge.
582fn glyph_slot(theme: &Theme, icon: Option<SharedString>, enabled: bool) -> gpui::Div {
583 div()
584 .flex_none()
585 .size(px(GLYPH))
586 .flex()
587 .items_center()
588 .justify_center()
589 .children(icon.map(|path| {
590 gpui::svg()
591 .path(path)
592 .size(px(GLYPH))
593 .text_color(if enabled {
594 theme.text_faint
595 } else {
596 theme.text_faint.opacity(0.5)
597 })
598 }))
599}
600
601/// The second line under a row's label: the same relationship a card row's
602/// meta line has to its title ([`crate::widgets::Scaffolding::meta_line`]),
603/// which is where the size and the tone come from.
604///
605/// It sets its own colour rather than inheriting the row's, because the row's
606/// is the *label's* — a lit row paints that at full contrast, and a
607/// description that followed it there would stop reading as the quieter half.
608fn description_line(theme: &Theme, description: SharedString, enabled: bool) -> gpui::Div {
609 div()
610 .mt(px(2.0))
611 .text_style(TextStyle::Subheadline)
612 .text_color(if enabled {
613 theme.text_muted
614 } else {
615 // The dimming `glyph_slot` gives a disabled glyph, so the whole
616 // row fades by one rule rather than two.
617 theme.text_faint.opacity(0.5)
618 })
619 .child(description)
620}
621
622/// A row that cannot be chosen: [`popover::menu_row`]'s metrics without its
623/// hover fade or its click, because a disabled row that lit under the pointer
624/// would be inviting a press that does nothing.
625fn disabled_row(theme: &Theme) -> gpui::Div {
626 div()
627 .flex()
628 .flex_row()
629 .items_center()
630 .gap(px(10.0))
631 .px(px(8.0))
632 .py(px(6.0))
633 // The disabled twin of `popover::menu_row`, in the same card — so it
634 // takes its corners from the same rule rather than a matching literal.
635 .rounded(px(Theme::inset_radius(
636 Theme::surface_radius(),
637 popover::MENU_PAD,
638 )))
639 .text_style(TextStyle::Body)
640 .text_color(theme.text_faint)
641}