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::{icons, keys, popover, scroll, tooltip::Tooltip};
15use gpui::{
16    Action, Axis, Context, MouseDownEvent, Pixels, Point, ScrollHandle, SharedString, Size, Window,
17    div, prelude::*, px,
18};
19use icons::Icon;
20use std::{cell::Cell, rc::Rc};
21use theme::{TextStyle, Theme, Typeset};
22
23/// The leading glyph and the trailing check, at the size the rows are set in.
24const GLYPH: f32 = 13.0;
25
26/// How wide a panel sits.
27const PANEL_MIN: f32 = 180.0;
28/// How wide one holding a described row sits — a width, not a floor. A
29/// description is a sentence rather than a name, so the panel widens the way
30/// one icon opens the glyph gutter; and because the sentence is kept to one
31/// line, the panel needs a ceiling to clip it against rather than growing to
32/// whatever the longest one measures.
33const PANEL_DESCRIBED: f32 = 280.0;
34
35/// A row in a menu.
36///
37/// Deliberately not a struct with an `is_separator` flag: a separator has no
38/// label, no accelerator and nothing to enable, and every one of those fields
39/// would have to be answered anyway.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum Item {
42    Action {
43        label: SharedString,
44        /// A second line under the label, for a row whose name does not say
45        /// enough on its own — what a command will do, which file it will act
46        /// on. `None` keeps the row one line tall; nothing reserves space for
47        /// a description the way the glyph gutter reserves space for an icon,
48        /// because a row's two lines read as one block and a blank second line
49        /// would read as a gap.
50        description: Option<SharedString>,
51        /// Hover text for the row, shown after gpui's tooltip delay. Where the
52        /// whole of a [`Item::with_description`] too long for its one line
53        /// goes, and where a disabled row says why — a disabled row takes no
54        /// click, but it still takes a tooltip.
55        tooltip: Option<SharedString>,
56        /// The leading glyph. A menu where no row has one keeps no room
57        /// for it.
58        icon: Option<Icon>,
59        /// The accelerator to *print* — the binding itself is the app's, and
60        /// bezel never dispatches it. A menu that showed a keystroke it did not
61        /// own would be documenting a lie, which is what
62        /// [`Item::with_shortcut`] fills this from the keymap to avoid.
63        keystroke: Option<SharedString>,
64        /// The choice the menu is currently on, marked with a trailing check.
65        checked: bool,
66        enabled: bool,
67    },
68    /// A row that drops a menu of its own, the way a SwiftUI `Menu` nests
69    /// inside a `Menu`. It carries no accelerator and nothing to check: the
70    /// only thing choosing it does is open.
71    Submenu {
72        label: SharedString,
73        icon: Option<Icon>,
74        enabled: bool,
75        items: Vec<Item>,
76    },
77    Separator,
78}
79
80impl Item {
81    pub fn action(label: impl Into<SharedString>) -> Self {
82        Item::Action {
83            label: label.into(),
84            description: None,
85            tooltip: None,
86            icon: None,
87            keystroke: None,
88            checked: false,
89            enabled: true,
90        }
91    }
92
93    pub fn submenu(label: impl Into<SharedString>, items: Vec<Item>) -> Self {
94        Item::Submenu {
95            label: label.into(),
96            icon: None,
97            enabled: true,
98            items,
99        }
100    }
101
102    /// No-ops on a separator, which has nothing to hang a glyph on.
103    pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
104        match &mut self {
105            Item::Action { icon: slot, .. } | Item::Submenu { icon: slot, .. } => {
106                *slot = Some(icon.into())
107            }
108            Item::Separator => {}
109        }
110        self
111    }
112
113    /// A second line under the label. No-ops on anything but an action row: a
114    /// submenu's second line is the panel it opens, and a separator has no
115    /// first line to put one under.
116    pub fn with_description(mut self, description: impl Into<SharedString>) -> Self {
117        if let Item::Action {
118            description: slot, ..
119        } = &mut self
120        {
121            *slot = Some(description.into());
122        }
123        self
124    }
125
126    /// The description *and* the whole of it on hover, from one string. A
127    /// sentence long enough to need clipping is one no caller should have to
128    /// write twice — two copies of it drift.
129    pub fn with_long_description(self, description: impl Into<SharedString>) -> Self {
130        let description = description.into();
131        self.with_description(description.clone())
132            .with_tooltip(description)
133    }
134
135    /// Hover text for the row — the rest of a description the row had to clip,
136    /// or why a disabled row is disabled. No-ops on anything but an action
137    /// row: a submenu row is already hovered to open it, and a label the
138    /// pointer waits on top of would fight the panel it drops.
139    pub fn with_tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
140        if let Item::Action { tooltip: slot, .. } = &mut self {
141            *slot = Some(tooltip.into());
142        }
143        self
144    }
145
146    /// No-ops on anything but an action row: a submenu's keystroke is the
147    /// arrow that opens it, and a separator has nothing to hang one on.
148    pub fn with_keystroke(mut self, keystroke: impl Into<SharedString>) -> Self {
149        if let Item::Action {
150            keystroke: slot, ..
151        } = &mut self
152        {
153            *slot = Some(keystroke.into());
154        }
155        self
156    }
157
158    /// The accelerator read off the keymap instead of typed in — the same slot
159    /// [`Item::with_keystroke`] fills, filled with what would actually fire.
160    /// An action with nothing bound to it leaves the row bare, because a menu
161    /// that prints a chord it no longer owns is documenting a lie.
162    ///
163    /// Still nothing to dispatch: the row's click stays the caller's, and the
164    /// action passed here is read, never run.
165    pub fn with_shortcut(self, action: &dyn Action, window: &Window) -> Self {
166        match keys::shortcut(action, window) {
167            Some(keystroke) => self.with_keystroke(keystroke),
168            None => self,
169        }
170    }
171
172    /// [`Item::with_shortcut`] for a chord bound to a surface that is not
173    /// focused — which is most menu rows, since opening the menu took focus
174    /// off whatever the row acts on. `context` is the one the binding was
175    /// scoped to (`editor::CONTEXT`, [`crate::input::KEY_CONTEXT`]).
176    pub fn with_shortcut_in(self, action: &dyn Action, context: &str, window: &Window) -> Self {
177        match keys::shortcut_in(action, context, window) {
178            Some(keystroke) => self.with_keystroke(keystroke),
179            None => self,
180        }
181    }
182
183    /// Takes the flag, because what a menu is on is decided per render. No-ops
184    /// on a submenu, which is not itself a choice.
185    pub fn checked(mut self, checked: bool) -> Self {
186        if let Item::Action { checked: slot, .. } = &mut self {
187            *slot = checked;
188        }
189        self
190    }
191
192    pub fn disabled(mut self) -> Self {
193        match &mut self {
194            Item::Action { enabled, .. } | Item::Submenu { enabled, .. } => *enabled = false,
195            Item::Separator => {}
196        }
197        self
198    }
199
200    /// Whether the keyboard and the pointer can land here at all. A submenu
201    /// with nothing to choose in it counts as unlandable: opening it would drop
202    /// a panel that is a dead end.
203    pub fn selectable(&self) -> bool {
204        match self {
205            Item::Action { enabled, .. } => *enabled,
206            Item::Submenu { enabled, items, .. } => *enabled && items.iter().any(Item::selectable),
207            Item::Separator => false,
208        }
209    }
210
211    fn has_description(&self) -> bool {
212        matches!(
213            self,
214            Item::Action {
215                description: Some(_),
216                ..
217            }
218        )
219    }
220
221    fn has_icon(&self) -> bool {
222        matches!(
223            self,
224            Item::Action { icon: Some(_), .. } | Item::Submenu { icon: Some(_), .. }
225        )
226    }
227
228    /// The submenu this row opens, if it is one that can be opened.
229    fn opens(&self) -> Option<&[Item]> {
230        match self {
231            Item::Submenu {
232                enabled: true,
233                items,
234                ..
235            } => Some(items),
236            _ => None,
237        }
238    }
239}
240
241/// The item `path` names — one row index per level, outermost first.
242pub fn at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a Item> {
243    let (&row, above) = path.split_last()?;
244    items_at(items, above)?.get(row)
245}
246
247/// The menu `path` opens into: `items` itself for an empty path, else the rows
248/// of the submenu each index names. `None` as soon as one of them does not name
249/// an open-able submenu.
250pub fn items_at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a [Item]> {
251    let mut level = items;
252    for &row in path {
253        level = level.get(row)?.opens()?;
254    }
255    Some(level)
256}
257
258/// How many levels of `open` still name an open-able submenu — where both the
259/// painting and the out-click count stop when the chain has gone stale.
260fn open_depth(items: &[Item], open: &[usize]) -> usize {
261    let mut level = items;
262    for (depth, &row) in open.iter().enumerate() {
263        match level.get(row).and_then(Item::opens) {
264            Some(inner) => level = inner,
265            None => return depth,
266        }
267    }
268    open.len()
269}
270
271/// The next row the keyboard can land on, `delta` deciding the direction:
272/// separators and disabled rows are stepped straight over, and both ends wrap.
273/// `from` of `None` enters the menu at the edge the direction comes from.
274///
275/// [`popover::menu_step`] cannot do this — it counts rows and knows nothing
276/// about which of them can be landed on. `None` back means *nothing* in the menu
277/// is selectable, which is the one shape that would otherwise spin forever.
278pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
279    let count = items.len();
280    if count == 0 {
281        return None;
282    }
283    let step = if delta >= 0 { 1 } else { -1 };
284    let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
285    // Entering, the first candidate is the edge itself; moving, it is the row
286    // after the one you are on.
287    let mut at = match from {
288        None if step > 0 => 0,
289        None => count - 1,
290        Some(at) => wrap(at.min(count - 1)),
291    };
292    for _ in 0..count {
293        if items[at].selectable() {
294            return Some(at);
295        }
296        at = wrap(at);
297    }
298    None
299}
300
301// ---------------------------------------------------------------------------
302// Cursor
303// ---------------------------------------------------------------------------
304
305/// Where an open menu is being worked: `open` is the chain of submenu rows
306/// currently down, outermost first, and `row` is the live row in the menu that
307/// chain ends at.
308///
309/// One cursor for both input devices. A menu that tracked hover separately from
310/// the keyboard could have two rows lit and a submenu hanging off neither.
311#[derive(Clone, Debug, Default, PartialEq, Eq)]
312pub struct Cursor {
313    open: Vec<usize>,
314    row: Option<usize>,
315}
316
317impl Cursor {
318    /// The submenu rows currently down, outermost first.
319    pub fn open(&self) -> &[usize] {
320        &self.open
321    }
322
323    /// The live row in the innermost open menu.
324    pub fn row(&self) -> Option<usize> {
325        self.row
326    }
327
328    /// Whether a submenu is down — where a menubar's `left` closes a level
329    /// instead of crossing to the previous menu.
330    pub fn nested(&self) -> bool {
331        !self.open.is_empty()
332    }
333
334    /// The full path to the live row, outermost first.
335    pub fn path(&self) -> Option<Vec<usize>> {
336        let row = self.row?;
337        let mut path = self.open.clone();
338        path.push(row);
339        Some(path)
340    }
341
342    /// Nothing down, nothing live — a menu opens here, and closes back to it.
343    pub fn clear(&mut self) {
344        self.open.clear();
345        self.row = None;
346    }
347
348    /// The row lit in the panel at `depth`: the row holding the submenu open
349    /// above the innermost panel, the live row in it, nothing below.
350    pub fn lit(&self, depth: usize) -> Option<usize> {
351        match depth.cmp(&self.open.len()) {
352            std::cmp::Ordering::Less => Some(self.open[depth]),
353            std::cmp::Ordering::Equal => self.row,
354            std::cmp::Ordering::Greater => None,
355        }
356    }
357
358    /// Move within the innermost open panel.
359    pub fn step(&mut self, root: &[Item], delta: isize) {
360        let Some(items) = items_at(root, &self.open) else {
361            return;
362        };
363        self.row = next_selectable(items, self.row, delta);
364    }
365
366    /// Open the submenu under the live row and land on its first row. `false`
367    /// when the live row is not one — which is a menubar's cue that `right`
368    /// meant the next menu instead.
369    pub fn descend(&mut self, root: &[Item]) -> bool {
370        let Some(row) = self.row else { return false };
371        let Some(inner) = items_at(root, &self.open)
372            .and_then(|items| items.get(row))
373            .and_then(Item::opens)
374        else {
375            return false;
376        };
377        self.row = next_selectable(inner, None, 1);
378        self.open.push(row);
379        true
380    }
381
382    /// Close the innermost submenu, landing back on the row that opened it.
383    /// `false` at the top level, where closing is the whole menu's to do.
384    pub fn ascend(&mut self) -> bool {
385        match self.open.pop() {
386            Some(row) => {
387                self.row = Some(row);
388                true
389            }
390            None => false,
391        }
392    }
393
394    /// Put the cursor on the row `path` names, opening the chain above it and,
395    /// if it is a submenu row, itself — pointing at a submenu row is what opens
396    /// it. Nothing is lit inside the fresh panel until something moves into it.
397    ///
398    /// Answers whether that changed anything, because the pointer reports every
399    /// move and only a change is worth a repaint.
400    pub fn point_at(&mut self, root: &[Item], path: &[usize]) -> bool {
401        let (open, row) = match path.split_last() {
402            None => (Vec::new(), None),
403            Some((&row, above)) if at(root, path).and_then(Item::opens).is_some() => {
404                (above.iter().copied().chain([row]).collect(), None)
405            }
406            Some((&row, above)) => (above.to_vec(), Some(row)),
407        };
408        if self.open == open && self.row == row {
409            return false;
410        }
411        self.open = open;
412        self.row = row;
413        true
414    }
415}
416
417// ---------------------------------------------------------------------------
418// The panels
419// ---------------------------------------------------------------------------
420
421/// What the pointer did to a menu. Every variant is a request: the caller's
422/// [`Cursor`] and open state are what answer it.
423#[derive(Clone, Debug, PartialEq, Eq)]
424pub enum Hit {
425    /// The pointer is on this row, or a submenu row was clicked. Feed it to
426    /// [`Cursor::point_at`], which is also what opens a submenu.
427    Point(Vec<usize>),
428    /// An action row was chosen.
429    Choose(Vec<usize>),
430    /// A press outside every panel of the open tree. Reported here rather than
431    /// left to an `.on_mouse_down_out` on the card, which sees only its own
432    /// bounds and would read a click in a submenu as a click away.
433    Dismiss,
434}
435
436/// The panel a menu drops: every [`Item`] as a row, in a
437/// [`popover::popover_card`], with a further panel hanging off each submenu row
438/// the [`Cursor`] holds open. `id` prefixes the rows' element ids, so two menus
439/// open at once keep their hover state apart.
440///
441/// Every panel is capped at the window's height less the snap margin either
442/// side, and scrolls its rows past that. A submenu hangs off its row wherever
443/// the row has scrolled to, outside the parent's clip.
444///
445/// The pointer moves the cursor rather than lighting a row of its own, so a
446/// submenu can only ever hang off the row that is live. Everything the pointer
447/// does arrives as a [`Hit`], dismissal included.
448pub fn card<V: 'static>(
449    theme: &Theme,
450    id: impl Into<SharedString>,
451    items: &[Item],
452    cursor: &Cursor,
453    window: &mut Window,
454    cx: &mut Context<V>,
455    on: impl Fn(&mut V, Hit, &mut Window, &mut Context<V>) + 'static,
456) -> gpui::Div {
457    let tree = Tree {
458        id: id.into(),
459        panels: 1 + open_depth(items, cursor.open()),
460        outside: Rc::new(Cell::new((None, 0))),
461        chain: popover::Chain::default(),
462        on: Rc::new(on),
463    };
464    tree.panel(theme, items, cursor, &[], window, cx)
465}
466
467/// A gpui mouse listener, boxed: `Context::listener` borrows the context it is
468/// made from, and these are handed back out of a method that must not.
469type Listener<E> = Box<dyn Fn(&E, &mut Window, &mut gpui::App)>;
470
471/// The caller's [`Hit`] handler, shared by every panel of one open tree.
472type Reporter<V> = Rc<dyn Fn(&mut V, Hit, &mut Window, &mut Context<V>)>;
473
474/// The parts every panel of one open tree shares.
475struct Tree<V: 'static> {
476    id: SharedString,
477    /// How many panels will paint — the divisor of the out-click count below.
478    panels: usize,
479    /// The press each panel has already reported as outside itself, stamped
480    /// with where it landed. A press outside *all* of them is the one that
481    /// dismisses, and no panel alone can tell.
482    outside: Rc<Cell<(Option<Point<Pixels>>, usize)>>,
483    /// Which way the open panels are stepping across the window.
484    chain: popover::Chain,
485    on: Reporter<V>,
486}
487
488impl<V: 'static> Tree<V> {
489    fn panel(
490        &self,
491        theme: &Theme,
492        items: &[Item],
493        cursor: &Cursor,
494        prefix: &[usize],
495        window: &mut Window,
496        cx: &mut Context<V>,
497    ) -> gpui::Div {
498        let depth = prefix.len();
499        let lit = cursor.lit(depth);
500        let down = cursor.open().get(depth).copied();
501        let rows_id = SharedString::from(format!("{}-rows", row_id(&self.id, prefix)));
502        let Scrolled(handle, shown) = window
503            .use_keyed_state(
504                SharedString::from(format!("{rows_id}-scrolled")),
505                cx,
506                |_, _| Scrolled::default(),
507            )
508            .read(cx)
509            .clone();
510        // Only a change of live row or of the viewport's size scrolls: a wheel
511        // that carried the live row out of view is left where it put it. gpui
512        // drops a request made before the rows have been laid out, and measures
513        // one against the viewport of the frame before.
514        let size = handle.bounds().size;
515        if size.height > Pixels::ZERO && shown.get() != Some((lit, size)) {
516            shown.set(Some((lit, size)));
517            if let Some(lit) = lit {
518                handle.scroll_to_item(lit);
519            }
520        }
521        let inset = px(popover::SNAP) + window.client_inset().unwrap_or(Pixels::ZERO);
522        let cap = window.viewport_size().height - inset * 2.0;
523        // A menu where nothing carries a glyph keeps no room for one — a bar's
524        // menus would otherwise open with an empty column down their left.
525        let gutter = items.iter().any(Item::has_icon);
526        let described = items.iter().any(Item::has_description);
527        let rows =
528            div().id(rows_id.clone()).p(px(popover::MENU_PAD)).children(
529                items.iter().enumerate().map(|(row, item)| {
530                    if matches!(item, Item::Separator) {
531                        return popover::divider().into_any_element();
532                    }
533                    let path: Vec<usize> = prefix.iter().copied().chain([row]).collect();
534                    let id = row_id(&self.id, &path);
535                    let (label, icon, enabled) = match item {
536                        Item::Action {
537                            label,
538                            icon,
539                            enabled,
540                            ..
541                        }
542                        | Item::Submenu {
543                            label,
544                            icon,
545                            enabled,
546                            ..
547                        } => (label.clone(), icon.clone(), *enabled),
548                        Item::Separator => unreachable!("separators returned above"),
549                    };
550                    let (description, hint) = match item {
551                        Item::Action {
552                            description,
553                            tooltip,
554                            ..
555                        } => (description.clone(), tooltip.clone()),
556                        _ => (None, None),
557                    };
558                    let row = if enabled {
559                        popover::menu_row(theme, lit == Some(row), None)
560                            .id(id.clone())
561                            .on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
562                            .on_click(self.reports(
563                                match item {
564                                    // Clicking a submenu row opens it; there is
565                                    // nothing else it could mean.
566                                    Item::Submenu { .. } => Hit::Point(path.clone()),
567                                    _ => Hit::Choose(path.clone()),
568                                },
569                                cx,
570                            ))
571                    } else {
572                        disabled_row(theme).id(id.clone())
573                    };
574                    let row = row.when_some(hint, |row, hint| {
575                        row.tooltip(move |window, cx| Tooltip::text(hint.clone(), window, cx))
576                    });
577                    row.when(gutter, |row| row.child(glyph_slot(theme, icon, enabled)))
578                        .child(
579                            div()
580                                .flex_1()
581                                .min_w_0()
582                                .flex()
583                                .flex_col()
584                                .child(label)
585                                .children(description.map(|description| {
586                                    description_line(theme, description, enabled)
587                                })),
588                        )
589                        .map(|row| match item {
590                            Item::Action {
591                                keystroke, checked, ..
592                            } => row
593                                .when(*checked, |row| {
594                                    row.child(
595                                        icons::icon(icons::glyph::Check)
596                                            .size(px(GLYPH))
597                                            .text_color(theme.text),
598                                    )
599                                })
600                                .when_some(keystroke.clone(), |row, keystroke| {
601                                    row.child(popover::kbd_hint(theme, &keystroke))
602                                }),
603                            _ => row.child(
604                                icons::icon(icons::glyph::ChevronRight)
605                                    .size(px(GLYPH))
606                                    .text_color(theme.text_faint),
607                            ),
608                        })
609                        .when_some(
610                            item.opens().filter(|_| down == Some(path[depth])),
611                            |parent, inner| {
612                                let panel = self
613                                    .panel(theme, inner, cursor, &path, window, cx)
614                                    .into_any_element();
615                                parent.relative().child(popover::anchored_submenu(
616                                    SharedString::from(format!("{id}-panel")),
617                                    panel,
618                                    &self.chain,
619                                ))
620                            },
621                        )
622                        .into_any_element()
623                }),
624            );
625        popover::popover_card(theme)
626            .p_0()
627            .flex()
628            .flex_col()
629            .max_h(cap)
630            .map(|card| match described {
631                true => card.w(px(PANEL_DESCRIBED)),
632                false => card.min_w(px(PANEL_MIN)),
633            })
634            .on_mouse_down_out(self.dismissal(cx))
635            .child(
636                scroll::Viewport::new(rows_id, rows, Axis::Vertical)
637                    .track_scroll(&handle)
638                    .fill(),
639            )
640    }
641
642    /// One panel's share of the out-click test: it reports the press it did not
643    /// contain, and whichever panel completes the tally is the one that calls
644    /// it a dismissal. Order between them does not matter, only the count.
645    ///
646    /// The dismissing press is swallowed. It runs in the capture phase, where
647    /// stopping propagation skips the bubble phase entirely — which is where
648    /// gpui records the press a click is later built from, so the row under the
649    /// pointer never fires. A press that lands on something while a menu is
650    /// open is a press asking for the menu to go away, and acting on what it
651    /// landed on runs an operation nobody aimed at (user report, DEV-11). The
652    /// thing under it is still one more press away.
653    fn dismissal(&self, cx: &mut Context<V>) -> Listener<MouseDownEvent> {
654        let outside = self.outside.clone();
655        let panels = self.panels;
656        let on = self.on.clone();
657        Box::new(
658            cx.listener(move |view, event: &MouseDownEvent, window, cx| {
659                let (at, count) = outside.get();
660                let count = if at == Some(event.position) {
661                    count + 1
662                } else {
663                    1
664                };
665                outside.set((Some(event.position), count));
666                if count >= panels {
667                    outside.set((None, 0));
668                    on(view, Hit::Dismiss, window, cx);
669                    cx.stop_propagation();
670                }
671            }),
672        )
673    }
674
675    /// A listener that hands `hit` back to the caller, whatever the event was.
676    fn reports<E: 'static>(&self, hit: Hit, cx: &mut Context<V>) -> Listener<E> {
677        let on = self.on.clone();
678        Box::new(cx.listener(move |view, _: &E, window, cx| on(view, hit.clone(), window, cx)))
679    }
680}
681
682/// One panel's scroll position, and the live row and viewport size it last
683/// scrolled for.
684#[derive(Clone, Default)]
685struct Scrolled(ScrollHandle, Rc<Cell<Option<Shown>>>);
686
687type Shown = (Option<usize>, Size<Pixels>);
688
689/// A row's element id: the card's, then the path, so rows of two panels — or of
690/// two menus open at once — never collide.
691fn row_id(id: &SharedString, path: &[usize]) -> SharedString {
692    let mut out = id.to_string();
693    for row in path {
694        out.push('-');
695        out.push_str(&row.to_string());
696    }
697    SharedString::from(out)
698}
699
700/// The leading column: the row's glyph, or the room one would have taken, so a
701/// menu of mixed rows keeps its labels on one edge.
702fn glyph_slot(theme: &Theme, icon: Option<Icon>, enabled: bool) -> gpui::Div {
703    div()
704        .flex_none()
705        .size(px(GLYPH))
706        .flex()
707        .items_center()
708        .justify_center()
709        .children(icon.map(|glyph| {
710            icons::icon(glyph).size(px(GLYPH)).text_color(if enabled {
711                theme.text_faint
712            } else {
713                theme.text_faint.opacity(0.5)
714            })
715        }))
716}
717
718/// The second line under a row's label: the same relationship a card row's
719/// meta line has to its title ([`crate::widgets::Scaffolding::meta_line`]),
720/// which is where the size and the tone come from.
721///
722/// One line, clipped — rows of a menu are a column of equal things, and a
723/// sentence that wrapped would make its row two or three times its neighbours'
724/// height. What a caller cannot do from outside is guess where to cut: it
725/// would be counting characters against a proportional font at a width only
726/// the panel knows. [`Item::with_tooltip`] is where the rest of it goes.
727///
728/// It sets its own colour rather than inheriting the row's, because the row's
729/// is the *label's* — a lit row paints that at full contrast, and a
730/// description that followed it there would stop reading as the quieter half.
731fn description_line(theme: &Theme, description: SharedString, enabled: bool) -> gpui::Div {
732    div()
733        .mt(px(2.0))
734        .truncate()
735        .text_style(TextStyle::Subheadline)
736        .text_color(if enabled {
737            theme.text_muted
738        } else {
739            // The dimming `glyph_slot` gives a disabled glyph, so the whole
740            // row fades by one rule rather than two.
741            theme.text_faint.opacity(0.5)
742        })
743        .child(description)
744}
745
746/// A row that cannot be chosen: [`popover::menu_row`]'s metrics without its
747/// hover fade or its click, because a disabled row that lit under the pointer
748/// would be inviting a press that does nothing.
749fn disabled_row(theme: &Theme) -> gpui::Div {
750    div()
751        .flex()
752        .flex_row()
753        .items_center()
754        .gap(px(10.0))
755        .px(px(8.0))
756        .py(px(popover::MENU_ROW_PAD_Y))
757        // The disabled twin of `popover::menu_row`, in the same card — so it
758        // takes its corners from the same rule rather than a matching literal.
759        .rounded(px(Theme::inset_radius(
760            Theme::surface_radius(),
761            popover::MENU_PAD,
762        )))
763        .text_style(TextStyle::Body)
764        .text_color(theme.text_faint)
765}