Skip to main content

gpui_kit/overlay/
popover.rs

1//! The anchored surface every menu-shaped component is built from.
2//!
3//! [`Popover`] is the component: a surface that hangs off a trigger and owns
4//! nothing but whether it is open. The free functions around it are the parts
5//! `Select`, `Menu`, `ContextMenu` and `CommandPalette` share — row geometry,
6//! key classification, cursor movement, and the match ranking a filterable
7//! list orders itself by.
8
9use std::rc::Rc;
10
11use gpui::{
12    Anchor, AnyElement, App, Bounds, Context, ElementId, EventEmitter, FocusHandle, Focusable,
13    InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Pixels, Point, Render,
14    SharedString, Styled, Window, div, prelude::*, px,
15};
16use gpui_kit_assets::Icon;
17use gpui_kit_semantics::{NodeSpec, Role, Semantic};
18use gpui_kit_theme::{ActiveTheme, Elevation, Space, TextTone, Theme, TypeScale};
19
20use crate::controls::button::Button;
21use crate::foundation::{Ident, StyledExt, text};
22use crate::overlay::focus::FocusTrap;
23use crate::overlay::layer::{Overlay, Placement, surface};
24
25use crate::motion;
26
27/// What a keystroke means to a menu-like surface, once the platform's
28/// modifier conventions have been applied.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum MenuKey {
31    Up,
32    Down,
33    /// Enters a submenu, which is the leading edge on a left-to-right layout.
34    Right,
35    /// Leaves a submenu.
36    Left,
37    Enter,
38    ModifiedEnter,
39    Escape,
40    Backspace,
41    Other,
42}
43
44/// Reads one keystroke as a menu intent, so every menu-like surface answers
45/// the same keys the same way.
46pub fn classify_key(key: &str, command: bool, control: bool) -> MenuKey {
47    match key {
48        "up" => MenuKey::Up,
49        "down" => MenuKey::Down,
50        "right" => MenuKey::Right,
51        "left" => MenuKey::Left,
52        "enter" if command || control => MenuKey::ModifiedEnter,
53        "enter" => MenuKey::Enter,
54        "escape" => MenuKey::Escape,
55        "backspace" => MenuKey::Backspace,
56        _ => MenuKey::Other,
57    }
58}
59
60/// The letter a keystroke types, for a menu that jumps on it.
61///
62/// Only a bare letter counts: a modified keystroke is a shortcut, and treating
63/// it as type-ahead would move the cursor while the typist meant to act.
64pub fn typed_letter(key: &str, modifiers: gpui::Modifiers) -> Option<char> {
65    if modifiers.platform || modifiers.control || modifiers.alt || modifiers.function {
66        return None;
67    }
68    let mut characters = key.chars();
69    let letter = characters.next()?;
70    if characters.next().is_some() || !letter.is_alphanumeric() {
71        return None;
72    }
73    Some(letter.to_ascii_lowercase())
74}
75
76/// The next entry whose label starts with `letter`, searching forward from the
77/// cursor and wrapping. `None` in `labels` marks an entry that cannot be
78/// jumped to, such as a separator or a refused row.
79pub fn jump_to<S: AsRef<str>>(
80    labels: &[Option<S>],
81    from: Option<usize>,
82    letter: char,
83) -> Option<usize> {
84    let count = labels.len();
85    if count == 0 {
86        return None;
87    }
88    let start = from.map_or(0, |index| index + 1);
89    let letter = letter.to_lowercase().next()?;
90    (0..count)
91        .map(|offset| (start + offset) % count)
92        .find(|index| {
93            labels[*index].as_ref().is_some_and(|label| {
94                label
95                    .as_ref()
96                    .chars()
97                    .next()
98                    .and_then(|first| first.to_lowercase().next())
99                    == Some(letter)
100            })
101        })
102}
103
104/// The index `delta` steps from `active`, wrapping at the ends.
105///
106/// A menu wraps where a strip stops: the list is short, the whole of it is on
107/// screen, and arrowing off the bottom onto the top is how a menu has always
108/// behaved. A strip stops instead, which `foundation::stepping` handles.
109pub fn step(active: Option<usize>, count: usize, delta: isize) -> Option<usize> {
110    if count == 0 {
111        return None;
112    }
113    let count = count as isize;
114    Some(match active {
115        None if delta >= 0 => 0,
116        None => count - 1,
117        Some(index) => (index as isize + delta).rem_euclid(count),
118    } as usize)
119}
120
121/// How well `label` answers `query`, lower being better, or `None` when it
122/// does not answer it at all.
123///
124/// A typist who knows what they are looking for types its beginning, so a
125/// prefix outranks the start of a later word, which outranks a match in the
126/// middle of one, which outranks letters merely occurring in order.
127pub fn match_rank(query: &str, label: &str) -> Option<usize> {
128    let query = query.trim().to_lowercase();
129    if query.is_empty() {
130        return Some(1);
131    }
132    let label = label.to_lowercase();
133    if label.starts_with(&query) {
134        Some(0)
135    } else if word_starts(&label).any(|start| label[start..].starts_with(&query)) {
136        Some(1)
137    } else if label.contains(&query) {
138        Some(2)
139    } else if is_subsequence(&query, &label) {
140        Some(3)
141    } else {
142        None
143    }
144}
145
146/// Byte offsets of every character that begins a word.
147fn word_starts(label: &str) -> impl Iterator<Item = usize> + '_ {
148    label.char_indices().filter_map(move |(index, character)| {
149        if index == 0 || !character.is_alphanumeric() {
150            return None;
151        }
152        let previous = label[..index].chars().next_back()?;
153        (!previous.is_alphanumeric()).then_some(index)
154    })
155}
156
157fn is_subsequence(query: &str, label: &str) -> bool {
158    let mut characters = label.chars();
159    query
160        .chars()
161        .all(|wanted| characters.any(|character| character == wanted))
162}
163
164/// The indices of the labels `query` answers, best answer first.
165pub fn filter_indices<S: AsRef<str>>(query: &str, labels: &[S]) -> Vec<usize> {
166    let mut ranked: Vec<_> = labels
167        .iter()
168        .enumerate()
169        .filter_map(|(index, label)| match_rank(query, label.as_ref()).map(|rank| (rank, index)))
170        .collect();
171    ranked.sort_by_key(|&(rank, index)| (rank, index));
172    ranked.into_iter().map(|(_, index)| index).collect()
173}
174
175/// The elevated surface every anchored overlay draws.
176pub fn card(theme: &Theme) -> gpui::Div {
177    div()
178        .rounded(px(theme.radii.card))
179        .elevation(theme, Elevation::Overlay)
180        .p(px(theme.spacing.xs))
181        .overflow_hidden()
182        .bg(theme.colors.overlay)
183        .text_color(theme.colors.text)
184}
185
186/// [`card`] without the inner padding, for a surface that draws its own rows
187/// edge to edge.
188pub fn card_flush(theme: &Theme) -> gpui::Div {
189    card(theme).p_0()
190}
191
192/// The viewport policy for a select-like popup whose trigger was measured on
193/// the preceding frame.
194#[derive(Debug, Clone, Copy, PartialEq)]
195pub(crate) struct MenuGeometry {
196    pub placement: Placement,
197    pub max_height: f32,
198    pub width: f32,
199}
200
201/// Resolves one menu against the actual window and trigger bounds.
202///
203/// The fallback is deliberately bounded by the whole usable viewport. It is
204/// used only before the trigger's first prepaint; that prepaint requests the
205/// corrective frame with side-specific space.
206pub(crate) fn menu_geometry(
207    window: &Window,
208    trigger: Bounds<Pixels>,
209    theme: &Theme,
210    desired_height: f32,
211    min_width: f32,
212) -> MenuGeometry {
213    let viewport = window.viewport_size();
214    let viewport_height = f32::from(viewport.height);
215    let viewport_width = f32::from(viewport.width);
216    let margin = theme.spacing.sm;
217    let gap = (theme.spacing.sm - 2.0).max(0.0);
218    let usable_width = (viewport_width - margin * 2.0).max(0.0);
219    let measured_width = f32::from(trigger.size.width);
220    let width = measured_width.max(min_width).min(usable_width);
221    let measured = measured_width > 0.0 && f32::from(trigger.size.height) > 0.0;
222
223    if !measured {
224        return MenuGeometry {
225            placement: Placement::Below,
226            max_height: desired_height.min((viewport_height - margin * 2.0 - gap).max(0.0)),
227            width,
228        };
229    }
230
231    let below = (viewport_height - margin - f32::from(trigger.bottom()) - gap).max(0.0);
232    let above = (f32::from(trigger.top()) - margin - gap).max(0.0);
233    let placement = if below >= desired_height || below >= above {
234        Placement::Below
235    } else {
236        Placement::Above
237    };
238    let available = match placement {
239        Placement::Above => above,
240        _ => below,
241    };
242
243    MenuGeometry {
244        placement,
245        max_height: desired_height.min(available),
246        width,
247    }
248}
249
250/// Paints a side-resolved menu through the canonical popover layer.
251pub(crate) fn menu_overlay(
252    ident: &Ident,
253    theme: &Theme,
254    placement: Placement,
255    content: AnyElement,
256) -> AnyElement {
257    let gap = px((theme.spacing.sm - 2.0).max(0.0));
258    let frame = div()
259        .occlude()
260        .when(placement == Placement::Below, |element| element.pt(gap))
261        .when(placement == Placement::Above, |element| element.pb(gap))
262        .child(content);
263
264    Overlay::new(ident.child("overlay"))
265        .placement(placement)
266        .window_snap_margin(px(theme.spacing.sm))
267        .child(motion::menu_in(ident.element_id(), theme, frame))
268        .into_any_element()
269}
270
271fn pinned(layer: AnyElement) -> AnyElement {
272    div()
273        .absolute()
274        .top_0()
275        .left_0()
276        .size_0()
277        .child(layer)
278        .into_any_element()
279}
280
281/// Places `content` under `anchor`, flipping above when there is no room.
282pub fn anchored_below(id: impl Into<ElementId>, theme: &Theme, content: AnyElement) -> AnyElement {
283    pinned(
284        gpui::deferred(
285            gpui::anchored()
286                .anchor(Anchor::TopLeft)
287                .snap_to_window_with_margin(px(theme.spacing.sm))
288                .child(motion::menu_in(
289                    id,
290                    theme,
291                    div()
292                        .occlude()
293                        .pt(px(theme.spacing.sm - 2.0))
294                        .child(content),
295                )),
296        )
297        .priority(1)
298        .into_any_element(),
299    )
300}
301
302/// Places `content` over `anchor`, flipping below when there is no room.
303pub fn anchored_above(id: impl Into<ElementId>, theme: &Theme, content: AnyElement) -> AnyElement {
304    pinned(
305        gpui::deferred(
306            gpui::anchored()
307                .anchor(Anchor::BottomLeft)
308                .snap_to_window_with_margin(px(theme.spacing.sm))
309                .child(motion::menu_in(
310                    id,
311                    theme,
312                    div()
313                        .occlude()
314                        .pb(px(theme.spacing.sm - 2.0))
315                        .child(content),
316                )),
317        )
318        .priority(1)
319        .into_any_element(),
320    )
321}
322
323/// Places `content` at a point, which is what a context menu needs.
324pub fn at(
325    id: impl Into<ElementId>,
326    theme: &Theme,
327    position: Point<Pixels>,
328    content: AnyElement,
329) -> AnyElement {
330    gpui::deferred(
331        gpui::anchored()
332            .position(position)
333            .anchor(Anchor::TopLeft)
334            .snap_to_window_with_margin(px(theme.spacing.sm))
335            .child(motion::menu_in(id, theme, div().occlude().child(content))),
336    )
337    .priority(1)
338    .into_any_element()
339}
340
341/// Places `content` in the middle of the window, over a scrim.
342pub fn modal(
343    id: impl Into<ElementId>,
344    theme: &Theme,
345    viewport: gpui::Size<Pixels>,
346    content: AnyElement,
347) -> AnyElement {
348    gpui::deferred(
349        gpui::anchored()
350            .position(gpui::point(px(0.0), px(0.0)))
351            .child(
352                div()
353                    .occlude()
354                    .w(viewport.width)
355                    .h(viewport.height)
356                    .bg(gpui::black().opacity(0.6))
357                    .flex()
358                    .items_center()
359                    .justify_center()
360                    .child(motion::dialog_in(id, theme, div().child(content))),
361            ),
362    )
363    .priority(2)
364    .into_any_element()
365}
366
367/// One row of a menu-like surface, with the shared height, hover wash, and
368/// refusal treatment.
369pub fn menu_row(theme: &Theme, selected: bool, highlighted: bool) -> gpui::Div {
370    div()
371        .flex()
372        .flex_row()
373        .items_center()
374        .gap(px(10.0))
375        .px(px(theme.spacing.sm))
376        .py(px(6.0))
377        .rounded(px(theme.radii.control))
378        .when(selected, |element| {
379            element
380                .bg(theme.colors.selected)
381                .shadow(theme.selected_ring())
382        })
383        .when(!selected && highlighted, |element| {
384            element.bg(theme.colors.hover)
385        })
386        .when(!selected && !highlighted, |element| {
387            element.hover(|style| style.bg(theme.colors.hover))
388        })
389}
390
391/// A menu row's visible label, including the foreground transition for a
392/// pointer anywhere over the row rather than only over the glyphs themselves.
393pub fn menu_label(
394    theme: &Theme,
395    label: impl Into<SharedString>,
396    selected: bool,
397    highlighted: bool,
398    hover_group: SharedString,
399) -> gpui::Div {
400    text(theme, TypeScale::Label, label)
401        .text_color(if selected || highlighted {
402            theme.colors.text
403        } else {
404            theme.colors.text_muted
405        })
406        .when(!selected && !highlighted, |element| {
407            element.group_hover(hover_group, |style| style.text_color(theme.colors.text))
408        })
409}
410
411/// A section label inside a menu-like surface.
412pub fn heading(theme: &Theme, label: &str) -> gpui::Div {
413    div()
414        .px(px(theme.spacing.sm))
415        .pb(px(theme.spacing.xs))
416        .pt(px(6.0))
417        .child(
418            text(
419                theme,
420                TypeScale::Caption,
421                SharedString::from(tracked_upper(label)),
422            )
423            .text_color(theme.colors.text_muted.opacity(0.6)),
424        )
425}
426
427/// The hairline between two groups of menu rows.
428pub fn separator(theme: &Theme) -> gpui::Div {
429    div()
430        .h(px(1.0))
431        .mx(px(-theme.spacing.xs))
432        .my(px(theme.spacing.xs))
433        .bg(theme.colors.hairline)
434}
435
436/// The shortcut cap a menu row carries on its trailing edge.
437pub fn key_cap(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
438    div()
439        .h(px(22.0))
440        .px(px(5.0))
441        .rounded(px(theme.radii.small))
442        .flex()
443        .items_center()
444        .justify_center()
445        .bg(theme.colors.hover.opacity(0.38))
446        .child(text(theme, TypeScale::Code, label.into()).text_tone(theme, TextTone::Muted))
447}
448
449/// The surface a modal draws itself on.
450pub fn dialog_card(theme: &Theme) -> gpui::Div {
451    div()
452        .w(px(360.0))
453        .p(px(theme.spacing.xl - theme.spacing.xs))
454        .rounded(px(theme.radii.dialog))
455        .bg(theme.colors.overlay)
456        .elevation(theme, Elevation::Modal)
457        .flex()
458        .flex_col()
459        .text_color(theme.colors.text)
460}
461
462/// The one question a modal is asking.
463pub fn dialog_title(theme: &Theme, title: impl Into<SharedString>) -> gpui::Div {
464    text(theme, TypeScale::Title, title.into())
465}
466
467/// The detail under a modal's question.
468pub fn dialog_body(theme: &Theme, body: impl Into<SharedString>) -> gpui::Div {
469    text(theme, TypeScale::Body, body.into())
470        .mt(px(theme.spacing.sm))
471        .text_tone(theme, TextTone::Muted)
472}
473
474/// Lays a trigger out with the slot an anchored overlay hangs from.
475///
476/// A surface anchors to where its slot sits, so the slot goes under the
477/// trigger for a surface that opens downwards and above it for one that opens
478/// upwards. The slot takes no space of its own.
479pub fn anchored_slot(
480    placement: Placement,
481    trigger: AnyElement,
482    overlay: Option<AnyElement>,
483) -> gpui::Div {
484    let slot = div().relative().children(overlay);
485    // The trigger sizes to itself: one stretched to its container would claim
486    // to be a full-width control.
487    let frame = div().flex().flex_col().items_start();
488    match placement {
489        Placement::Above => frame.child(slot).child(trigger),
490        _ => frame.child(trigger).child(slot),
491    }
492}
493
494/// What a popover reports. The owner decides what any of it means.
495///
496/// A dismissal is always followed by [`PopoverEvent::Closed`], so a subscriber
497/// that only cares that the surface went away has one event to watch.
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub enum PopoverEvent {
500    Opened,
501    /// The surface was waved away, by escape or by a click outside it.
502    Dismissed,
503    Closed,
504}
505
506impl EventEmitter<PopoverEvent> for Popover {}
507
508/// Builds the popover body for one frame.
509type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
510
511/// A surface anchored to a trigger, holding whatever the caller puts in it.
512///
513/// Open state and the element that had the keyboard before opening both
514/// outlive a frame, so a popover is a view rather than a builder. The body is
515/// a callback instead of a stored element because an `AnyElement` can be
516/// consumed once, while an open popover re-renders for as long as it stays
517/// open.
518pub struct Popover {
519    ident: Ident,
520    focus_handle: FocusHandle,
521    trigger_focus: FocusHandle,
522    trigger: SharedString,
523    trigger_icon: Option<Icon>,
524    content: Option<Content>,
525    placement: Placement,
526    dismissable: bool,
527    open: bool,
528    /// Set by `open`, cleared by the first frame that can act on it.
529    pending_focus: bool,
530    trap: FocusTrap,
531}
532
533impl std::fmt::Debug for Popover {
534    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535        formatter
536            .debug_struct("Popover")
537            .field("ident", &self.ident)
538            .field("trigger", &self.trigger)
539            .field("has_content", &self.content.is_some())
540            .field("placement", &self.placement)
541            .field("dismissable", &self.dismissable)
542            .field("open", &self.open)
543            .finish()
544    }
545}
546
547impl Popover {
548    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
549        Self {
550            ident: ident.into(),
551            focus_handle: cx.focus_handle(),
552            trigger_focus: cx.focus_handle(),
553            trigger: SharedString::default(),
554            trigger_icon: None,
555            content: None,
556            placement: Placement::Below,
557            dismissable: true,
558            open: false,
559            pending_focus: false,
560            trap: FocusTrap::new(),
561        }
562    }
563
564    /// The label of the control that opens the surface.
565    pub fn trigger(mut self, label: impl Into<SharedString>) -> Self {
566        self.trigger = label.into();
567        self
568    }
569
570    pub fn trigger_icon(mut self, icon: Icon) -> Self {
571        self.trigger_icon = Some(icon);
572        self
573    }
574
575    /// Supplies the body, rebuilt on every frame the popover is open.
576    pub fn content(
577        mut self,
578        content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
579    ) -> Self {
580        self.content = Some(Rc::new(content));
581        self
582    }
583
584    pub fn placement(mut self, placement: Placement) -> Self {
585        self.placement = placement;
586        self
587    }
588
589    /// Whether escape and a click outside close the surface. A popover that is
590    /// not dismissable installs neither handler.
591    pub fn dismissable(mut self, dismissable: bool) -> Self {
592        self.dismissable = dismissable;
593        self
594    }
595
596    pub fn is_open(&self) -> bool {
597        self.open
598    }
599
600    pub fn is_dismissable(&self) -> bool {
601        self.dismissable
602    }
603
604    pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
605        if self.open {
606            return;
607        }
608        self.open = true;
609        self.pending_focus = true;
610        self.trap.engage(window, cx);
611        cx.emit(PopoverEvent::Opened);
612        cx.notify();
613    }
614
615    /// Closes the surface and gives the keyboard back to the trigger.
616    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
617        if !self.open {
618            return;
619        }
620        self.open = false;
621        self.pending_focus = false;
622        self.trap.release(window, cx);
623        self.trigger_focus.focus(window, cx);
624        cx.emit(PopoverEvent::Closed);
625        cx.notify();
626    }
627
628    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
629        if self.open {
630            self.dismiss(window, cx);
631        } else {
632            self.open(window, cx);
633        }
634    }
635
636    /// Reports a wave-away. A popover that is not dismissable cannot be waved
637    /// away even by a host calling this directly.
638    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
639        if !self.open || !self.dismissable {
640            return;
641        }
642        cx.emit(PopoverEvent::Dismissed);
643        self.close(window, cx);
644    }
645
646    fn on_dismiss_key(
647        &mut self,
648        event: &KeyDownEvent,
649        window: &mut Window,
650        cx: &mut Context<Self>,
651    ) {
652        if !self.open || event.keystroke.key.as_str() != "escape" {
653            return;
654        }
655        self.dismiss(window, cx);
656        cx.stop_propagation();
657    }
658}
659
660impl Focusable for Popover {
661    fn focus_handle(&self, _cx: &App) -> FocusHandle {
662        self.focus_handle.clone()
663    }
664}
665
666impl Render for Popover {
667    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
668        let theme = cx.theme().clone();
669        let popover = cx.entity().downgrade();
670        let trigger = Button::new(self.ident.child("trigger"))
671            .label(self.trigger.clone())
672            .secondary()
673            .track_focus(&self.trigger_focus)
674            .when_some(self.trigger_icon, |button, glyph| button.icon(glyph))
675            .on_click(move |window, cx| {
676                popover
677                    .update(cx, |popover, cx| popover.toggle(window, cx))
678                    .ok();
679            })
680            .into_any_element();
681
682        let overlay = self.open.then(|| {
683            if self.pending_focus {
684                // The handle can only take focus once this frame has put it in
685                // the dispatch tree, which is why opening records the intent.
686                self.pending_focus = false;
687                self.focus_handle.focus(window, cx);
688            }
689            let body = self.content.clone().map(|content| content(window, cx));
690            let mut card = surface(&theme, Elevation::Overlay)
691                .p_token(&theme, Space::Sm)
692                .track_focus(&self.focus_handle);
693            if self.dismissable {
694                card = card
695                    .on_key_down(cx.listener(Self::on_dismiss_key))
696                    .on_mouse_down_out(cx.listener(|popover, _, window, cx| {
697                        popover.dismiss(window, cx);
698                    }));
699            }
700            let card = card.children(body).semantic_in(
701                cx,
702                NodeSpec::new(self.ident.child("surface").semantic_id(), Role::Group)
703                    .parent(self.ident.semantic_id())
704                    .focus(&self.focus_handle),
705            );
706            Overlay::new(self.ident.child("overlay"))
707                .placement(self.placement)
708                .child(card)
709                .into_any_element()
710        });
711
712        anchored_slot(self.placement, trigger, overlay).semantic_in(
713            cx,
714            NodeSpec::new(self.ident.semantic_id(), Role::Group).expanded(self.open),
715        )
716    }
717}
718
719/// Upper-cases a section label and opens its letter spacing, which is the one
720/// place in the library that shouts.
721pub fn tracked_upper(label: &str) -> String {
722    let mut output = String::with_capacity(label.len() * 2);
723    for (index, character) in label.to_uppercase().chars().enumerate() {
724        if index > 0 {
725            output.push('\u{200A}');
726        }
727        output.push(character);
728    }
729    output
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn navigation_wraps_and_handles_empty_lists() {
738        assert_eq!(step(None, 0, 1), None);
739        assert_eq!(step(None, 3, 1), Some(0));
740        assert_eq!(step(None, 3, -1), Some(2));
741        assert_eq!(step(Some(2), 3, 1), Some(0));
742        assert_eq!(step(Some(0), 3, -1), Some(2));
743    }
744
745    #[test]
746    fn filtering_prefers_prefixes_and_is_stable() {
747        let labels = ["main", "feature/main-sync", "master", "dev"];
748        assert_eq!(filter_indices("ma", &labels), vec![0, 2, 1]);
749        assert_eq!(filter_indices("", &labels), vec![0, 1, 2, 3]);
750    }
751
752    #[test]
753    fn key_classification_keeps_modified_enter_distinct() {
754        assert_eq!(classify_key("enter", false, false), MenuKey::Enter);
755        assert_eq!(classify_key("enter", true, false), MenuKey::ModifiedEnter);
756        assert_eq!(classify_key("escape", false, false), MenuKey::Escape);
757    }
758
759    #[test]
760    fn a_submenu_is_entered_and_left_sideways() {
761        assert_eq!(classify_key("right", false, false), MenuKey::Right);
762        assert_eq!(classify_key("left", false, false), MenuKey::Left);
763    }
764
765    #[test]
766    fn ranking_prefers_a_prefix_then_a_word_then_a_subsequence() {
767        assert_eq!(match_rank("com", "Command palette"), Some(0));
768        assert_eq!(match_rank("pal", "Command palette"), Some(1));
769        assert_eq!(match_rank("mmand", "Command palette"), Some(2));
770        assert_eq!(match_rank("cmp", "Command palette"), Some(3));
771        assert_eq!(match_rank("zz", "Command palette"), None);
772    }
773
774    #[test]
775    fn filtering_orders_literal_matches_ahead_of_a_subsequence() {
776        let labels = ["Set theme", "Reset zoom", "Show settings", "Save file"];
777        assert_eq!(filter_indices("se", &labels), vec![0, 2, 1, 3]);
778    }
779
780    #[test]
781    fn type_ahead_wraps_and_skips_entries_it_cannot_land_on() {
782        let labels = [
783            Some("Copy"),
784            None,
785            Some("Cut"),
786            Some("Paste"),
787            Some("Copy path"),
788        ];
789        assert_eq!(jump_to(&labels, None, 'c'), Some(0));
790        assert_eq!(jump_to(&labels, Some(0), 'c'), Some(2));
791        assert_eq!(jump_to(&labels, Some(2), 'c'), Some(4));
792        assert_eq!(jump_to(&labels, Some(4), 'c'), Some(0));
793        assert_eq!(jump_to(&labels, None, 'z'), None);
794    }
795
796    #[test]
797    fn only_an_unmodified_letter_is_type_ahead() {
798        let none = gpui::Modifiers::none();
799        assert_eq!(typed_letter("s", none), Some('s'));
800        assert_eq!(typed_letter("escape", none), None);
801        assert_eq!(typed_letter("s", gpui::Modifiers::command()), None);
802    }
803}