1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum MenuKey {
31 Up,
32 Down,
33 Right,
35 Left,
37 Enter,
38 ModifiedEnter,
39 Escape,
40 Backspace,
41 Other,
42}
43
44pub 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
60pub 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
76pub 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
104pub 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
121pub 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
146fn 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
164pub 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
175pub 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
186pub fn card_flush(theme: &Theme) -> gpui::Div {
189 card(theme).p_0()
190}
191
192#[derive(Debug, Clone, Copy, PartialEq)]
195pub(crate) struct MenuGeometry {
196 pub placement: Placement,
197 pub max_height: f32,
198 pub width: f32,
199}
200
201pub(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
250pub(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
281pub 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
302pub 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
323pub 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
341pub 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
367pub 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
391pub 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
411pub 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
427pub 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
436pub 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
449pub 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
462pub fn dialog_title(theme: &Theme, title: impl Into<SharedString>) -> gpui::Div {
464 text(theme, TypeScale::Title, title.into())
465}
466
467pub 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
474pub fn anchored_slot(
480 placement: Placement,
481 trigger: AnyElement,
482 overlay: Option<AnyElement>,
483) -> gpui::Div {
484 let slot = div().relative().children(overlay);
485 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub enum PopoverEvent {
500 Opened,
501 Dismissed,
503 Closed,
504}
505
506impl EventEmitter<PopoverEvent> for Popover {}
507
508type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
510
511pub 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 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 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 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 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 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 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 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
719pub 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}