Skip to main content

ui/
menu.rs

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