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