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::status::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}