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