use std::rc::Rc;
use gpui::{
Anchor, AnyElement, App, Bounds, Context, ElementId, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Pixels, Point, Render,
SharedString, Styled, Window, div, prelude::*, px,
};
use gpui_kit_assets::Icon;
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Space, TextTone, Theme, TypeScale};
use crate::controls::button::Button;
use crate::foundation::{Ident, StyledExt, text};
use crate::overlay::focus::FocusTrap;
use crate::overlay::layer::{Overlay, Placement, surface};
use crate::motion;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuKey {
Up,
Down,
Right,
Left,
Enter,
ModifiedEnter,
Escape,
Backspace,
Other,
}
pub fn classify_key(key: &str, command: bool, control: bool) -> MenuKey {
match key {
"up" => MenuKey::Up,
"down" => MenuKey::Down,
"right" => MenuKey::Right,
"left" => MenuKey::Left,
"enter" if command || control => MenuKey::ModifiedEnter,
"enter" => MenuKey::Enter,
"escape" => MenuKey::Escape,
"backspace" => MenuKey::Backspace,
_ => MenuKey::Other,
}
}
pub fn typed_letter(key: &str, modifiers: gpui::Modifiers) -> Option<char> {
if modifiers.platform || modifiers.control || modifiers.alt || modifiers.function {
return None;
}
let mut characters = key.chars();
let letter = characters.next()?;
if characters.next().is_some() || !letter.is_alphanumeric() {
return None;
}
Some(letter.to_ascii_lowercase())
}
pub fn jump_to<S: AsRef<str>>(
labels: &[Option<S>],
from: Option<usize>,
letter: char,
) -> Option<usize> {
let count = labels.len();
if count == 0 {
return None;
}
let start = from.map_or(0, |index| index + 1);
let letter = letter.to_lowercase().next()?;
(0..count)
.map(|offset| (start + offset) % count)
.find(|index| {
labels[*index].as_ref().is_some_and(|label| {
label
.as_ref()
.chars()
.next()
.and_then(|first| first.to_lowercase().next())
== Some(letter)
})
})
}
pub fn step(active: Option<usize>, count: usize, delta: isize) -> Option<usize> {
if count == 0 {
return None;
}
let count = count as isize;
Some(match active {
None if delta >= 0 => 0,
None => count - 1,
Some(index) => (index as isize + delta).rem_euclid(count),
} as usize)
}
pub fn match_rank(query: &str, label: &str) -> Option<usize> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Some(1);
}
let label = label.to_lowercase();
if label.starts_with(&query) {
Some(0)
} else if word_starts(&label).any(|start| label[start..].starts_with(&query)) {
Some(1)
} else if label.contains(&query) {
Some(2)
} else if is_subsequence(&query, &label) {
Some(3)
} else {
None
}
}
fn word_starts(label: &str) -> impl Iterator<Item = usize> + '_ {
label.char_indices().filter_map(move |(index, character)| {
if index == 0 || !character.is_alphanumeric() {
return None;
}
let previous = label[..index].chars().next_back()?;
(!previous.is_alphanumeric()).then_some(index)
})
}
fn is_subsequence(query: &str, label: &str) -> bool {
let mut characters = label.chars();
query
.chars()
.all(|wanted| characters.any(|character| character == wanted))
}
pub fn filter_indices<S: AsRef<str>>(query: &str, labels: &[S]) -> Vec<usize> {
let mut ranked: Vec<_> = labels
.iter()
.enumerate()
.filter_map(|(index, label)| match_rank(query, label.as_ref()).map(|rank| (rank, index)))
.collect();
ranked.sort_by_key(|&(rank, index)| (rank, index));
ranked.into_iter().map(|(_, index)| index).collect()
}
pub fn card(theme: &Theme) -> gpui::Div {
div()
.rounded(px(theme.radii.card))
.elevation(theme, Elevation::Overlay)
.p(px(theme.spacing.xs))
.overflow_hidden()
.bg(theme.colors.overlay)
.text_color(theme.colors.text)
}
pub fn card_flush(theme: &Theme) -> gpui::Div {
card(theme).p_0()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct MenuGeometry {
pub placement: Placement,
pub max_height: f32,
pub width: f32,
}
pub(crate) fn menu_geometry(
window: &Window,
trigger: Bounds<Pixels>,
theme: &Theme,
desired_height: f32,
min_width: f32,
) -> MenuGeometry {
let viewport = window.viewport_size();
let viewport_height = f32::from(viewport.height);
let viewport_width = f32::from(viewport.width);
let margin = theme.spacing.sm;
let gap = (theme.spacing.sm - 2.0).max(0.0);
let usable_width = (viewport_width - margin * 2.0).max(0.0);
let measured_width = f32::from(trigger.size.width);
let width = measured_width.max(min_width).min(usable_width);
let measured = measured_width > 0.0 && f32::from(trigger.size.height) > 0.0;
if !measured {
return MenuGeometry {
placement: Placement::Below,
max_height: desired_height.min((viewport_height - margin * 2.0 - gap).max(0.0)),
width,
};
}
let below = (viewport_height - margin - f32::from(trigger.bottom()) - gap).max(0.0);
let above = (f32::from(trigger.top()) - margin - gap).max(0.0);
let placement = if below >= desired_height || below >= above {
Placement::Below
} else {
Placement::Above
};
let available = match placement {
Placement::Above => above,
_ => below,
};
MenuGeometry {
placement,
max_height: desired_height.min(available),
width,
}
}
pub(crate) fn menu_overlay(
ident: &Ident,
theme: &Theme,
placement: Placement,
content: AnyElement,
) -> AnyElement {
let gap = px((theme.spacing.sm - 2.0).max(0.0));
let frame = div()
.occlude()
.when(placement == Placement::Below, |element| element.pt(gap))
.when(placement == Placement::Above, |element| element.pb(gap))
.child(content);
Overlay::new(ident.child("overlay"))
.placement(placement)
.window_snap_margin(px(theme.spacing.sm))
.child(motion::menu_in(ident.element_id(), theme, frame))
.into_any_element()
}
fn pinned(layer: AnyElement) -> AnyElement {
div()
.absolute()
.top_0()
.left_0()
.size_0()
.child(layer)
.into_any_element()
}
pub fn anchored_below(id: impl Into<ElementId>, theme: &Theme, content: AnyElement) -> AnyElement {
pinned(
gpui::deferred(
gpui::anchored()
.anchor(Anchor::TopLeft)
.snap_to_window_with_margin(px(theme.spacing.sm))
.child(motion::menu_in(
id,
theme,
div()
.occlude()
.pt(px(theme.spacing.sm - 2.0))
.child(content),
)),
)
.priority(1)
.into_any_element(),
)
}
pub fn anchored_above(id: impl Into<ElementId>, theme: &Theme, content: AnyElement) -> AnyElement {
pinned(
gpui::deferred(
gpui::anchored()
.anchor(Anchor::BottomLeft)
.snap_to_window_with_margin(px(theme.spacing.sm))
.child(motion::menu_in(
id,
theme,
div()
.occlude()
.pb(px(theme.spacing.sm - 2.0))
.child(content),
)),
)
.priority(1)
.into_any_element(),
)
}
pub fn at(
id: impl Into<ElementId>,
theme: &Theme,
position: Point<Pixels>,
content: AnyElement,
) -> AnyElement {
gpui::deferred(
gpui::anchored()
.position(position)
.anchor(Anchor::TopLeft)
.snap_to_window_with_margin(px(theme.spacing.sm))
.child(motion::menu_in(id, theme, div().occlude().child(content))),
)
.priority(1)
.into_any_element()
}
pub fn modal(
id: impl Into<ElementId>,
theme: &Theme,
viewport: gpui::Size<Pixels>,
content: AnyElement,
) -> AnyElement {
gpui::deferred(
gpui::anchored()
.position(gpui::point(px(0.0), px(0.0)))
.child(
div()
.occlude()
.w(viewport.width)
.h(viewport.height)
.bg(gpui::black().opacity(0.6))
.flex()
.items_center()
.justify_center()
.child(motion::dialog_in(id, theme, div().child(content))),
),
)
.priority(2)
.into_any_element()
}
pub fn menu_row(theme: &Theme, selected: bool, highlighted: bool) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(10.0))
.px(px(theme.spacing.sm))
.py(px(6.0))
.rounded(px(theme.radii.control))
.when(selected, |element| {
element
.bg(theme.colors.selected)
.shadow(theme.selected_ring())
})
.when(!selected && highlighted, |element| {
element.bg(theme.colors.hover)
})
.when(!selected && !highlighted, |element| {
element.hover(|style| style.bg(theme.colors.hover))
})
}
pub fn menu_label(
theme: &Theme,
label: impl Into<SharedString>,
selected: bool,
highlighted: bool,
hover_group: SharedString,
) -> gpui::Div {
text(theme, TypeScale::Label, label)
.text_color(if selected || highlighted {
theme.colors.text
} else {
theme.colors.text_muted
})
.when(!selected && !highlighted, |element| {
element.group_hover(hover_group, |style| style.text_color(theme.colors.text))
})
}
pub fn heading(theme: &Theme, label: &str) -> gpui::Div {
div()
.px(px(theme.spacing.sm))
.pb(px(theme.spacing.xs))
.pt(px(6.0))
.child(
text(
theme,
TypeScale::Caption,
SharedString::from(tracked_upper(label)),
)
.text_color(theme.colors.text_muted.opacity(0.6)),
)
}
pub fn separator(theme: &Theme) -> gpui::Div {
div()
.h(px(1.0))
.mx(px(-theme.spacing.xs))
.my(px(theme.spacing.xs))
.bg(theme.colors.hairline)
}
pub fn key_cap(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
div()
.h(px(22.0))
.px(px(5.0))
.rounded(px(theme.radii.small))
.flex()
.items_center()
.justify_center()
.bg(theme.colors.hover.opacity(0.38))
.child(text(theme, TypeScale::Code, label.into()).text_tone(theme, TextTone::Muted))
}
pub fn dialog_card(theme: &Theme) -> gpui::Div {
div()
.w(px(360.0))
.p(px(theme.spacing.xl - theme.spacing.xs))
.rounded(px(theme.radii.dialog))
.bg(theme.colors.overlay)
.elevation(theme, Elevation::Modal)
.flex()
.flex_col()
.text_color(theme.colors.text)
}
pub fn dialog_title(theme: &Theme, title: impl Into<SharedString>) -> gpui::Div {
text(theme, TypeScale::Title, title.into())
}
pub fn dialog_body(theme: &Theme, body: impl Into<SharedString>) -> gpui::Div {
text(theme, TypeScale::Body, body.into())
.mt(px(theme.spacing.sm))
.text_tone(theme, TextTone::Muted)
}
pub fn anchored_slot(
placement: Placement,
trigger: AnyElement,
overlay: Option<AnyElement>,
) -> gpui::Div {
let slot = div().relative().children(overlay);
let frame = div().flex().flex_col().items_start();
match placement {
Placement::Above => frame.child(slot).child(trigger),
_ => frame.child(trigger).child(slot),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopoverEvent {
Opened,
Dismissed,
Closed,
}
impl EventEmitter<PopoverEvent> for Popover {}
type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
pub struct Popover {
ident: Ident,
focus_handle: FocusHandle,
trigger_focus: FocusHandle,
trigger: SharedString,
trigger_icon: Option<Icon>,
content: Option<Content>,
placement: Placement,
dismissable: bool,
open: bool,
pending_focus: bool,
trap: FocusTrap,
}
impl std::fmt::Debug for Popover {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Popover")
.field("ident", &self.ident)
.field("trigger", &self.trigger)
.field("has_content", &self.content.is_some())
.field("placement", &self.placement)
.field("dismissable", &self.dismissable)
.field("open", &self.open)
.finish()
}
}
impl Popover {
pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
ident: ident.into(),
focus_handle: cx.focus_handle(),
trigger_focus: cx.focus_handle(),
trigger: SharedString::default(),
trigger_icon: None,
content: None,
placement: Placement::Below,
dismissable: true,
open: false,
pending_focus: false,
trap: FocusTrap::new(),
}
}
pub fn trigger(mut self, label: impl Into<SharedString>) -> Self {
self.trigger = label.into();
self
}
pub fn trigger_icon(mut self, icon: Icon) -> Self {
self.trigger_icon = Some(icon);
self
}
pub fn content(
mut self,
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.content = Some(Rc::new(content));
self
}
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn is_dismissable(&self) -> bool {
self.dismissable
}
pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.open {
return;
}
self.open = true;
self.pending_focus = true;
self.trap.engage(window, cx);
cx.emit(PopoverEvent::Opened);
cx.notify();
}
pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
self.open = false;
self.pending_focus = false;
self.trap.release(window, cx);
self.trigger_focus.focus(window, cx);
cx.emit(PopoverEvent::Closed);
cx.notify();
}
pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.open {
self.dismiss(window, cx);
} else {
self.open(window, cx);
}
}
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open || !self.dismissable {
return;
}
cx.emit(PopoverEvent::Dismissed);
self.close(window, cx);
}
fn on_dismiss_key(
&mut self,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.open || event.keystroke.key.as_str() != "escape" {
return;
}
self.dismiss(window, cx);
cx.stop_propagation();
}
}
impl Focusable for Popover {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Popover {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
let popover = cx.entity().downgrade();
let trigger = Button::new(self.ident.child("trigger"))
.label(self.trigger.clone())
.secondary()
.track_focus(&self.trigger_focus)
.when_some(self.trigger_icon, |button, glyph| button.icon(glyph))
.on_click(move |window, cx| {
popover
.update(cx, |popover, cx| popover.toggle(window, cx))
.ok();
})
.into_any_element();
let overlay = self.open.then(|| {
if self.pending_focus {
self.pending_focus = false;
self.focus_handle.focus(window, cx);
}
let body = self.content.clone().map(|content| content(window, cx));
let mut card = surface(&theme, Elevation::Overlay)
.p_token(&theme, Space::Sm)
.track_focus(&self.focus_handle);
if self.dismissable {
card = card
.on_key_down(cx.listener(Self::on_dismiss_key))
.on_mouse_down_out(cx.listener(|popover, _, window, cx| {
popover.dismiss(window, cx);
}));
}
let card = card.children(body).semantic_in(
cx,
NodeSpec::new(self.ident.child("surface").semantic_id(), Role::Group)
.parent(self.ident.semantic_id())
.focus(&self.focus_handle),
);
Overlay::new(self.ident.child("overlay"))
.placement(self.placement)
.child(card)
.into_any_element()
});
anchored_slot(self.placement, trigger, overlay).semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::Group).expanded(self.open),
)
}
}
pub fn tracked_upper(label: &str) -> String {
let mut output = String::with_capacity(label.len() * 2);
for (index, character) in label.to_uppercase().chars().enumerate() {
if index > 0 {
output.push('\u{200A}');
}
output.push(character);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navigation_wraps_and_handles_empty_lists() {
assert_eq!(step(None, 0, 1), None);
assert_eq!(step(None, 3, 1), Some(0));
assert_eq!(step(None, 3, -1), Some(2));
assert_eq!(step(Some(2), 3, 1), Some(0));
assert_eq!(step(Some(0), 3, -1), Some(2));
}
#[test]
fn filtering_prefers_prefixes_and_is_stable() {
let labels = ["main", "feature/main-sync", "master", "dev"];
assert_eq!(filter_indices("ma", &labels), vec![0, 2, 1]);
assert_eq!(filter_indices("", &labels), vec![0, 1, 2, 3]);
}
#[test]
fn key_classification_keeps_modified_enter_distinct() {
assert_eq!(classify_key("enter", false, false), MenuKey::Enter);
assert_eq!(classify_key("enter", true, false), MenuKey::ModifiedEnter);
assert_eq!(classify_key("escape", false, false), MenuKey::Escape);
}
#[test]
fn a_submenu_is_entered_and_left_sideways() {
assert_eq!(classify_key("right", false, false), MenuKey::Right);
assert_eq!(classify_key("left", false, false), MenuKey::Left);
}
#[test]
fn ranking_prefers_a_prefix_then_a_word_then_a_subsequence() {
assert_eq!(match_rank("com", "Command palette"), Some(0));
assert_eq!(match_rank("pal", "Command palette"), Some(1));
assert_eq!(match_rank("mmand", "Command palette"), Some(2));
assert_eq!(match_rank("cmp", "Command palette"), Some(3));
assert_eq!(match_rank("zz", "Command palette"), None);
}
#[test]
fn filtering_orders_literal_matches_ahead_of_a_subsequence() {
let labels = ["Set theme", "Reset zoom", "Show settings", "Save file"];
assert_eq!(filter_indices("se", &labels), vec![0, 2, 1, 3]);
}
#[test]
fn type_ahead_wraps_and_skips_entries_it_cannot_land_on() {
let labels = [
Some("Copy"),
None,
Some("Cut"),
Some("Paste"),
Some("Copy path"),
];
assert_eq!(jump_to(&labels, None, 'c'), Some(0));
assert_eq!(jump_to(&labels, Some(0), 'c'), Some(2));
assert_eq!(jump_to(&labels, Some(2), 'c'), Some(4));
assert_eq!(jump_to(&labels, Some(4), 'c'), Some(0));
assert_eq!(jump_to(&labels, None, 'z'), None);
}
#[test]
fn only_an_unmodified_letter_is_type_ahead() {
let none = gpui::Modifiers::none();
assert_eq!(typed_letter("s", none), Some('s'));
assert_eq!(typed_letter("escape", none), None);
assert_eq!(typed_letter("s", gpui::Modifiers::command()), None);
}
}