Skip to main content

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}