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.
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
9use crate::{icons, popover};
10use gpui::{Context, SharedString, Window, div, prelude::*, px};
11use motion::{Fade, Painter};
12use std::rc::Rc;
13use theme::{TextStyle, Theme, Typeset};
14
15/// The leading glyph and the trailing check, at the size the rows are set in.
16const GLYPH: f32 = 13.0;
17
18/// A row in a menu.
19///
20/// Deliberately not a struct with an `is_separator` flag: a separator has no
21/// label, no accelerator and nothing to enable, and every one of those fields
22/// would have to be answered anyway.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum Item {
25    Action {
26        label: SharedString,
27        /// The leading glyph's asset path — [`crate::icons`]' consts, or a
28        /// path the app resolved at runtime. A menu where no row has one keeps
29        /// no room for it.
30        icon: Option<SharedString>,
31        /// The accelerator to *print* — the binding itself is the app's, and
32        /// bezel never dispatches it. A menu that showed a keystroke it did not
33        /// own would be documenting a lie.
34        keystroke: Option<SharedString>,
35        /// The choice the menu is currently on, marked with a trailing check.
36        checked: bool,
37        enabled: bool,
38    },
39    Separator,
40}
41
42impl Item {
43    pub fn action(label: impl Into<SharedString>) -> Self {
44        Item::Action {
45            label: label.into(),
46            icon: None,
47            keystroke: None,
48            checked: false,
49            enabled: true,
50        }
51    }
52
53    /// No-ops on a separator, which has nothing to hang a glyph on.
54    pub fn with_icon(self, icon: impl Into<SharedString>) -> Self {
55        match self {
56            Item::Action {
57                label,
58                keystroke,
59                checked,
60                enabled,
61                ..
62            } => Item::Action {
63                label,
64                icon: Some(icon.into()),
65                keystroke,
66                checked,
67                enabled,
68            },
69            Item::Separator => Item::Separator,
70        }
71    }
72
73    /// No-ops on a separator, which has nothing to hang a keystroke on.
74    pub fn with_keystroke(self, keystroke: impl Into<SharedString>) -> Self {
75        match self {
76            Item::Action {
77                label,
78                icon,
79                checked,
80                enabled,
81                ..
82            } => Item::Action {
83                label,
84                icon,
85                keystroke: Some(keystroke.into()),
86                checked,
87                enabled,
88            },
89            Item::Separator => Item::Separator,
90        }
91    }
92
93    /// Takes the flag, because what a menu is on is decided per render.
94    pub fn checked(self, checked: bool) -> Self {
95        match self {
96            Item::Action {
97                label,
98                icon,
99                keystroke,
100                enabled,
101                ..
102            } => Item::Action {
103                label,
104                icon,
105                keystroke,
106                checked,
107                enabled,
108            },
109            Item::Separator => Item::Separator,
110        }
111    }
112
113    pub fn disabled(self) -> Self {
114        match self {
115            Item::Action {
116                label,
117                icon,
118                keystroke,
119                checked,
120                ..
121            } => Item::Action {
122                label,
123                icon,
124                keystroke,
125                checked,
126                enabled: false,
127            },
128            Item::Separator => Item::Separator,
129        }
130    }
131
132    /// Whether the keyboard and the pointer can land here at all.
133    pub fn selectable(&self) -> bool {
134        matches!(self, Item::Action { enabled: true, .. })
135    }
136
137    fn has_icon(&self) -> bool {
138        matches!(self, Item::Action { icon: Some(_), .. })
139    }
140}
141
142/// The next row the keyboard can land on, `delta` deciding the direction:
143/// separators and disabled rows are stepped straight over, and both ends wrap.
144/// `from` of `None` enters the menu at the edge the direction comes from.
145///
146/// [`popover::menu_step`] cannot do this — it counts rows and knows nothing
147/// about which of them can be landed on. `None` back means *nothing* in the menu
148/// is selectable, which is the one shape that would otherwise spin forever.
149pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
150    let count = items.len();
151    if count == 0 {
152        return None;
153    }
154    let step = if delta >= 0 { 1 } else { -1 };
155    let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
156    // Entering, the first candidate is the edge itself; moving, it is the row
157    // after the one you are on.
158    let mut at = match from {
159        None if step > 0 => 0,
160        None => count - 1,
161        Some(at) => wrap(at.min(count - 1)),
162    };
163    for _ in 0..count {
164        if items[at].selectable() {
165            return Some(at);
166        }
167        at = wrap(at);
168    }
169    None
170}
171
172/// The panel a menu drops: every [`Item`] as a row, in a
173/// [`popover::popover_card`]. `id` prefixes the rows' element ids, so two menus
174/// open at once keep their hover state apart.
175///
176/// `highlighted` is where the keyboard is. The pointer's own row is the hover
177/// fade, so a menu nobody can arrow through passes `None`.
178///
179/// Dismissal is the caller's `.on_mouse_down_out` on the returned card: what
180/// closing means is the caller's state, not the panel's.
181pub fn card<V: 'static>(
182    theme: &Theme,
183    id: impl Into<SharedString>,
184    items: &[Item],
185    highlighted: Option<usize>,
186    cx: &mut Context<V>,
187    choose: impl Fn(&mut V, usize, &mut Window, &mut Context<V>) + 'static,
188) -> gpui::Div {
189    let id = id.into();
190    let painter = Painter::of(cx);
191    // A menu where nothing carries a glyph keeps no room for one — a bar's
192    // menus would otherwise open with an empty column down their left.
193    let gutter = items.iter().any(Item::has_icon);
194    let choose = Rc::new(choose);
195    popover::popover_card(theme)
196        .min_w(px(180.0))
197        .children(items.iter().enumerate().map(|(index, item)| {
198            let Item::Action {
199                label,
200                icon,
201                keystroke,
202                checked,
203                enabled,
204            } = item
205            else {
206                return popover::divider().into_any_element();
207            };
208            let row = if *enabled {
209                let choose = choose.clone();
210                popover::menu_row(
211                    theme,
212                    highlighted == Some(index),
213                    Some(Fade::new(painter, format!("{id}-{index}"))),
214                )
215                .id(SharedString::from(format!("{id}-{index}")))
216                .on_click(cx.listener(move |view, _, window, cx| choose(view, index, window, cx)))
217            } else {
218                disabled_row(theme).id(SharedString::from(format!("{id}-{index}")))
219            };
220            row.when(gutter, |row| {
221                row.child(glyph_slot(theme, icon.clone(), *enabled))
222            })
223            .child(div().flex_1().min_w_0().child(label.clone()))
224            .when(*checked, |row| {
225                row.child(
226                    icons::icon(icons::CHECK)
227                        .size(px(GLYPH))
228                        .text_color(theme.text),
229                )
230            })
231            .when_some(keystroke.clone(), |row, keystroke| {
232                row.child(popover::kbd_hint(theme, &keystroke))
233            })
234            .into_any_element()
235        }))
236}
237
238/// The leading column: the row's glyph, or the room one would have taken, so a
239/// menu of mixed rows keeps its labels on one edge.
240fn glyph_slot(theme: &Theme, icon: Option<SharedString>, enabled: bool) -> gpui::Div {
241    div()
242        .flex_none()
243        .size(px(GLYPH))
244        .flex()
245        .items_center()
246        .justify_center()
247        .children(icon.map(|path| {
248            gpui::svg()
249                .path(path)
250                .size(px(GLYPH))
251                .text_color(if enabled {
252                    theme.text_faint
253                } else {
254                    theme.text_faint.opacity(0.5)
255                })
256        }))
257}
258
259/// A row that cannot be chosen: [`popover::menu_row`]'s metrics without its
260/// hover fade or its click, because a disabled row that lit under the pointer
261/// would be inviting a press that does nothing.
262fn disabled_row(theme: &Theme) -> gpui::Div {
263    div()
264        .flex()
265        .flex_row()
266        .items_center()
267        .gap(px(10.0))
268        .px(px(8.0))
269        .py(px(6.0))
270        // The disabled twin of `popover::menu_row`, in the same card — so it
271        // takes its corners from the same rule rather than a matching literal.
272        .rounded(px(Theme::inset_radius(
273            Theme::surface_radius(),
274            popover::MENU_PAD,
275        )))
276        .text_style(TextStyle::Body)
277        .text_color(theme.text_faint)
278}