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}