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, keys, popover, scroll, tooltip::Tooltip};
15use gpui::{
16    Action, Axis, Context, MouseDownEvent, Pixels, Point, ScrollHandle, SharedString, Size, Window,
17    div, prelude::*, px,
18};
19use icons::Icon;
20use std::{cell::Cell, rc::Rc};
21use theme::{TextStyle, Theme, Typeset};
22
23/// The leading glyph and the trailing check, at the size the rows are set in.
24const GLYPH: f32 = 13.0;
25
26/// A segment's glyph, over its label.
27const SEGMENT_GLYPH: f32 = 16.0;
28
29/// How wide a panel sits.
30const PANEL_MIN: f32 = 180.0;
31/// How wide one holding a described row sits — a width, not a floor. A
32/// description is a sentence rather than a name, so the panel widens the way
33/// one icon opens the glyph gutter; and because the sentence is kept to one
34/// line, the panel needs a ceiling to clip it against rather than growing to
35/// whatever the longest one measures.
36const PANEL_DESCRIBED: f32 = 280.0;
37
38/// A row in a menu.
39///
40/// Deliberately not a struct with an `is_separator` flag: a separator has no
41/// label, no accelerator and nothing to enable, and every one of those fields
42/// would have to be answered anyway.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum Item {
45    Action {
46        label: SharedString,
47        /// A second line under the label, for a row whose name does not say
48        /// enough on its own — what a command will do, which file it will act
49        /// on. `None` keeps the row one line tall; nothing reserves space for
50        /// a description the way the glyph gutter reserves space for an icon,
51        /// because a row's two lines read as one block and a blank second line
52        /// would read as a gap.
53        description: Option<SharedString>,
54        /// Hover text for the row, shown after gpui's tooltip delay. Where the
55        /// whole of a [`Item::with_description`] too long for its one line
56        /// goes, and where a disabled row says why — a disabled row takes no
57        /// click, but it still takes a tooltip.
58        tooltip: Option<SharedString>,
59        /// The leading glyph. A menu where no row has one keeps no room
60        /// for it.
61        icon: Option<Icon>,
62        /// The accelerator to *print* — the binding itself is the app's, and
63        /// bezel never dispatches it. A menu that showed a keystroke it did not
64        /// own would be documenting a lie, which is what
65        /// [`Item::with_shortcut`] fills this from the keymap to avoid.
66        keystroke: Option<SharedString>,
67        /// The choice the menu is currently on, marked with a trailing check.
68        checked: bool,
69        enabled: bool,
70    },
71    /// A row that drops a menu of its own, the way a SwiftUI `Menu` nests
72    /// inside a `Menu`. It carries no accelerator and nothing to check: the
73    /// only thing choosing it does is open.
74    Submenu {
75        label: SharedString,
76        icon: Option<Icon>,
77        enabled: bool,
78        items: Vec<Item>,
79    },
80    /// Options side by side in one row, each a glyph over its label, the
81    /// `selected` one highlighted. A pick is a [`Hit::Choose`] whose path is
82    /// the row's, then the segment's index.
83    Segmented {
84        segments: Vec<Segment>,
85        selected: usize,
86        enabled: bool,
87    },
88    Separator,
89}
90
91/// One option of an [`Item::Segmented`] row.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct Segment {
94    pub icon: Icon,
95    pub label: SharedString,
96}
97
98impl Segment {
99    pub fn new(icon: impl Into<Icon>, label: impl Into<SharedString>) -> Self {
100        Self {
101            icon: icon.into(),
102            label: label.into(),
103        }
104    }
105}
106
107impl Item {
108    pub fn action(label: impl Into<SharedString>) -> Self {
109        Item::Action {
110            label: label.into(),
111            description: None,
112            tooltip: None,
113            icon: None,
114            keystroke: None,
115            checked: false,
116            enabled: true,
117        }
118    }
119
120    pub fn submenu(label: impl Into<SharedString>, items: Vec<Item>) -> Self {
121        Item::Submenu {
122            label: label.into(),
123            icon: None,
124            enabled: true,
125            items,
126        }
127    }
128
129    pub fn segmented(segments: impl IntoIterator<Item = Segment>, selected: usize) -> Self {
130        Item::Segmented {
131            segments: segments.into_iter().collect(),
132            selected,
133            enabled: true,
134        }
135    }
136
137    /// No-ops on a separator, which has nothing to hang a glyph on, and on a
138    /// segmented row, whose glyphs are its segments'.
139    pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
140        match &mut self {
141            Item::Action { icon: slot, .. } | Item::Submenu { icon: slot, .. } => {
142                *slot = Some(icon.into())
143            }
144            Item::Segmented { .. } | Item::Separator => {}
145        }
146        self
147    }
148
149    /// A second line under the label. No-ops on anything but an action row: a
150    /// submenu's second line is the panel it opens, and a separator has no
151    /// first line to put one under.
152    pub fn with_description(mut self, description: impl Into<SharedString>) -> Self {
153        if let Item::Action {
154            description: slot, ..
155        } = &mut self
156        {
157            *slot = Some(description.into());
158        }
159        self
160    }
161
162    /// The description *and* the whole of it on hover, from one string. A
163    /// sentence long enough to need clipping is one no caller should have to
164    /// write twice — two copies of it drift.
165    pub fn with_long_description(self, description: impl Into<SharedString>) -> Self {
166        let description = description.into();
167        self.with_description(description.clone())
168            .with_tooltip(description)
169    }
170
171    /// Hover text for the row — the rest of a description the row had to clip,
172    /// or why a disabled row is disabled. No-ops on anything but an action
173    /// row: a submenu row is already hovered to open it, and a label the
174    /// pointer waits on top of would fight the panel it drops.
175    pub fn with_tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
176        if let Item::Action { tooltip: slot, .. } = &mut self {
177            *slot = Some(tooltip.into());
178        }
179        self
180    }
181
182    /// No-ops on anything but an action row: a submenu's keystroke is the
183    /// arrow that opens it, and a separator has nothing to hang one on.
184    pub fn with_keystroke(mut self, keystroke: impl Into<SharedString>) -> Self {
185        if let Item::Action {
186            keystroke: slot, ..
187        } = &mut self
188        {
189            *slot = Some(keystroke.into());
190        }
191        self
192    }
193
194    /// The accelerator read off the keymap instead of typed in — the same slot
195    /// [`Item::with_keystroke`] fills, filled with what would actually fire.
196    /// An action with nothing bound to it leaves the row bare, because a menu
197    /// that prints a chord it no longer owns is documenting a lie.
198    ///
199    /// Still nothing to dispatch: the row's click stays the caller's, and the
200    /// action passed here is read, never run.
201    pub fn with_shortcut(self, action: &dyn Action, window: &Window) -> Self {
202        match keys::shortcut(action, window) {
203            Some(keystroke) => self.with_keystroke(keystroke),
204            None => self,
205        }
206    }
207
208    /// [`Item::with_shortcut`] for a chord bound to a surface that is not
209    /// focused — which is most menu rows, since opening the menu took focus
210    /// off whatever the row acts on. `context` is the one the binding was
211    /// scoped to (`editor::CONTEXT`, [`crate::input::KEY_CONTEXT`]).
212    pub fn with_shortcut_in(self, action: &dyn Action, context: &str, window: &Window) -> Self {
213        match keys::shortcut_in(action, context, window) {
214            Some(keystroke) => self.with_keystroke(keystroke),
215            None => self,
216        }
217    }
218
219    /// Takes the flag, because what a menu is on is decided per render. No-ops
220    /// on a submenu, which is not itself a choice.
221    pub fn checked(mut self, checked: bool) -> Self {
222        if let Item::Action { checked: slot, .. } = &mut self {
223            *slot = checked;
224        }
225        self
226    }
227
228    pub fn disabled(mut self) -> Self {
229        match &mut self {
230            Item::Action { enabled, .. }
231            | Item::Submenu { enabled, .. }
232            | Item::Segmented { enabled, .. } => *enabled = false,
233            Item::Separator => {}
234        }
235        self
236    }
237
238    /// Whether the keyboard and the pointer can land here at all. A submenu
239    /// with nothing to choose in it counts as unlandable: opening it would drop
240    /// a panel that is a dead end.
241    pub fn selectable(&self) -> bool {
242        match self {
243            Item::Action { enabled, .. } => *enabled,
244            Item::Submenu { enabled, items, .. } => *enabled && items.iter().any(Item::selectable),
245            Item::Segmented {
246                enabled, segments, ..
247            } => *enabled && !segments.is_empty(),
248            Item::Separator => false,
249        }
250    }
251
252    fn has_description(&self) -> bool {
253        matches!(
254            self,
255            Item::Action {
256                description: Some(_),
257                ..
258            }
259        )
260    }
261
262    fn has_icon(&self) -> bool {
263        matches!(
264            self,
265            Item::Action { icon: Some(_), .. } | Item::Submenu { icon: Some(_), .. }
266        )
267    }
268
269    /// The segment a cursor landing on this row starts on: the selected one.
270    fn landing(&self) -> Option<usize> {
271        match self {
272            Item::Segmented {
273                segments, selected, ..
274            } if !segments.is_empty() => Some((*selected).min(segments.len() - 1)),
275            _ => None,
276        }
277    }
278
279    /// The submenu this row opens, if it is one that can be opened.
280    fn opens(&self) -> Option<&[Item]> {
281        match self {
282            Item::Submenu {
283                enabled: true,
284                items,
285                ..
286            } => Some(items),
287            _ => None,
288        }
289    }
290}
291
292/// The item `path` names — one row index per level, outermost first. `None`
293/// for a path to a segment of an [`Item::Segmented`] row; its path less the
294/// last index names the row.
295pub fn at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a Item> {
296    let (&row, above) = path.split_last()?;
297    items_at(items, above)?.get(row)
298}
299
300/// The menu `path` opens into: `items` itself for an empty path, else the rows
301/// of the submenu each index names. `None` as soon as one of them does not name
302/// an open-able submenu.
303pub fn items_at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a [Item]> {
304    let mut level = items;
305    for &row in path {
306        level = level.get(row)?.opens()?;
307    }
308    Some(level)
309}
310
311/// How many levels of `open` still name an open-able submenu — where both the
312/// painting and the out-click count stop when the chain has gone stale.
313fn open_depth(items: &[Item], open: &[usize]) -> usize {
314    let mut level = items;
315    for (depth, &row) in open.iter().enumerate() {
316        match level.get(row).and_then(Item::opens) {
317            Some(inner) => level = inner,
318            None => return depth,
319        }
320    }
321    open.len()
322}
323
324/// The next row the keyboard can land on, `delta` deciding the direction:
325/// separators and disabled rows are stepped straight over, and both ends wrap.
326/// `from` of `None` enters the menu at the edge the direction comes from.
327///
328/// [`popover::menu_step`] cannot do this — it counts rows and knows nothing
329/// about which of them can be landed on. `None` back means *nothing* in the menu
330/// is selectable, which is the one shape that would otherwise spin forever.
331pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
332    let count = items.len();
333    if count == 0 {
334        return None;
335    }
336    let step = if delta >= 0 { 1 } else { -1 };
337    let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
338    // Entering, the first candidate is the edge itself; moving, it is the row
339    // after the one you are on.
340    let mut at = match from {
341        None if step > 0 => 0,
342        None => count - 1,
343        Some(at) => wrap(at.min(count - 1)),
344    };
345    for _ in 0..count {
346        if items[at].selectable() {
347            return Some(at);
348        }
349        at = wrap(at);
350    }
351    None
352}
353
354// ---------------------------------------------------------------------------
355// Cursor
356// ---------------------------------------------------------------------------
357
358/// Where an open menu is being worked: `open` is the chain of submenu rows
359/// currently down, outermost first, `row` is the live row in the menu that
360/// chain ends at, and `segment` the live segment when that row is an
361/// [`Item::Segmented`].
362///
363/// One cursor for both input devices. A menu that tracked hover separately from
364/// the keyboard could have two rows lit and a submenu hanging off neither.
365#[derive(Clone, Debug, Default, PartialEq, Eq)]
366pub struct Cursor {
367    open: Vec<usize>,
368    row: Option<usize>,
369    segment: Option<usize>,
370}
371
372impl Cursor {
373    /// The submenu rows currently down, outermost first.
374    pub fn open(&self) -> &[usize] {
375        &self.open
376    }
377
378    /// The live row in the innermost open menu.
379    pub fn row(&self) -> Option<usize> {
380        self.row
381    }
382
383    /// The live segment, while the live row is an [`Item::Segmented`].
384    pub fn segment(&self) -> Option<usize> {
385        self.segment
386    }
387
388    /// Whether a submenu is down — where a menubar's `left` closes a level
389    /// instead of crossing to the previous menu.
390    pub fn nested(&self) -> bool {
391        !self.open.is_empty()
392    }
393
394    /// The full path to the live row, outermost first, and on to the live
395    /// segment of a segmented row.
396    pub fn path(&self) -> Option<Vec<usize>> {
397        let row = self.row?;
398        let mut path = self.open.clone();
399        path.push(row);
400        path.extend(self.segment);
401        Some(path)
402    }
403
404    /// Nothing down, nothing live — a menu opens here, and closes back to it.
405    pub fn clear(&mut self) {
406        self.open.clear();
407        self.row = None;
408        self.segment = None;
409    }
410
411    /// The row lit in the panel at `depth`: the row holding the submenu open
412    /// above the innermost panel, the live row in it, nothing below.
413    pub fn lit(&self, depth: usize) -> Option<usize> {
414        match depth.cmp(&self.open.len()) {
415            std::cmp::Ordering::Less => Some(self.open[depth]),
416            std::cmp::Ordering::Equal => self.row,
417            std::cmp::Ordering::Greater => None,
418        }
419    }
420
421    /// Move within the innermost open panel.
422    pub fn step(&mut self, root: &[Item], delta: isize) {
423        let Some(items) = items_at(root, &self.open) else {
424            return;
425        };
426        self.row = next_selectable(items, self.row, delta);
427        self.segment = self.row.and_then(|row| items[row].landing());
428    }
429
430    /// Move across the segments of the live row, stopping at either end.
431    /// `false` when the live row is not an [`Item::Segmented`].
432    pub fn slide(&mut self, root: &[Item], delta: isize) -> bool {
433        let Some(Item::Segmented { segments, .. }) = self
434            .row
435            .and_then(|row| items_at(root, &self.open)?.get(row))
436        else {
437            return false;
438        };
439        let at = self.segment.unwrap_or(0) as isize + delta.signum();
440        self.segment = Some(at.clamp(0, segments.len() as isize - 1) as usize);
441        true
442    }
443
444    /// Open the submenu under the live row and land on its first row. `false`
445    /// when the live row is not one — which is a menubar's cue that `right`
446    /// meant the next menu instead.
447    pub fn descend(&mut self, root: &[Item]) -> bool {
448        let Some(row) = self.row else { return false };
449        let Some(inner) = items_at(root, &self.open)
450            .and_then(|items| items.get(row))
451            .and_then(Item::opens)
452        else {
453            return false;
454        };
455        self.row = next_selectable(inner, None, 1);
456        self.segment = self.row.and_then(|row| inner[row].landing());
457        self.open.push(row);
458        true
459    }
460
461    /// Close the innermost submenu, landing back on the row that opened it.
462    /// `false` at the top level, where closing is the whole menu's to do.
463    pub fn ascend(&mut self) -> bool {
464        match self.open.pop() {
465            Some(row) => {
466                self.row = Some(row);
467                self.segment = None;
468                true
469            }
470            None => false,
471        }
472    }
473
474    /// Put the cursor on the row `path` names, opening the chain above it and,
475    /// if it is a submenu row, itself — pointing at a submenu row is what opens
476    /// it. Nothing is lit inside the fresh panel until something moves into it.
477    /// A path to a segment puts the cursor on that segment of its row.
478    ///
479    /// Answers whether that changed anything, because the pointer reports every
480    /// move and only a change is worth a repaint.
481    pub fn point_at(&mut self, root: &[Item], path: &[usize]) -> bool {
482        let (open, row, segment) = match path {
483            [] => (Vec::new(), None, None),
484            [above @ .., row, segment]
485                if matches!(
486                    at(root, &path[..path.len() - 1]),
487                    Some(Item::Segmented { .. })
488                ) =>
489            {
490                (above.to_vec(), Some(*row), Some(*segment))
491            }
492            _ if at(root, path).and_then(Item::opens).is_some() => (path.to_vec(), None, None),
493            [above @ .., row] => (
494                above.to_vec(),
495                Some(*row),
496                at(root, path).and_then(Item::landing),
497            ),
498        };
499        if self.open == open && self.row == row && self.segment == segment {
500            return false;
501        }
502        self.open = open;
503        self.row = row;
504        self.segment = segment;
505        true
506    }
507}
508
509// ---------------------------------------------------------------------------
510// The panels
511// ---------------------------------------------------------------------------
512
513/// What the pointer did to a menu. Every variant is a request: the caller's
514/// [`Cursor`] and open state are what answer it.
515#[derive(Clone, Debug, PartialEq, Eq)]
516pub enum Hit {
517    /// The pointer is on this row, or a submenu row was clicked. Feed it to
518    /// [`Cursor::point_at`], which is also what opens a submenu.
519    Point(Vec<usize>),
520    /// An action row was chosen.
521    Choose(Vec<usize>),
522    /// A press outside every panel of the open tree. Reported here rather than
523    /// left to an `.on_mouse_down_out` on the card, which sees only its own
524    /// bounds and would read a click in a submenu as a click away.
525    Dismiss,
526}
527
528/// The panel a menu drops: every [`Item`] as a row, in a
529/// [`popover::popover_card`], with a further panel hanging off each submenu row
530/// the [`Cursor`] holds open. `id` prefixes the rows' element ids, so two menus
531/// open at once keep their hover state apart.
532///
533/// The root panel is returned unanchored: mount it through one of
534/// [`popover`]'s anchored layers ([`popover::anchored_menu_below`],
535/// [`popover::menu_at`], …). Childed bare, it paints in flow, under every
536/// later sibling. Submenu panels are anchored here.
537///
538/// Every panel is capped at the window's height less the snap margin either
539/// side, and scrolls its rows past that. A submenu hangs off its row wherever
540/// the row has scrolled to, outside the parent's clip.
541///
542/// The pointer moves the cursor rather than lighting a row of its own, so a
543/// submenu can only ever hang off the row that is live. Everything the pointer
544/// does arrives as a [`Hit`], dismissal included.
545pub fn card<V: 'static>(
546    theme: &Theme,
547    id: impl Into<SharedString>,
548    items: &[Item],
549    cursor: &Cursor,
550    window: &mut Window,
551    cx: &mut Context<V>,
552    on: impl Fn(&mut V, Hit, &mut Window, &mut Context<V>) + 'static,
553) -> gpui::Div {
554    let tree = Tree {
555        id: id.into(),
556        panels: 1 + open_depth(items, cursor.open()),
557        outside: Rc::new(Cell::new((None, 0))),
558        chain: popover::Chain::default(),
559        on: Rc::new(on),
560    };
561    tree.panel(theme, items, cursor, &[], window, cx)
562}
563
564/// A gpui mouse listener, boxed: `Context::listener` borrows the context it is
565/// made from, and these are handed back out of a method that must not.
566type Listener<E> = Box<dyn Fn(&E, &mut Window, &mut gpui::App)>;
567
568/// The caller's [`Hit`] handler, shared by every panel of one open tree.
569type Reporter<V> = Rc<dyn Fn(&mut V, Hit, &mut Window, &mut Context<V>)>;
570
571/// The parts every panel of one open tree shares.
572struct Tree<V: 'static> {
573    id: SharedString,
574    /// How many panels will paint — the divisor of the out-click count below.
575    panels: usize,
576    /// The press each panel has already reported as outside itself, stamped
577    /// with where it landed. A press outside *all* of them is the one that
578    /// dismisses, and no panel alone can tell.
579    outside: Rc<Cell<(Option<Point<Pixels>>, usize)>>,
580    /// Which way the open panels are stepping across the window.
581    chain: popover::Chain,
582    on: Reporter<V>,
583}
584
585impl<V: 'static> Tree<V> {
586    fn panel(
587        &self,
588        theme: &Theme,
589        items: &[Item],
590        cursor: &Cursor,
591        prefix: &[usize],
592        window: &mut Window,
593        cx: &mut Context<V>,
594    ) -> gpui::Div {
595        let depth = prefix.len();
596        let lit = cursor.lit(depth);
597        let down = cursor.open().get(depth).copied();
598        let rows_id = SharedString::from(format!("{}-rows", row_id(&self.id, prefix)));
599        let Scrolled(handle, shown) = window
600            .use_keyed_state(
601                SharedString::from(format!("{rows_id}-scrolled")),
602                cx,
603                |_, _| Scrolled::default(),
604            )
605            .read(cx)
606            .clone();
607        // Only a change of live row or of the viewport's size scrolls: a wheel
608        // that carried the live row out of view is left where it put it. gpui
609        // drops a request made before the rows have been laid out, and measures
610        // one against the viewport of the frame before.
611        let size = handle.bounds().size;
612        if size.height > Pixels::ZERO && shown.get() != Some((lit, size)) {
613            shown.set(Some((lit, size)));
614            if let Some(lit) = lit {
615                handle.scroll_to_item(lit);
616            }
617        }
618        let inset = px(popover::SNAP) + window.client_inset().unwrap_or(Pixels::ZERO);
619        let cap = window.viewport_size().height - inset * 2.0;
620        // A menu where nothing carries a glyph keeps no room for one — a bar's
621        // menus would otherwise open with an empty column down their left.
622        let gutter = items.iter().any(Item::has_icon);
623        let described = items.iter().any(Item::has_description);
624        let rows =
625            div().id(rows_id.clone()).p(px(popover::MENU_PAD)).children(
626                items.iter().enumerate().map(|(row, item)| {
627                    if matches!(item, Item::Separator) {
628                        return popover::divider().into_any_element();
629                    }
630                    let path: Vec<usize> = prefix.iter().copied().chain([row]).collect();
631                    if let Item::Segmented {
632                        segments,
633                        selected,
634                        enabled,
635                    } = item
636                    {
637                        let live = cursor.segment().filter(|_| lit == Some(row));
638                        return self
639                            .segmented(theme, segments, *selected, *enabled, live, &path, cx)
640                            .into_any_element();
641                    }
642                    let id = row_id(&self.id, &path);
643                    let (label, icon, enabled) = match item {
644                        Item::Action {
645                            label,
646                            icon,
647                            enabled,
648                            ..
649                        }
650                        | Item::Submenu {
651                            label,
652                            icon,
653                            enabled,
654                            ..
655                        } => (label.clone(), icon.clone(), *enabled),
656                        Item::Segmented { .. } | Item::Separator => {
657                            unreachable!("returned above")
658                        }
659                    };
660                    let (description, hint) = match item {
661                        Item::Action {
662                            description,
663                            tooltip,
664                            ..
665                        } => (description.clone(), tooltip.clone()),
666                        _ => (None, None),
667                    };
668                    let row = if enabled {
669                        popover::menu_row(theme, lit == Some(row), None)
670                            .id(id.clone())
671                            .on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
672                            .on_click(self.reports(
673                                match item {
674                                    // Clicking a submenu row opens it; there is
675                                    // nothing else it could mean.
676                                    Item::Submenu { .. } => Hit::Point(path.clone()),
677                                    _ => Hit::Choose(path.clone()),
678                                },
679                                cx,
680                            ))
681                    } else {
682                        disabled_row(theme).id(id.clone())
683                    };
684                    let row = row.when_some(hint, |row, hint| {
685                        row.tooltip(move |window, cx| Tooltip::text(hint.clone(), window, cx))
686                    });
687                    row.when(gutter, |row| row.child(glyph_slot(theme, icon, enabled)))
688                        .child(
689                            div()
690                                .flex_1()
691                                .min_w_0()
692                                .flex()
693                                .flex_col()
694                                .child(label)
695                                .children(description.map(|description| {
696                                    description_line(theme, description, enabled)
697                                })),
698                        )
699                        .map(|row| match item {
700                            Item::Action {
701                                keystroke, checked, ..
702                            } => row
703                                .when(*checked, |row| {
704                                    row.child(
705                                        icons::icon(icons::glyph::Check)
706                                            .size(px(GLYPH))
707                                            .text_color(theme.text),
708                                    )
709                                })
710                                .when_some(keystroke.clone(), |row, keystroke| {
711                                    row.child(popover::kbd_hint(theme, &keystroke))
712                                }),
713                            _ => row.child(
714                                icons::icon(icons::glyph::ChevronRight)
715                                    .size(px(GLYPH))
716                                    .text_color(theme.text_faint),
717                            ),
718                        })
719                        .when_some(
720                            item.opens().filter(|_| down == Some(path[depth])),
721                            |parent, inner| {
722                                let panel = self
723                                    .panel(theme, inner, cursor, &path, window, cx)
724                                    .into_any_element();
725                                parent.relative().child(popover::anchored_submenu(
726                                    SharedString::from(format!("{id}-panel")),
727                                    panel,
728                                    &self.chain,
729                                ))
730                            },
731                        )
732                        .into_any_element()
733                }),
734            );
735        popover::popover_card(theme)
736            .p_0()
737            .flex()
738            .flex_col()
739            .max_h(cap)
740            .map(|card| match described {
741                true => card.w(px(PANEL_DESCRIBED)),
742                false => card.min_w(px(PANEL_MIN)),
743            })
744            .on_mouse_down_out(self.dismissal(cx))
745            .child(
746                scroll::Viewport::new(rows_id, rows, Axis::Vertical)
747                    .track_scroll(&handle)
748                    .fill(),
749            )
750    }
751
752    /// An [`Item::Segmented`] row: its segments share the row's width, the
753    /// selected one washed, the live one lit the way a live row is.
754    #[allow(clippy::too_many_arguments)]
755    fn segmented(
756        &self,
757        theme: &Theme,
758        segments: &[Segment],
759        selected: usize,
760        enabled: bool,
761        live: Option<usize>,
762        path: &[usize],
763        cx: &mut Context<V>,
764    ) -> gpui::Stateful<gpui::Div> {
765        div()
766            .id(row_id(&self.id, path))
767            .flex()
768            .flex_row()
769            .gap(px(popover::MENU_PAD))
770            .children(segments.iter().enumerate().map(|(at, segment)| {
771                let path: Vec<usize> = path.iter().copied().chain([at]).collect();
772                let chosen = at == selected;
773                let ink = match (enabled, chosen || live == Some(at)) {
774                    (false, _) => theme.text_faint.opacity(0.5),
775                    (true, true) => theme.text,
776                    (true, false) => theme.text_muted,
777                };
778                let cell = div()
779                    .id(row_id(&self.id, &path))
780                    .flex_1()
781                    .min_w_0()
782                    .flex()
783                    .flex_col()
784                    .items_center()
785                    .gap(px(4.0))
786                    .px(px(8.0))
787                    .py(px(6.0))
788                    .rounded(px(Theme::inset_radius(
789                        Theme::surface_radius(),
790                        popover::MENU_PAD,
791                    )))
792                    .text_style(TextStyle::Callout)
793                    .text_color(ink)
794                    .when(chosen, |cell| {
795                        cell.bg(theme.element_active)
796                            .font_weight(gpui::FontWeight::MEDIUM)
797                    })
798                    .when(!chosen && live == Some(at), |cell| {
799                        cell.bg(theme.card_selected_bg())
800                    })
801                    .child(
802                        icons::icon(segment.icon.clone())
803                            .size(px(SEGMENT_GLYPH))
804                            .text_color(ink),
805                    )
806                    .child(div().max_w_full().truncate().child(segment.label.clone()));
807                match enabled {
808                    true => cell
809                        .cursor_pointer()
810                        .on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
811                        .on_click(self.reports(Hit::Choose(path), cx)),
812                    false => cell,
813                }
814            }))
815    }
816
817    /// One panel's share of the out-click test: it reports the press it did not
818    /// contain, and whichever panel completes the tally is the one that calls
819    /// it a dismissal. Order between them does not matter, only the count.
820    ///
821    /// The dismissing press is swallowed. It runs in the capture phase, where
822    /// stopping propagation skips the bubble phase entirely — which is where
823    /// gpui records the press a click is later built from, so the row under the
824    /// pointer never fires. A press that lands on something while a menu is
825    /// open is a press asking for the menu to go away, and acting on what it
826    /// landed on runs an operation nobody aimed at (user report, DEV-11). The
827    /// thing under it is still one more press away.
828    fn dismissal(&self, cx: &mut Context<V>) -> Listener<MouseDownEvent> {
829        let outside = self.outside.clone();
830        let panels = self.panels;
831        let on = self.on.clone();
832        Box::new(
833            cx.listener(move |view, event: &MouseDownEvent, window, cx| {
834                let (at, count) = outside.get();
835                let count = if at == Some(event.position) {
836                    count + 1
837                } else {
838                    1
839                };
840                outside.set((Some(event.position), count));
841                if count >= panels {
842                    outside.set((None, 0));
843                    on(view, Hit::Dismiss, window, cx);
844                    cx.stop_propagation();
845                }
846            }),
847        )
848    }
849
850    /// A listener that hands `hit` back to the caller, whatever the event was.
851    fn reports<E: 'static>(&self, hit: Hit, cx: &mut Context<V>) -> Listener<E> {
852        let on = self.on.clone();
853        Box::new(cx.listener(move |view, _: &E, window, cx| on(view, hit.clone(), window, cx)))
854    }
855}
856
857/// One panel's scroll position, and the live row and viewport size it last
858/// scrolled for.
859#[derive(Clone, Default)]
860struct Scrolled(ScrollHandle, Rc<Cell<Option<Shown>>>);
861
862type Shown = (Option<usize>, Size<Pixels>);
863
864/// A row's element id: the card's, then the path, so rows of two panels — or of
865/// two menus open at once — never collide.
866fn row_id(id: &SharedString, path: &[usize]) -> SharedString {
867    let mut out = id.to_string();
868    for row in path {
869        out.push('-');
870        out.push_str(&row.to_string());
871    }
872    SharedString::from(out)
873}
874
875/// The leading column: the row's glyph, or the room one would have taken, so a
876/// menu of mixed rows keeps its labels on one edge.
877fn glyph_slot(theme: &Theme, icon: Option<Icon>, enabled: bool) -> gpui::Div {
878    div()
879        .flex_none()
880        .size(px(GLYPH))
881        .flex()
882        .items_center()
883        .justify_center()
884        .children(icon.map(|glyph| {
885            icons::icon(glyph).size(px(GLYPH)).text_color(if enabled {
886                theme.text_faint
887            } else {
888                theme.text_faint.opacity(0.5)
889            })
890        }))
891}
892
893/// The second line under a row's label: the same relationship a card row's
894/// meta line has to its title ([`crate::widgets::Scaffolding::meta_line`]),
895/// which is where the size and the tone come from.
896///
897/// One line, clipped — rows of a menu are a column of equal things, and a
898/// sentence that wrapped would make its row two or three times its neighbours'
899/// height. What a caller cannot do from outside is guess where to cut: it
900/// would be counting characters against a proportional font at a width only
901/// the panel knows. [`Item::with_tooltip`] is where the rest of it goes.
902///
903/// It sets its own colour rather than inheriting the row's, because the row's
904/// is the *label's* — a lit row paints that at full contrast, and a
905/// description that followed it there would stop reading as the quieter half.
906fn description_line(theme: &Theme, description: SharedString, enabled: bool) -> gpui::Div {
907    div()
908        .mt(px(2.0))
909        .truncate()
910        .text_style(TextStyle::Subheadline)
911        .text_color(if enabled {
912            theme.text_muted
913        } else {
914            // The dimming `glyph_slot` gives a disabled glyph, so the whole
915            // row fades by one rule rather than two.
916            theme.text_faint.opacity(0.5)
917        })
918        .child(description)
919}
920
921/// A row that cannot be chosen: [`popover::menu_row`]'s metrics without its
922/// hover fade or its click, because a disabled row that lit under the pointer
923/// would be inviting a press that does nothing.
924fn disabled_row(theme: &Theme) -> gpui::Div {
925    div()
926        .flex()
927        .flex_row()
928        .items_center()
929        .gap(px(10.0))
930        .px(px(8.0))
931        .py(px(popover::MENU_ROW_PAD_Y))
932        // The disabled twin of `popover::menu_row`, in the same card — so it
933        // takes its corners from the same rule rather than a matching literal.
934        .rounded(px(Theme::inset_radius(
935            Theme::surface_radius(),
936            popover::MENU_PAD,
937        )))
938        .text_style(TextStyle::Body)
939        .text_color(theme.text_faint)
940}