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