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::{
15    icons,
16    input::{FieldEvent, TextField},
17    keys, popover, scroll,
18    tooltip::Tooltip,
19};
20use gpui::{
21    Action, App, Axis, Context, Entity, Focusable as _, Global, KeyBinding, MouseDownEvent, Pixels,
22    Point, ScrollHandle, SharedString, Size, Subscription, Window, actions, div, prelude::*, px,
23};
24use icons::Icon;
25use std::{cell::Cell, rc::Rc};
26use theme::{TextStyle, Theme, Typeset};
27
28/// The leading glyph and the trailing check, at the size the rows are set in.
29const GLYPH: f32 = 13.0;
30
31/// A segment's glyph, over its label.
32const SEGMENT_GLYPH: f32 = 16.0;
33
34/// How many rows a searchable submenu shows before it scrolls.
35const SEARCH_ROWS: f32 = 10.0;
36const DEFAULT_ROWS: usize = 12;
37
38actions!(
39    bezel_menu,
40    [
41        SelectNext,
42        SelectPrevious,
43        SelectChild,
44        SelectParent,
45        Confirm,
46        Dismiss
47    ]
48);
49
50/// How far each [`Item::indented`] level moves a row's content in, app-wide.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct MenuIndent(pub Pixels);
53
54impl Default for MenuIndent {
55    fn default() -> Self {
56        Self(px(12.0))
57    }
58}
59
60impl Global for MenuIndent {}
61
62pub(crate) fn indent(cx: &App) -> MenuIndent {
63    cx.try_global::<MenuIndent>().copied().unwrap_or_default()
64}
65
66/// Changes the step and repaints open windows.
67pub(crate) fn set_indent(indent: MenuIndent, cx: &mut App) {
68    cx.set_global(indent);
69    cx.refresh_windows();
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub struct MenuHeight {
74    pub rows: usize,
75}
76
77impl Default for MenuHeight {
78    fn default() -> Self {
79        Self { rows: DEFAULT_ROWS }
80    }
81}
82
83impl MenuHeight {
84    pub fn limit(self, available: Pixels) -> Pixels {
85        px(self.rows.max(1) as f32 * popover::menu_row_height() + 2. * popover::MENU_PAD)
86            .min(available.max(Pixels::ZERO))
87    }
88}
89
90impl Global for MenuHeight {}
91
92pub(crate) fn height(cx: &App) -> MenuHeight {
93    cx.try_global::<MenuHeight>().copied().unwrap_or_default()
94}
95
96pub(crate) fn set_height(height: MenuHeight, cx: &mut App) {
97    cx.set_global(height);
98    cx.refresh_windows();
99}
100
101/// How wide a menu's panels sit, app-wide.
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub struct MenuWidth {
104    /// The narrowest a panel sits. A panel without a described row grows past
105    /// it to its widest row.
106    pub min: Pixels,
107    /// How a panel holding an [`Item::with_description`] row is sized.
108    pub described: Described,
109}
110
111/// How a panel holding a described row is sized. A description is one line,
112/// cut at the panel's edge.
113#[derive(Clone, Copy, Debug, PartialEq)]
114pub enum Described {
115    /// Exactly this wide, whatever its rows measure.
116    Fixed(Pixels),
117    /// As wide as its widest row, from [`MenuWidth::min`] up to this.
118    Fit(Pixels),
119}
120
121impl Default for MenuWidth {
122    fn default() -> Self {
123        Self {
124            min: px(180.0),
125            described: Described::Fixed(px(280.0)),
126        }
127    }
128}
129
130impl Global for MenuWidth {}
131
132pub(crate) fn width(cx: &App) -> MenuWidth {
133    cx.try_global::<MenuWidth>().copied().unwrap_or_default()
134}
135
136/// Changes the widths and repaints open windows.
137pub(crate) fn set_width(width: MenuWidth, cx: &mut App) {
138    cx.set_global(width);
139    cx.refresh_windows();
140}
141
142/// The key context around a searchable submenu's query field. Typing goes to
143/// the field; the arrows, enter and escape fall through to the menu.
144pub const SEARCH_CONTEXT: &str = "MenuSearch";
145
146/// The keymap a searchable submenu and a [`crate::context_menu`] answer to,
147/// as data — see [`crate::keys`].
148pub fn bindings() -> Vec<KeyBinding> {
149    let ctx = Some(SEARCH_CONTEXT);
150    let context = Some(crate::context_menu::KEY_CONTEXT);
151    vec![
152        KeyBinding::new("down", SelectNext, ctx),
153        KeyBinding::new("up", SelectPrevious, ctx),
154        KeyBinding::new("ctrl-n", SelectNext, ctx),
155        KeyBinding::new("ctrl-p", SelectPrevious, ctx),
156        KeyBinding::new("enter", Confirm, ctx),
157        KeyBinding::new("escape", Dismiss, ctx),
158        KeyBinding::new("down", SelectNext, context),
159        KeyBinding::new("up", SelectPrevious, context),
160        KeyBinding::new("right", SelectChild, context),
161        KeyBinding::new("left", SelectParent, context),
162        KeyBinding::new("enter", Confirm, context),
163        KeyBinding::new("escape", Dismiss, context),
164    ]
165}
166
167/// Install [`bindings`]. Call once at startup.
168pub fn init(cx: &mut App) {
169    cx.bind_keys(bindings());
170}
171
172/// A row in a menu.
173///
174/// Deliberately not a struct with an `is_separator` flag: a separator has no
175/// label, no accelerator and nothing to enable, and every one of those fields
176/// would have to be answered anyway.
177#[derive(Clone, Debug, PartialEq, Eq)]
178pub enum Item {
179    Action {
180        label: SharedString,
181        /// A second line under the label, for a row whose name does not say
182        /// enough on its own — what a command will do, which file it will act
183        /// on. `None` keeps the row one line tall; nothing reserves space for
184        /// a description the way the glyph gutter reserves space for an icon,
185        /// because a row's two lines read as one block and a blank second line
186        /// would read as a gap.
187        description: Option<SharedString>,
188        /// The description stands after the label, on the label's line, and
189        /// takes the width the label leaves — see
190        /// [`Item::with_inline_description`].
191        inline: bool,
192        /// Hover text for the row, shown after gpui's tooltip delay. Where the
193        /// whole of a [`Item::with_description`] too long for its one line
194        /// goes, and where a disabled row says why — a disabled row takes no
195        /// click, but it still takes a tooltip.
196        tooltip: Option<SharedString>,
197        /// The leading glyph. A menu where no row has one keeps no room
198        /// for it.
199        icon: Option<Icon>,
200        /// Levels of [`Item::indented`]; 0 sits flush.
201        indent: usize,
202        /// The accelerator to *print* — the binding itself is the app's, and
203        /// bezel never dispatches it. A menu that showed a keystroke it did not
204        /// own would be documenting a lie, which is what
205        /// [`Item::with_shortcut`] fills this from the keymap to avoid.
206        keystroke: Option<SharedString>,
207        /// The choice the menu is currently on, marked with a trailing check.
208        checked: bool,
209        enabled: bool,
210    },
211    /// A row that drops a menu of its own, the way a SwiftUI `Menu` nests
212    /// inside a `Menu`. It carries no accelerator and nothing to check: the
213    /// only thing choosing it does is open.
214    Submenu {
215        label: SharedString,
216        icon: Option<Icon>,
217        indent: usize,
218        enabled: bool,
219        items: Vec<Item>,
220        /// The placeholder of the query field the panel opens with, focused.
221        /// Typing narrows its rows by label and description; the panel shows
222        /// [`SEARCH_ROWS`] rows and scrolls past them. `None` opens a plain
223        /// panel.
224        search: Option<SharedString>,
225    },
226    /// Options side by side in one row, each a glyph over its label, the
227    /// `selected` one highlighted. A pick is a [`Hit::Choose`] whose path is
228    /// the row's, then the segment's index.
229    Segmented {
230        segments: Vec<Segment>,
231        selected: usize,
232        enabled: bool,
233    },
234    /// A section's name over the rows after it. Never lit, never chosen.
235    Heading(SharedString),
236    /// A line of plain text about what the menu is on. Never lit, never
237    /// chosen.
238    Note(SharedString),
239    Separator,
240}
241
242/// One option of an [`Item::Segmented`] row.
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct Segment {
245    pub icon: Icon,
246    pub label: SharedString,
247}
248
249impl Segment {
250    pub fn new(icon: impl Into<Icon>, label: impl Into<SharedString>) -> Self {
251        Self {
252            icon: icon.into(),
253            label: label.into(),
254        }
255    }
256}
257
258impl Item {
259    pub fn action(label: impl Into<SharedString>) -> Self {
260        Item::Action {
261            label: label.into(),
262            description: None,
263            inline: false,
264            tooltip: None,
265            icon: None,
266            indent: 0,
267            keystroke: None,
268            checked: false,
269            enabled: true,
270        }
271    }
272
273    pub fn submenu(label: impl Into<SharedString>, items: Vec<Item>) -> Self {
274        Item::Submenu {
275            label: label.into(),
276            icon: None,
277            indent: 0,
278            enabled: true,
279            items,
280            search: None,
281        }
282    }
283
284    /// Open the submenu with a query field — see [`Item::Submenu::search`].
285    /// No-ops on anything but a submenu row.
286    pub fn searchable(mut self, placeholder: impl Into<SharedString>) -> Self {
287        if let Item::Submenu { search, .. } = &mut self {
288            *search = Some(placeholder.into());
289        }
290        self
291    }
292
293    pub fn segmented(segments: impl IntoIterator<Item = Segment>, selected: usize) -> Self {
294        Item::Segmented {
295            segments: segments.into_iter().collect(),
296            selected,
297            enabled: true,
298        }
299    }
300
301    /// No-ops on a separator and a heading, which have nothing to hang a glyph
302    /// on, and on a segmented row, whose glyphs are its segments'.
303    pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
304        match &mut self {
305            Item::Action { icon: slot, .. } | Item::Submenu { icon: slot, .. } => {
306                *slot = Some(icon.into())
307            }
308            Item::Segmented { .. } | Item::Heading(_) | Item::Note(_) | Item::Separator => {}
309        }
310        self
311    }
312
313    /// Moves the row's content in by `level` steps of the app's
314    /// [`MenuIndent`], so a flat list of rows can show a hierarchy. No-ops on
315    /// a separator, a heading and a segmented row.
316    pub fn indented(mut self, level: usize) -> Self {
317        match &mut self {
318            Item::Action { indent, .. } | Item::Submenu { indent, .. } => *indent = level,
319            Item::Segmented { .. } | Item::Heading(_) | Item::Note(_) | Item::Separator => {}
320        }
321        self
322    }
323
324    /// A second line under the label. No-ops on anything but an action row: a
325    /// submenu's second line is the panel it opens, and a separator has no
326    /// first line to put one under.
327    pub fn with_description(mut self, description: impl Into<SharedString>) -> Self {
328        if let Item::Action {
329            description: slot, ..
330        } = &mut self
331        {
332            *slot = Some(description.into());
333        }
334        self
335    }
336
337    /// A description after the label on the label's line, muted and cut at
338    /// the row's end, in place of a second line. No-ops on anything but an
339    /// action row, as [`Item::with_description`] does.
340    pub fn with_inline_description(mut self, description: impl Into<SharedString>) -> Self {
341        if let Item::Action {
342            description: slot,
343            inline,
344            ..
345        } = &mut self
346        {
347            *slot = Some(description.into());
348            *inline = true;
349        }
350        self
351    }
352
353    /// The description *and* the whole of it on hover, from one string. A
354    /// sentence long enough to need clipping is one no caller should have to
355    /// write twice — two copies of it drift.
356    pub fn with_long_description(self, description: impl Into<SharedString>) -> Self {
357        let description = description.into();
358        self.with_description(description.clone())
359            .with_tooltip(description)
360    }
361
362    /// Hover text for the row — the rest of a description the row had to clip,
363    /// or why a disabled row is disabled. No-ops on anything but an action
364    /// row: a submenu row is already hovered to open it, and a label the
365    /// pointer waits on top of would fight the panel it drops.
366    pub fn with_tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
367        if let Item::Action { tooltip: slot, .. } = &mut self {
368            *slot = Some(tooltip.into());
369        }
370        self
371    }
372
373    /// No-ops on anything but an action row: a submenu's keystroke is the
374    /// arrow that opens it, and a separator has nothing to hang one on.
375    pub fn with_keystroke(mut self, keystroke: impl Into<SharedString>) -> Self {
376        if let Item::Action {
377            keystroke: slot, ..
378        } = &mut self
379        {
380            *slot = Some(keystroke.into());
381        }
382        self
383    }
384
385    /// The accelerator read off the keymap instead of typed in — the same slot
386    /// [`Item::with_keystroke`] fills, filled with what would actually fire.
387    /// An action with nothing bound to it leaves the row bare, because a menu
388    /// that prints a chord it no longer owns is documenting a lie.
389    ///
390    /// Still nothing to dispatch: the row's click stays the caller's, and the
391    /// action passed here is read, never run.
392    pub fn with_shortcut(self, action: &dyn Action, window: &Window) -> Self {
393        match keys::shortcut(action, window) {
394            Some(keystroke) => self.with_keystroke(keystroke),
395            None => self,
396        }
397    }
398
399    /// [`Item::with_shortcut`] for a chord bound to a surface that is not
400    /// focused — which is most menu rows, since opening the menu took focus
401    /// off whatever the row acts on. `context` is the one the binding was
402    /// scoped to (`editor::CONTEXT`, [`crate::input::KEY_CONTEXT`]).
403    pub fn with_shortcut_in(self, action: &dyn Action, context: &str, window: &Window) -> Self {
404        match keys::shortcut_in(action, context, window) {
405            Some(keystroke) => self.with_keystroke(keystroke),
406            None => self,
407        }
408    }
409
410    /// Takes the flag, because what a menu is on is decided per render. No-ops
411    /// on a submenu, which is not itself a choice.
412    pub fn checked(mut self, checked: bool) -> Self {
413        if let Item::Action { checked: slot, .. } = &mut self {
414            *slot = checked;
415        }
416        self
417    }
418
419    pub fn disabled(mut self, disabled: bool) -> Self {
420        match &mut self {
421            Item::Action { enabled, .. }
422            | Item::Submenu { enabled, .. }
423            | Item::Segmented { enabled, .. } => *enabled = !disabled,
424            Item::Heading(_) | Item::Note(_) | Item::Separator => {}
425        }
426        self
427    }
428
429    /// Whether the keyboard and the pointer can land here at all. A submenu
430    /// with nothing to choose in it counts as unlandable: opening it would drop
431    /// a panel that is a dead end.
432    pub fn selectable(&self) -> bool {
433        match self {
434            Item::Action { enabled, .. } => *enabled,
435            Item::Submenu { enabled, items, .. } => *enabled && items.iter().any(Item::selectable),
436            Item::Segmented {
437                enabled, segments, ..
438            } => *enabled && !segments.is_empty(),
439            Item::Heading(_) | Item::Note(_) | Item::Separator => false,
440        }
441    }
442
443    fn has_description(&self) -> bool {
444        matches!(
445            self,
446            Item::Action {
447                description: Some(_),
448                ..
449            }
450        )
451    }
452
453    fn has_icon(&self) -> bool {
454        matches!(
455            self,
456            Item::Action { icon: Some(_), .. } | Item::Submenu { icon: Some(_), .. }
457        )
458    }
459
460    /// The segment a cursor landing on this row starts on: the selected one.
461    fn landing(&self) -> Option<usize> {
462        match self {
463            Item::Segmented {
464                segments, selected, ..
465            } if !segments.is_empty() => Some((*selected).min(segments.len() - 1)),
466            _ => None,
467        }
468    }
469
470    /// What a query is matched against: the label, then the description.
471    fn keywords(&self) -> String {
472        match self {
473            Item::Action {
474                label, description, ..
475            } => match description {
476                Some(description) => format!("{label} {description}"),
477                None => label.to_string(),
478            },
479            Item::Submenu { label, .. } => label.to_string(),
480            Item::Segmented { .. } | Item::Heading(_) | Item::Note(_) | Item::Separator => {
481                String::new()
482            }
483        }
484    }
485
486    /// The placeholder of the query field this row's submenu opens with.
487    fn search(&self) -> Option<&SharedString> {
488        match self {
489            Item::Submenu { search, .. } => search.as_ref(),
490            _ => None,
491        }
492    }
493
494    /// The submenu this row opens, if it is one that can be opened.
495    fn opens(&self) -> Option<&[Item]> {
496        match self {
497            Item::Submenu {
498                enabled: true,
499                items,
500                ..
501            } => Some(items),
502            _ => None,
503        }
504    }
505}
506
507/// The item `path` names — one row index per level, outermost first. `None`
508/// for a path to a segment of an [`Item::Segmented`] row; its path less the
509/// last index names the row.
510pub fn at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a Item> {
511    let (&row, above) = path.split_last()?;
512    items_at(items, above)?.get(row)
513}
514
515/// The menu `path` opens into: `items` itself for an empty path, else the rows
516/// of the submenu each index names. `None` as soon as one of them does not name
517/// an open-able submenu.
518pub fn items_at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a [Item]> {
519    let mut level = items;
520    for &row in path {
521        level = level.get(row)?.opens()?;
522    }
523    Some(level)
524}
525
526/// How many levels of `open` still name an open-able submenu — where both the
527/// painting and the out-click count stop when the chain has gone stale.
528fn open_depth(items: &[Item], open: &[usize]) -> usize {
529    let mut level = items;
530    for (depth, &row) in open.iter().enumerate() {
531        match level.get(row).and_then(Item::opens) {
532            Some(inner) => level = inner,
533            None => return depth,
534        }
535    }
536    open.len()
537}
538
539/// The next row the keyboard can land on, `delta` deciding the direction:
540/// separators and disabled rows are stepped straight over, and both ends wrap.
541/// `from` of `None` enters the menu at the edge the direction comes from.
542///
543/// [`popover::menu_step`] cannot do this — it counts rows and knows nothing
544/// about which of them can be landed on. `None` back means *nothing* in the menu
545/// is selectable, which is the one shape that would otherwise spin forever.
546pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
547    let count = items.len();
548    if count == 0 {
549        return None;
550    }
551    let step = if delta >= 0 { 1 } else { -1 };
552    let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
553    // Entering, the first candidate is the edge itself; moving, it is the row
554    // after the one you are on.
555    let mut at = match from {
556        None if step > 0 => 0,
557        None => count - 1,
558        Some(at) => wrap(at.min(count - 1)),
559    };
560    for _ in 0..count {
561        if items[at].selectable() {
562            return Some(at);
563        }
564        at = wrap(at);
565    }
566    None
567}
568
569// ---------------------------------------------------------------------------
570// Cursor
571// ---------------------------------------------------------------------------
572
573/// Where an open menu is being worked: `open` is the chain of submenu rows
574/// currently down, outermost first, `row` is the live row in the menu that
575/// chain ends at, and `segment` the live segment when that row is an
576/// [`Item::Segmented`].
577///
578/// One cursor for both input devices. A menu that tracked hover separately from
579/// the keyboard could have two rows lit and a submenu hanging off neither.
580#[derive(Clone, Debug, Default, PartialEq, Eq)]
581pub struct Cursor {
582    open: Vec<usize>,
583    row: Option<usize>,
584    segment: Option<usize>,
585}
586
587impl Cursor {
588    /// The submenu rows currently down, outermost first.
589    pub fn open(&self) -> &[usize] {
590        &self.open
591    }
592
593    /// The live row in the innermost open menu.
594    pub fn row(&self) -> Option<usize> {
595        self.row
596    }
597
598    /// The live segment, while the live row is an [`Item::Segmented`].
599    pub fn segment(&self) -> Option<usize> {
600        self.segment
601    }
602
603    /// Whether a submenu is down — where a menubar's `left` closes a level
604    /// instead of crossing to the previous menu.
605    pub fn nested(&self) -> bool {
606        !self.open.is_empty()
607    }
608
609    /// The full path to the live row, outermost first, and on to the live
610    /// segment of a segmented row.
611    pub fn path(&self) -> Option<Vec<usize>> {
612        let row = self.row?;
613        let mut path = self.open.clone();
614        path.push(row);
615        path.extend(self.segment);
616        Some(path)
617    }
618
619    /// Nothing down, nothing live — a menu opens here, and closes back to it.
620    pub fn clear(&mut self) {
621        self.open.clear();
622        self.row = None;
623        self.segment = None;
624    }
625
626    /// The row lit in the panel at `depth`: the row holding the submenu open
627    /// above the innermost panel, the live row in it, nothing below.
628    pub fn lit(&self, depth: usize) -> Option<usize> {
629        match depth.cmp(&self.open.len()) {
630            std::cmp::Ordering::Less => Some(self.open[depth]),
631            std::cmp::Ordering::Equal => self.row,
632            std::cmp::Ordering::Greater => None,
633        }
634    }
635
636    /// Move within the innermost open panel.
637    pub fn step(&mut self, root: &[Item], delta: isize) {
638        let Some(items) = items_at(root, &self.open) else {
639            return;
640        };
641        self.row = next_selectable(items, self.row, delta);
642        self.segment = self.row.and_then(|row| items[row].landing());
643    }
644
645    /// Move across the segments of the live row, stopping at either end.
646    /// `false` when the live row is not an [`Item::Segmented`].
647    pub fn slide(&mut self, root: &[Item], delta: isize) -> bool {
648        let Some(Item::Segmented { segments, .. }) = self
649            .row
650            .and_then(|row| items_at(root, &self.open)?.get(row))
651        else {
652            return false;
653        };
654        let at = self.segment.unwrap_or(0) as isize + delta.signum();
655        self.segment = Some(at.clamp(0, segments.len() as isize - 1) as usize);
656        true
657    }
658
659    /// Open the submenu under the live row and land on its first row. `false`
660    /// when the live row is not one — which is a menubar's cue that `right`
661    /// meant the next menu instead.
662    pub fn descend(&mut self, root: &[Item]) -> bool {
663        let Some(row) = self.row else { return false };
664        let Some(inner) = items_at(root, &self.open)
665            .and_then(|items| items.get(row))
666            .and_then(Item::opens)
667        else {
668            return false;
669        };
670        self.row = next_selectable(inner, None, 1);
671        self.segment = self.row.and_then(|row| inner[row].landing());
672        self.open.push(row);
673        true
674    }
675
676    /// Close the innermost submenu, landing back on the row that opened it.
677    /// `false` at the top level, where closing is the whole menu's to do.
678    pub fn ascend(&mut self) -> bool {
679        match self.open.pop() {
680            Some(row) => {
681                self.row = Some(row);
682                self.segment = None;
683                true
684            }
685            None => false,
686        }
687    }
688
689    /// Put the cursor on the row `path` names, opening the chain above it and,
690    /// if it is a submenu row, itself — pointing at a submenu row is what opens
691    /// it. Nothing is lit inside the fresh panel until something moves into it.
692    /// A path to a segment puts the cursor on that segment of its row.
693    ///
694    /// Answers whether that changed anything, because the pointer reports every
695    /// move and only a change is worth a repaint.
696    pub fn point_at(&mut self, root: &[Item], path: &[usize]) -> bool {
697        let (open, row, segment) = match path {
698            [] => (Vec::new(), None, None),
699            [above @ .., row, segment]
700                if matches!(
701                    at(root, &path[..path.len() - 1]),
702                    Some(Item::Segmented { .. })
703                ) =>
704            {
705                (above.to_vec(), Some(*row), Some(*segment))
706            }
707            _ if at(root, path).and_then(Item::opens).is_some() => (path.to_vec(), None, None),
708            [above @ .., row] => (
709                above.to_vec(),
710                Some(*row),
711                at(root, path).and_then(Item::landing),
712            ),
713        };
714        if self.open == open && self.row == row && self.segment == segment {
715            return false;
716        }
717        self.open = open;
718        self.row = row;
719        self.segment = segment;
720        true
721    }
722}
723
724// ---------------------------------------------------------------------------
725// The panels
726// ---------------------------------------------------------------------------
727
728/// What the pointer did to a menu. Every variant is a request: the caller's
729/// [`Cursor`] and open state are what answer it.
730#[derive(Clone, Debug, PartialEq, Eq)]
731pub enum Hit {
732    /// The pointer is on this row, or a submenu row was clicked. Feed it to
733    /// [`Cursor::point_at`], which is also what opens a submenu.
734    Point(Vec<usize>),
735    /// An action row was chosen.
736    Choose(Vec<usize>),
737    /// A press outside every panel of the open tree. Reported here rather than
738    /// left to an `.on_mouse_down_out` on the card, which sees only its own
739    /// bounds and would read a click in a submenu as a click away.
740    Dismiss,
741}
742
743/// A row of a text surface's right-click menu, in the order [`edit_items`]
744/// lists them.
745#[derive(Clone, Copy, Debug, PartialEq, Eq)]
746pub enum Edit {
747    Cut,
748    Copy,
749    Paste,
750    SelectAll,
751}
752
753impl Edit {
754    /// The row a [`Hit::Choose`] path names in [`edit_items`].
755    pub fn at(path: &[usize]) -> Option<Self> {
756        match path {
757            [0] => Some(Edit::Cut),
758            [1] => Some(Edit::Copy),
759            [2] => Some(Edit::Paste),
760            [4] => Some(Edit::SelectAll),
761            _ => None,
762        }
763    }
764}
765
766/// The rows of a text surface's right-click menu. `actions` are the surface's
767/// own Cut, Copy, Paste and Select All, read for the chord each row prints.
768pub fn edit_items(
769    selected: bool,
770    pasteable: bool,
771    actions: [&dyn Action; 4],
772    window: &Window,
773) -> Vec<Item> {
774    let [cut, copy, paste, select_all] = actions;
775    let enable = |item: Item, on: bool| item.disabled(!on);
776    vec![
777        enable(Item::action("Cut").with_shortcut(cut, window), selected),
778        enable(Item::action("Copy").with_shortcut(copy, window), selected),
779        enable(
780            Item::action("Paste").with_shortcut(paste, window),
781            pasteable,
782        ),
783        Item::Separator,
784        Item::action("Select All").with_shortcut(select_all, window),
785    ]
786}
787
788/// The panel a menu drops: every [`Item`] as a row, in a
789/// [`popover::popover_card`], with a further panel hanging off each submenu row
790/// the [`Cursor`] holds open. `id` prefixes the rows' element ids, so two menus
791/// open at once keep their hover state apart. In a test build each action and
792/// submenu row answers to the debug selector `{id}/{label}`.
793///
794/// The root panel is returned unanchored: mount it through one of
795/// [`popover`]'s anchored layers ([`popover::anchored_menu_below`],
796/// [`popover::menu_at`], …). Childed bare, it paints in flow, under every
797/// later sibling. Submenu panels are anchored here.
798///
799/// Panels scroll past [`MenuHeight`]'s limit. A submenu hangs off its row
800/// wherever the row has scrolled to, outside the parent's clip.
801///
802/// The pointer moves the cursor rather than lighting a row of its own, so a
803/// submenu can only ever hang off the row that is live. Everything the pointer
804/// does arrives as a [`Hit`], dismissal included.
805///
806/// `closing` is the popup's, the same instant its anchored layer is given:
807/// every open submenu panel plays its exit with the root card.
808#[allow(clippy::too_many_arguments)]
809pub fn card<V: 'static>(
810    theme: &Theme,
811    id: impl Into<SharedString>,
812    items: &[Item],
813    cursor: &Cursor,
814    closing: Option<web_time::Instant>,
815    window: &mut Window,
816    cx: &mut Context<V>,
817    on: impl Fn(&mut V, Hit, &mut Window, &mut Context<V>) + 'static,
818) -> gpui::Div {
819    let tree = Tree {
820        id: id.into(),
821        closing,
822        panels: 1 + open_depth(items, cursor.open()),
823        outside: Rc::new(Cell::new((None, 0))),
824        chain: popover::Chain::default(),
825        on: Rc::new(on),
826    };
827    tree.panel(theme, items, cursor, &[], None, window, cx)
828}
829
830/// A searchable panel's query: its field, and whether it has been focused
831/// yet. Held in the panel's element state, so it lives while the panel is
832/// down and a panel opened again starts empty.
833struct Query {
834    field: Entity<TextField>,
835    focused: bool,
836    _changed: Subscription,
837}
838
839/// A gpui mouse listener, boxed: `Context::listener` borrows the context it is
840/// made from, and these are handed back out of a method that must not.
841type Listener<E> = Box<dyn Fn(&E, &mut Window, &mut gpui::App)>;
842
843/// The caller's [`Hit`] handler, shared by every panel of one open tree.
844type Reporter<V> = Rc<dyn Fn(&mut V, Hit, &mut Window, &mut Context<V>)>;
845
846/// The parts every panel of one open tree shares.
847struct Tree<V: 'static> {
848    id: SharedString,
849    /// How many panels will paint — the divisor of the out-click count below.
850    panels: usize,
851    /// The press each panel has already reported as outside itself, stamped
852    /// with where it landed. A press outside *all* of them is the one that
853    /// dismisses, and no panel alone can tell.
854    outside: Rc<Cell<(Option<Point<Pixels>>, usize)>>,
855    /// Which way the open panels are stepping across the window.
856    chain: popover::Chain,
857    /// When the popup began to close, while it is closing.
858    closing: Option<web_time::Instant>,
859    on: Reporter<V>,
860}
861
862impl<V: 'static> Tree<V> {
863    #[allow(clippy::too_many_arguments)]
864    fn panel(
865        &self,
866        theme: &Theme,
867        items: &[Item],
868        cursor: &Cursor,
869        prefix: &[usize],
870        search: Option<&SharedString>,
871        window: &mut Window,
872        cx: &mut Context<V>,
873    ) -> gpui::Div {
874        let depth = prefix.len();
875        let lit = cursor.lit(depth);
876        let down = cursor.open().get(depth).copied();
877        let rows_id = SharedString::from(format!("{}-rows", row_id(&self.id, prefix)));
878        let Scrolled(handle, shown) = window
879            .use_keyed_state(
880                SharedString::from(format!("{rows_id}-scrolled")),
881                cx,
882                |_, _| Scrolled::default(),
883            )
884            .read(cx)
885            .clone();
886        let query = search.map(|placeholder| self.query(&rows_id, placeholder, window, cx));
887        let text = query
888            .as_ref()
889            .map(|query| query.read(cx).field.read(cx).content().trim().to_owned())
890            .unwrap_or_default();
891        // The rows on show, by index into `items`, in the order they paint. A
892        // query ranks them and drops what it does not match.
893        let visible: Vec<usize> = if text.is_empty() {
894            (0..items.len()).collect()
895        } else {
896            let words: Vec<String> = items.iter().map(Item::keywords).collect();
897            popover::filter_indices(&text, &words)
898                .into_iter()
899                .filter(|&row| items[row].selectable())
900                .collect()
901        };
902        // Only a change of live row or of the viewport's size scrolls: a wheel
903        // that carried the live row out of view is left where it put it. gpui
904        // drops a request made before the rows have been laid out, and measures
905        // one against the viewport of the frame before.
906        let size = handle.bounds().size;
907        if size.height > Pixels::ZERO && shown.get() != Some((lit, size)) {
908            shown.set(Some((lit, size)));
909            if let Some(at) = lit.and_then(|lit| visible.iter().position(|&row| row == lit)) {
910                handle.scroll_to_item(at);
911            }
912        }
913        let inset = px(popover::SNAP) + window.client_inset().unwrap_or(Pixels::ZERO);
914        let cap = height(cx).limit(window.viewport_size().height - inset * 2.0);
915        // A menu where nothing carries a glyph keeps no room for one — a bar's
916        // menus would otherwise open with an empty column down their left.
917        let gutter = items.iter().any(Item::has_icon);
918        let MenuIndent(step) = indent(cx);
919        let described = items.iter().any(Item::has_description);
920        let width = width(cx);
921        let rows = div()
922            .id(rows_id.clone())
923            .p(px(popover::MENU_PAD))
924            .children(visible.iter().map(|&row| {
925                let item = &items[row];
926                match item {
927                    Item::Separator => return popover::divider(theme).into_any_element(),
928                    Item::Heading(label) => {
929                        return popover::menu_heading(theme, label.clone()).into_any_element();
930                    }
931                    Item::Note(text) => {
932                        return popover::menu_note(theme, text.clone()).into_any_element();
933                    }
934                    _ => {}
935                }
936                let path: Vec<usize> = prefix.iter().copied().chain([row]).collect();
937                if let Item::Segmented {
938                    segments,
939                    selected,
940                    enabled,
941                } = item
942                {
943                    let live = cursor.segment().filter(|_| lit == Some(row));
944                    return self
945                        .segmented(theme, segments, *selected, *enabled, live, &path, cx)
946                        .into_any_element();
947                }
948                let id = row_id(&self.id, &path);
949                let (label, icon, indent, enabled) = match item {
950                    Item::Action {
951                        label,
952                        icon,
953                        indent,
954                        enabled,
955                        ..
956                    }
957                    | Item::Submenu {
958                        label,
959                        icon,
960                        indent,
961                        enabled,
962                        ..
963                    } => (label.clone(), icon.clone(), *indent, *enabled),
964                    Item::Segmented { .. } | Item::Heading(_) | Item::Note(_) | Item::Separator => {
965                        unreachable!("returned above")
966                    }
967                };
968                let (description, inline, hint) = match item {
969                    Item::Action {
970                        description,
971                        inline,
972                        tooltip,
973                        ..
974                    } => (description.clone(), *inline, tooltip.clone()),
975                    _ => (None, false, None),
976                };
977                let row = if enabled {
978                    popover::menu_row(theme, lit == Some(row), None)
979                        .id(id.clone())
980                        .on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
981                        .on_click(self.reports(
982                            match item {
983                                // Clicking a submenu row opens it; there is
984                                // nothing else it could mean.
985                                Item::Submenu { .. } => Hit::Point(path.clone()),
986                                _ => Hit::Choose(path.clone()),
987                            },
988                            cx,
989                        ))
990                } else {
991                    disabled_row(theme).id(id.clone())
992                };
993                let selector = format!("{}/{label}", self.id);
994                let row = row.debug_selector(move || selector);
995                let row = row.when_some(hint, |row, hint| {
996                    row.tooltip(move |window, cx| Tooltip::text(hint.clone(), window, cx))
997                });
998                row.when(indent > 0, |row| {
999                    row.pl(px(popover::MENU_ROW_INSET) + step * indent as f32)
1000                })
1001                .when(gutter, |row| row.child(glyph_slot(theme, icon, enabled)))
1002                .child(
1003                    div()
1004                        .flex_1()
1005                        .min_w_0()
1006                        .flex()
1007                        .when(!inline, |text| text.flex_col())
1008                        .when(inline, |text| text.flex_row().items_baseline().gap(px(8.0)))
1009                        .child(
1010                            div()
1011                                .truncate()
1012                                .when(inline, |label| label.flex_none().max_w_full())
1013                                .child(label),
1014                        )
1015                        .children(description.map(|description| {
1016                            description_line(theme, description, enabled)
1017                                .when(inline, |line| line.mt_0().min_w_0())
1018                        })),
1019                )
1020                .map(|row| match item {
1021                    Item::Action {
1022                        keystroke, checked, ..
1023                    } => row
1024                        .when(*checked, |row| {
1025                            row.child(
1026                                icons::icon(icons::glyph::Check)
1027                                    .size(px(GLYPH))
1028                                    .text_color(theme.text),
1029                            )
1030                        })
1031                        .when_some(keystroke.clone(), |row, keystroke| {
1032                            row.child(popover::kbd_hint(theme, &keystroke))
1033                        }),
1034                    _ => row.child(
1035                        icons::icon(icons::glyph::ChevronRight)
1036                            .size(px(GLYPH))
1037                            .text_color(theme.text_faint),
1038                    ),
1039                })
1040                .when_some(
1041                    item.opens().filter(|_| down == Some(path[depth])),
1042                    |parent, inner| {
1043                        let panel = self
1044                            .panel(theme, inner, cursor, &path, item.search(), window, cx)
1045                            .into_any_element();
1046                        parent.relative().child(popover::anchored_submenu(
1047                            SharedString::from(format!("{id}-panel")),
1048                            panel,
1049                            &self.chain,
1050                            self.closing,
1051                        ))
1052                    },
1053                )
1054                .into_any_element()
1055            }));
1056        popover::popover_card(theme)
1057            .p_0()
1058            .flex()
1059            .flex_col()
1060            .min_h_0()
1061            .max_h(cap)
1062            .map(|card| match (described, width.described) {
1063                (true, Described::Fixed(wide)) => card.w(wide),
1064                (true, Described::Fit(most)) => card.min_w(width.min).max_w(most),
1065                (false, _) => card.min_w(width.min),
1066            })
1067            .when(self.closing.is_none(), |card| {
1068                card.on_mouse_down_out(self.dismissal(cx))
1069            })
1070            .map(|card| match query {
1071                None => card.child(
1072                    scroll::Viewport::new(rows_id, rows, Axis::Vertical)
1073                        .track_scroll(&handle)
1074                        .fill(),
1075                ),
1076                Some(query) => {
1077                    let field = query.read(cx).field.clone();
1078                    let rows = rows.max_h(px(
1079                        SEARCH_ROWS * popover::menu_row_height() + 2.0 * popover::MENU_PAD
1080                    ));
1081                    card.child(
1082                        div()
1083                            .key_context(SEARCH_CONTEXT)
1084                            .px(px(popover::MENU_PAD))
1085                            .pt(px(popover::MENU_PAD))
1086                            .map(|el| self.keys(el, items, &visible, lit, prefix, cx))
1087                            .child(popover::search_line(theme, field.into_any_element())),
1088                    )
1089                    .child(match visible.is_empty() {
1090                        true => div()
1091                            .px(px(popover::MENU_PAD + popover::MENU_ROW_INSET))
1092                            .pb(px(8.0))
1093                            .text_style(TextStyle::Body)
1094                            .text_color(theme.text_muted)
1095                            .child("No matches")
1096                            .into_any_element(),
1097                        false => scroll::Viewport::new(rows_id, rows, Axis::Vertical)
1098                            .track_scroll(&handle)
1099                            .into_any_element(),
1100                    })
1101                }
1102            })
1103    }
1104
1105    /// The query of the searchable panel at `key`, focused the first time it
1106    /// paints. Each edit repaints the view the menu is drawn in.
1107    fn query(
1108        &self,
1109        key: &SharedString,
1110        placeholder: &SharedString,
1111        window: &mut Window,
1112        cx: &mut Context<V>,
1113    ) -> Entity<Query> {
1114        let host = cx.entity_id();
1115        let placeholder = placeholder.clone();
1116        let query = window.use_keyed_state(
1117            SharedString::from(format!("{key}-query")),
1118            cx,
1119            move |_, cx| {
1120                let field = cx.new(|cx| crate::search::query_field(placeholder, cx));
1121                let _changed = cx.subscribe(&field, move |_, _, _: &FieldEvent, cx| {
1122                    let app: &mut App = cx;
1123                    app.notify(host);
1124                });
1125                Query {
1126                    field,
1127                    focused: false,
1128                    _changed,
1129                }
1130            },
1131        );
1132        if !query.read(cx).focused {
1133            query.update(cx, |query, _| query.focused = true);
1134            let handle = query.read(cx).field.focus_handle(cx);
1135            window.focus(&handle, cx);
1136        }
1137        query
1138    }
1139
1140    /// The arrows, enter and escape on a searchable panel's query: they walk
1141    /// and choose among the rows on show, and report it as the pointer would.
1142    fn keys(
1143        &self,
1144        el: gpui::Div,
1145        items: &[Item],
1146        shown: &[usize],
1147        lit: Option<usize>,
1148        prefix: &[usize],
1149        cx: &mut Context<V>,
1150    ) -> gpui::Div {
1151        let landable: Rc<Vec<usize>> = Rc::new(
1152            shown
1153                .iter()
1154                .copied()
1155                .filter(|&row| items[row].selectable())
1156                .collect(),
1157        );
1158        let at = lit.and_then(|lit| landable.iter().position(|&row| row == lit));
1159        let path = {
1160            let prefix = prefix.to_vec();
1161            move |row: usize| prefix.iter().copied().chain([row]).collect::<Vec<_>>()
1162        };
1163        let step = |delta: isize| {
1164            let count = landable.len() as isize;
1165            (count > 0).then(|| {
1166                let next = match at {
1167                    Some(at) => (at as isize + delta).rem_euclid(count),
1168                    None if delta > 0 => 0,
1169                    None => count - 1,
1170                };
1171                landable[next as usize]
1172            })
1173        };
1174        let next = step(1).map(&path);
1175        let previous = step(-1).map(&path);
1176        let chosen = at
1177            .map(|at| landable[at])
1178            .or_else(|| landable.first().copied())
1179            .map(|row| match items[row].opens() {
1180                Some(_) => Hit::Point(path(row)),
1181                None => Hit::Choose(path(row)),
1182            });
1183        let on = self.on.clone();
1184        let report = move |hit: Option<Hit>| {
1185            let on = on.clone();
1186            move |view: &mut V, window: &mut Window, cx: &mut Context<V>| {
1187                if let Some(hit) = hit.clone() {
1188                    on(view, hit, window, cx);
1189                }
1190            }
1191        };
1192        let (down, up, enter, escape) = (
1193            report(next.map(Hit::Point)),
1194            report(previous.map(Hit::Point)),
1195            report(chosen),
1196            report(Some(Hit::Dismiss)),
1197        );
1198        el.on_action(cx.listener(move |view, _: &SelectNext, window, cx| down(view, window, cx)))
1199            .on_action(
1200                cx.listener(move |view, _: &SelectPrevious, window, cx| up(view, window, cx)),
1201            )
1202            .on_action(cx.listener(move |view, _: &Confirm, window, cx| enter(view, window, cx)))
1203            .on_action(cx.listener(move |view, _: &Dismiss, window, cx| escape(view, window, cx)))
1204    }
1205
1206    /// An [`Item::Segmented`] row: its segments share the row's width, the
1207    /// selected one washed, the live one lit the way a live row is.
1208    #[allow(clippy::too_many_arguments)]
1209    fn segmented(
1210        &self,
1211        theme: &Theme,
1212        segments: &[Segment],
1213        selected: usize,
1214        enabled: bool,
1215        live: Option<usize>,
1216        path: &[usize],
1217        cx: &mut Context<V>,
1218    ) -> gpui::Stateful<gpui::Div> {
1219        div()
1220            .id(row_id(&self.id, path))
1221            .flex()
1222            .flex_row()
1223            .gap(px(popover::MENU_PAD))
1224            .children(segments.iter().enumerate().map(|(at, segment)| {
1225                let path: Vec<usize> = path.iter().copied().chain([at]).collect();
1226                let chosen = at == selected;
1227                let ink = match (enabled, chosen || live == Some(at)) {
1228                    (false, _) => theme.text_faint.opacity(0.5),
1229                    (true, true) => theme.text,
1230                    (true, false) => theme.text_muted,
1231                };
1232                let cell = div()
1233                    .id(row_id(&self.id, &path))
1234                    .flex_1()
1235                    .min_w_0()
1236                    .flex()
1237                    .flex_col()
1238                    .items_center()
1239                    .gap(px(4.0))
1240                    .px(px(8.0))
1241                    .py(px(6.0))
1242                    .rounded(px(Theme::inset_radius(
1243                        Theme::surface_radius(),
1244                        popover::MENU_PAD,
1245                    )))
1246                    .text_style(TextStyle::Callout)
1247                    .text_color(ink)
1248                    .when(chosen, |cell| {
1249                        cell.bg(theme.element_active)
1250                            .font_weight(gpui::FontWeight::MEDIUM)
1251                    })
1252                    .when(!chosen && live == Some(at), |cell| {
1253                        cell.bg(theme.card_selected_bg())
1254                    })
1255                    .child(
1256                        icons::icon(segment.icon.clone())
1257                            .size(px(SEGMENT_GLYPH))
1258                            .text_color(ink),
1259                    )
1260                    .child(div().max_w_full().truncate().child(segment.label.clone()));
1261                match enabled {
1262                    true => cell
1263                        .cursor_pointer()
1264                        .on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
1265                        .on_click(self.reports(Hit::Choose(path), cx)),
1266                    false => cell,
1267                }
1268            }))
1269    }
1270
1271    /// One panel's share of the out-click test: it reports the press it did not
1272    /// contain, and whichever panel completes the tally is the one that calls
1273    /// it a dismissal. Order between them does not matter, only the count.
1274    ///
1275    /// The dismissing press is swallowed. It runs in the capture phase, where
1276    /// stopping propagation skips the bubble phase entirely — which is where
1277    /// gpui records the press a click is later built from, so the row under the
1278    /// pointer never fires. A press that lands on something while a menu is
1279    /// open is a press asking for the menu to go away, and acting on what it
1280    /// landed on runs an operation nobody aimed at (user report, DEV-11). The
1281    /// thing under it is still one more press away.
1282    fn dismissal(&self, cx: &mut Context<V>) -> Listener<MouseDownEvent> {
1283        let outside = self.outside.clone();
1284        let panels = self.panels;
1285        let on = self.on.clone();
1286        Box::new(
1287            cx.listener(move |view, event: &MouseDownEvent, window, cx| {
1288                let (at, count) = outside.get();
1289                let count = if at == Some(event.position) {
1290                    count + 1
1291                } else {
1292                    1
1293                };
1294                outside.set((Some(event.position), count));
1295                if count >= panels {
1296                    outside.set((None, 0));
1297                    on(view, Hit::Dismiss, window, cx);
1298                    cx.stop_propagation();
1299                }
1300            }),
1301        )
1302    }
1303
1304    /// A listener that hands `hit` back to the caller, whatever the event was.
1305    fn reports<E: 'static>(&self, hit: Hit, cx: &mut Context<V>) -> Listener<E> {
1306        let on = self.on.clone();
1307        Box::new(cx.listener(move |view, _: &E, window, cx| on(view, hit.clone(), window, cx)))
1308    }
1309}
1310
1311/// One panel's scroll position, and the live row and viewport size it last
1312/// scrolled for.
1313#[derive(Clone, Default)]
1314struct Scrolled(ScrollHandle, Rc<Cell<Option<Shown>>>);
1315
1316type Shown = (Option<usize>, Size<Pixels>);
1317
1318/// A row's element id: the card's, then the path, so rows of two panels — or of
1319/// two menus open at once — never collide.
1320fn row_id(id: &SharedString, path: &[usize]) -> SharedString {
1321    let mut out = id.to_string();
1322    for row in path {
1323        out.push('-');
1324        out.push_str(&row.to_string());
1325    }
1326    SharedString::from(out)
1327}
1328
1329/// The leading column: the row's glyph, or the room one would have taken, so a
1330/// menu of mixed rows keeps its labels on one edge.
1331pub fn glyph_slot(theme: &Theme, icon: Option<Icon>, enabled: bool) -> gpui::Div {
1332    div()
1333        .flex_none()
1334        .size(px(GLYPH))
1335        .flex()
1336        .items_center()
1337        .justify_center()
1338        .children(icon.map(|glyph| {
1339            icons::icon(glyph).size(px(GLYPH)).text_color(if enabled {
1340                theme.text_faint
1341            } else {
1342                theme.text_faint.opacity(0.5)
1343            })
1344        }))
1345}
1346
1347/// The second line under a row's label: the same relationship a card row's
1348/// meta line has to its title ([`crate::widgets::Scaffolding::meta_line`]),
1349/// which is where the size and the tone come from.
1350///
1351/// One line, clipped — rows of a menu are a column of equal things, and a
1352/// sentence that wrapped would make its row two or three times its neighbours'
1353/// height. What a caller cannot do from outside is guess where to cut: it
1354/// would be counting characters against a proportional font at a width only
1355/// the panel knows. [`Item::with_tooltip`] is where the rest of it goes.
1356///
1357/// It sets its own colour rather than inheriting the row's, because the row's
1358/// is the *label's* — a lit row paints that at full contrast, and a
1359/// description that followed it there would stop reading as the quieter half.
1360fn description_line(theme: &Theme, description: SharedString, enabled: bool) -> gpui::Div {
1361    div()
1362        .mt(px(2.0))
1363        .truncate()
1364        .text_style(TextStyle::Subheadline)
1365        .text_color(if enabled {
1366            theme.text_muted
1367        } else {
1368            // The dimming `glyph_slot` gives a disabled glyph, so the whole
1369            // row fades by one rule rather than two.
1370            theme.text_faint.opacity(0.5)
1371        })
1372        .child(description)
1373}
1374
1375/// A row that cannot be chosen: [`popover::menu_row`]'s metrics without its
1376/// hover fade or its click, because a disabled row that lit under the pointer
1377/// would be inviting a press that does nothing.
1378fn disabled_row(theme: &Theme) -> gpui::Div {
1379    div()
1380        .flex()
1381        .flex_row()
1382        .items_center()
1383        .gap(px(10.0))
1384        .px(px(8.0))
1385        .py(px(popover::MENU_ROW_PAD_Y))
1386        // The disabled twin of `popover::menu_row`, in the same card — so it
1387        // takes its corners from the same rule rather than a matching literal.
1388        .rounded(px(Theme::inset_radius(
1389            Theme::surface_radius(),
1390            popover::MENU_PAD,
1391        )))
1392        .text_style(TextStyle::Body)
1393        .text_color(theme.text_faint)
1394}