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