Skip to main content

ui/
menu.rs

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