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