T07c navigation palettes: command palette, quick open, quick switcher, search palette and quick launcher over one palette engine with fuzzy matching; tour lands with overlays

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 12:31:20 -04:00
1 parent c02dd7d1c3
commit a923def708
14 files changed
+1683 -12

No files matched your search

+4
View File
@@ -6,6 +6,7 @@ mod menu;
mod nav;
mod overflow;
mod pages;
mod palette;
mod steps;
mod tabs;
#[cfg(all(test, feature = "test-support"))]
@@ -20,6 +21,9 @@ pub use menu::NavigationMenu;
pub use nav::{NavGroup, NavItem};
pub use overflow::TabOverflowMenu;
pub use pages::Pagination;
pub use palette::{
Command, CommandPalette, QuickLauncher, QuickOpen, QuickSwitcher, SearchPalette,
};
pub use steps::{Steps, Wizard};
pub use tabs::{TabPlacement, Tabs};
pub use toc::{Anchor, Sections, TableOfContents};
+475
View File
@@ -0,0 +1,475 @@
use std::{iter, ops::Range, rc::Rc};
use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window};
use super::{Fit, Group, Palette, Row, fuzzy, marked, query_field};
use crate::{
forms::{Choice, OnValue, Run},
primitives::IconName,
typography::{Highlight, KbdCombo},
};
/// Items that fit `query`, best first; ties go to the shorter text, then the given order.
fn ranked<T>(
items: impl IntoIterator<Item = T>,
query: &str,
text: impl Fn(&T) -> &str,
) -> Vec<(T, Fit)> {
let mut fits: Vec<(T, Fit)> = items
.into_iter()
.filter_map(|item| fuzzy(query, text(&item)).map(|fit| (item, fit)))
.collect();
fits.sort_by(|(a, fa), (b, fb)| {
fb.score
.cmp(&fa.score)
.then(text(a).len().cmp(&text(b).len()))
});
fits
}
/// A command a palette runs.
#[derive(Clone, Debug)]
pub struct Command {
pub value: SharedString,
pub label: SharedString,
pub icon: Option<IconName>,
pub keys: Option<SharedString>,
}
impl Command {
pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
value: value.into(),
label: label.into(),
icon: None,
keys: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// Its chord in gpui key syntax, such as `"secondary-shift-p"`.
pub fn keys(mut self, keys: impl Into<SharedString>) -> Self {
self.keys = Some(keys.into());
self
}
fn row(&self, hits: Vec<Range<usize>>, cx: &App) -> Row {
Row {
value: self.value.clone(),
icon: self.icon,
label: marked(self.label.clone(), hits, cx),
detail: None,
end: self
.keys
.as_ref()
.map(|keys| KbdCombo::new(keys).into_any_element()),
}
}
}
/// Commands in groups. Recent ones lead while the query is empty; a query ranks fuzzy matches and marks the letters that matched.
#[derive(IntoElement)]
pub struct CommandPalette {
id: ElementId,
groups: Vec<(SharedString, Vec<Command>)>,
recent: Vec<SharedString>,
on_run: Option<OnValue>,
on_close: Run,
}
impl CommandPalette {
/// Render it while open; `on_close` runs on Escape, a click outside, or a run.
pub fn new(
id: impl Into<ElementId>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
groups: Vec::new(),
recent: Vec::new(),
on_run: None,
on_close: Rc::new(on_close),
}
}
pub fn group(
mut self,
title: impl Into<SharedString>,
commands: impl IntoIterator<Item = Command>,
) -> Self {
self.groups
.push((title.into(), commands.into_iter().collect()));
self
}
/// Values of recent commands, newest first.
pub fn recent(mut self, values: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.recent = values.into_iter().map(Into::into).collect();
self
}
pub fn on_run(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_run = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CommandPalette {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = query_field(&self.id, "Type a command", window, cx);
let query = input.read(cx).text().trim().to_string();
let groups = if query.is_empty() {
let find = |value: &SharedString| {
self.groups
.iter()
.flat_map(|(_, commands)| commands)
.find(|command| command.value == *value)
.unwrap_or_else(|| panic!("recent command {value} is not in the palette"))
};
let recent = Group {
title: Some("Recent".into()),
rows: self
.recent
.iter()
.map(|value| find(value).row(Vec::new(), cx))
.collect(),
};
iter::once(recent)
.chain(self.groups.iter().map(|(title, commands)| {
Group {
title: Some(title.clone()),
rows: commands
.iter()
.filter(|command| !self.recent.contains(&command.value))
.map(|command| command.row(Vec::new(), cx))
.collect(),
}
}))
.filter(|group| !group.rows.is_empty())
.collect()
} else {
let mut found: Vec<(i32, Group)> = self
.groups
.iter()
.filter_map(|(title, commands)| {
let fits = ranked(commands.iter(), &query, |command| &command.label[..]);
let best = fits.first()?.1.score;
let rows = fits
.into_iter()
.map(|(command, fit)| command.row(fit.hits, cx))
.collect();
Some((
best,
Group {
title: Some(title.clone()),
rows,
},
))
})
.collect();
found.sort_by(|(a, _), (b, _)| b.cmp(a));
found.into_iter().map(|(_, group)| group).collect()
};
Palette {
id: self.id,
input,
groups,
start: 0,
empty: "No matching commands".into(),
on_pick: self.on_run,
on_close: self.on_close,
}
.overlay(window, cx)
}
}
// ponytail: the best 50; a longer query reaches the rest.
const SHOWN: usize = 50;
/// Files by path. A query ranks fuzzy matches over the whole path; recent files lead while it is empty.
#[derive(IntoElement)]
pub struct QuickOpen {
id: ElementId,
paths: Vec<SharedString>,
recent: Vec<SharedString>,
on_open: Option<OnValue>,
on_close: Run,
}
impl QuickOpen {
pub fn new(
id: impl Into<ElementId>,
paths: impl IntoIterator<Item = impl Into<SharedString>>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
paths: paths.into_iter().map(Into::into).collect(),
recent: Vec::new(),
on_open: None,
on_close: Rc::new(on_close),
}
}
/// Recent paths, newest first.
pub fn recent(mut self, paths: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.recent = paths.into_iter().map(Into::into).collect();
self
}
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
/// A file row: its name, then its folder, each with the hits that fall in it.
fn file_row(path: &SharedString, hits: &[Range<usize>], cx: &App) -> Row {
let name_at = path.rfind('/').map_or(0, |slash| slash + 1);
let within = |from: usize, to: usize| -> Vec<Range<usize>> {
hits.iter()
.map(|hit| hit.start.max(from)..hit.end.min(to))
.filter(|hit| hit.start < hit.end)
.map(|hit| hit.start - from..hit.end - from)
.collect()
};
let folder = &path[..name_at.saturating_sub(1)];
Row {
value: path.clone(),
icon: Some(IconName::File),
label: marked(
path[name_at..].to_string().into(),
within(name_at, path.len()),
cx,
),
detail: (!folder.is_empty())
.then(|| marked(folder.to_string().into(), within(0, folder.len()), cx)),
end: None,
}
}
impl RenderOnce for QuickOpen {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = query_field(&self.id, "Go to file", window, cx);
let query = input.read(cx).text().trim().to_string();
let (group, empty) = if query.is_empty() {
let rows = self
.recent
.iter()
.map(|path| file_row(path, &[], cx))
.collect();
(
Group {
title: Some("Recent".into()),
rows,
},
"Type part of a file name",
)
} else {
let rows = ranked(self.paths.iter(), &query, |path| &path[..])
.into_iter()
.take(SHOWN)
.map(|(path, fit)| file_row(path, &fit.hits, cx))
.collect();
(Group { title: None, rows }, "No matching files")
};
Palette {
id: self.id,
input,
groups: vec![group],
start: 0,
empty: empty.into(),
on_pick: self.on_open,
on_close: self.on_close,
}
.overlay(window, cx)
}
}
/// Documents, sessions and projects, most recent first. It opens on the one before the current, so Enter goes back.
#[derive(IntoElement)]
pub struct QuickSwitcher {
id: ElementId,
items: Vec<Choice>,
on_switch: Option<OnValue>,
on_close: Run,
}
impl QuickSwitcher {
/// Each item's note says what it is.
pub fn new(
id: impl Into<ElementId>,
items: impl IntoIterator<Item = Choice>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
let items: Vec<Choice> = items.into_iter().collect();
assert!(
items.iter().all(|item| !item.disabled),
"a switcher cannot hold disabled items"
);
Self {
id: id.into(),
items,
on_switch: None,
on_close: Rc::new(on_close),
}
}
pub fn on_switch(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_switch = Some(Rc::new(handler));
self
}
}
impl RenderOnce for QuickSwitcher {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = query_field(&self.id, "Switch to", window, cx);
let query = input.read(cx).text().trim().to_string();
let row = |item: &Choice, hits: Vec<Range<usize>>| Row {
value: item.value.clone(),
icon: item.icon,
label: marked(item.label.clone(), hits, cx),
detail: None,
end: item.note.clone().map(|note| note.into_any_element()),
};
let (rows, start) = if query.is_empty() {
(
self.items
.iter()
.map(|item| row(item, Vec::new()))
.collect(),
1,
)
} else {
let fits = ranked(self.items.iter(), &query, |item| &item.label[..]);
(
fits.into_iter()
.map(|(item, fit)| row(item, fit.hits))
.collect(),
0,
)
};
Palette {
id: self.id,
input,
groups: vec![Group { title: None, rows }],
start,
empty: "Nothing open by that name".into(),
on_pick: self.on_switch,
on_close: self.on_close,
}
.overlay(window, cx)
}
}
pub(crate) type Search = Rc<dyn Fn(&str) -> Vec<(SharedString, Vec<Choice>)>>;
/// Searches through the owner's function, which answers a query with results grouped by where they were found. Matches are marked.
#[derive(IntoElement)]
pub struct SearchPalette {
id: ElementId,
search: Search,
on_open: Option<OnValue>,
on_close: Run,
}
impl SearchPalette {
/// `search` answers the empty query too, with what shows first. Results cannot be disabled.
pub fn new(
id: impl Into<ElementId>,
search: impl Fn(&str) -> Vec<(SharedString, Vec<Choice>)> + 'static,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
search: Rc::new(search),
on_open: None,
on_close: Rc::new(on_close),
}
}
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
pub(crate) fn from_parts(
id: ElementId,
search: Search,
on_open: Option<OnValue>,
on_close: Run,
) -> Self {
Self {
id,
search,
on_open,
on_close,
}
}
pub(crate) fn palette(self, window: &mut Window, cx: &mut App) -> Palette {
let input = query_field(&self.id, "Search everything", window, cx);
let query = input.read(cx).text().trim().to_string();
let groups = (self.search)(&query)
.into_iter()
.filter(|(_, results)| !results.is_empty())
.map(|(title, results)| Group {
title: Some(title),
rows: results
.into_iter()
.inspect(|result| {
assert!(
!result.disabled,
"search result {} is disabled",
result.value
);
})
.map(|result| Row {
value: result.value,
icon: result.icon,
label: Highlight::matching(result.label, &query).into_any_element(),
detail: result
.note
.map(|note| Highlight::matching(note, &query).into_any_element()),
end: None,
})
.collect(),
})
.collect();
Palette {
id: self.id,
input,
groups,
start: 0,
empty: if query.is_empty() {
"Type to search".into()
} else {
format!("Nothing found for “{query}”").into()
},
on_pick: self.on_open,
on_close: self.on_close,
}
}
}
impl RenderOnce for SearchPalette {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
self.palette(window, cx).overlay(window, cx)
}
}
+91
View File
@@ -0,0 +1,91 @@
use std::rc::Rc;
use anyhow::Context as _;
use gpui::{
App, AppContext, Bounds, Context, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window, WindowBackgroundAppearance, WindowBounds, WindowHandle, WindowKind,
WindowOptions, div, point, size,
};
use super::kinds::{Search, SearchPalette};
use crate::{
forms::{Choice, OnValue},
theme::ActiveTheme,
};
/// A search palette alone in a window above all others, as Spotlight is. A pick, Escape or a click elsewhere closes it.
pub struct QuickLauncher {
search: Search,
on_open: OnValue,
focused: bool,
_away: Subscription,
}
impl QuickLauncher {
/// Opens centered across the main display, a fifth of the way down.
pub fn open(
search: impl Fn(&str) -> Vec<(SharedString, Vec<Choice>)> + 'static,
on_open: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
cx: &mut App,
) -> anyhow::Result<WindowHandle<QuickLauncher>> {
let theme = cx.theme();
let (card, rem) = (theme.palette_size(), theme.base_rem());
let card = size(card.width.to_pixels(rem), card.height.to_pixels(rem));
let area = cx
.primary_display()
.context("a launcher needs a display")?
.bounds();
let origin = point(
area.center().x - card.width / 2.0,
area.top() + area.size.height / 5.0,
);
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::new(origin, card))),
titlebar: None,
kind: WindowKind::PopUp,
is_resizable: false,
is_minimizable: false,
window_background: WindowBackgroundAppearance::Transparent,
..Default::default()
};
let (search, on_open): (Search, OnValue) = (Rc::new(search), Rc::new(on_open));
let handle = cx.open_window(options, |window, cx| {
cx.new(|cx: &mut Context<QuickLauncher>| {
let away = cx.observe_window_activation(window, |_, window, _| {
if !window.is_window_active() {
log::info!("quick launcher: closed on leaving");
window.remove_window();
}
});
QuickLauncher {
search,
on_open,
focused: false,
_away: away,
}
})
})?;
log::info!("quick launcher: opened");
Ok(handle)
}
}
impl Render for QuickLauncher {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let palette = SearchPalette::from_parts(
"quick-launcher".into(),
self.search.clone(),
Some(self.on_open.clone()),
Rc::new(|window, _| window.remove_window()),
)
.palette(window, cx);
if !self.focused {
self.focused = true;
window.focus(&palette.input.read(cx).focus().clone());
}
div()
.size_full()
.font_family(cx.theme().font_family.clone())
.child(palette.card(window, cx))
}
}
+390
View File
@@ -0,0 +1,390 @@
mod kinds;
mod launcher;
#[cfg(all(test, feature = "test-support"))]
mod tests;
use std::{ops::Range, rc::Rc};
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, Entity, FontWeight, HighlightStyle,
InteractiveElement, IntoElement, MouseButton, ParentElement, Point, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*,
};
use crate::{
forms::{Down, Enter, OnValue, Pick, Run, TextInput, Up},
motion,
primitives::{Backdrop, FocusScope, Icon, IconName, give_back, take_focus},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
};
pub use kinds::{Command, CommandPalette, QuickOpen, QuickSwitcher, SearchPalette};
pub use launcher::QuickLauncher;
/// How well a query fits a text, and the byte ranges it covers.
#[derive(Debug, PartialEq)]
pub(crate) struct Fit {
pub score: i32,
pub hits: Vec<Range<usize>>,
}
fn fold(c: char) -> char {
c.to_lowercase()
.next()
.expect("a char lowercases to at least one char")
}
/// The query's characters in order within `text`, ignoring case and spaces. Word starts and runs score higher, gaps lower.
pub(crate) fn fuzzy(query: &str, text: &str) -> Option<Fit> {
let query: Vec<char> = query
.chars()
.filter(|c| !c.is_whitespace())
.map(fold)
.collect();
let chars: Vec<(usize, char)> = text.char_indices().collect();
let mut next = 0;
let end = match query.len() {
0 => {
return Some(Fit {
score: 0,
hits: Vec::new(),
});
}
len => chars.iter().position(|&(_, c)| {
next += usize::from(fold(c) == query[next]);
next == len
})?,
};
// ponytail: the tightest window at the first full match, not the best; score every window if rankings disappoint.
let mut at = Vec::with_capacity(query.len());
for ix in (0..=end).rev() {
if at.len() < query.len() && fold(chars[ix].1) == query[query.len() - 1 - at.len()] {
at.push(ix);
}
}
at.reverse();
let starts_word = |ix: usize| {
ix == 0 || {
let (before, here) = (chars[ix - 1].1, chars[ix].1);
!before.is_alphanumeric() || (before.is_lowercase() && here.is_uppercase())
}
};
let mut score = 0;
let mut hits: Vec<Range<usize>> = Vec::new();
for (k, &ix) in at.iter().enumerate() {
let bonus = if starts_word(ix) { 8 } else { 0 };
score += 16 + if k == 0 { bonus * 2 } else { bonus };
if k > 0 {
let gap = (ix - at[k - 1] - 1) as i32;
score += if gap == 0 { 4 } else { -2 - gap };
}
let (from, c) = chars[ix];
match hits.last_mut() {
Some(last) if last.end == from => last.end = from + c.len_utf8(),
_ => hits.push(from..from + c.len_utf8()),
}
}
Some(Fit { score, hits })
}
/// `text` with the letters a query matched drawn in the accent color.
pub(crate) fn marked(text: SharedString, hits: Vec<Range<usize>>, cx: &App) -> AnyElement {
let mark = HighlightStyle {
color: Some(cx.theme().colors.accent),
font_weight: Some(FontWeight::SEMIBOLD),
..Default::default()
};
StyledText::new(text)
.with_highlights(hits.into_iter().map(|hit| (hit, mark)))
.into_any_element()
}
/// One result: the value a pick hands back, then what the row shows.
pub(crate) struct Row {
pub value: SharedString,
pub icon: Option<IconName>,
pub label: AnyElement,
pub detail: Option<AnyElement>,
pub end: Option<AnyElement>,
}
/// Rows under an optional heading.
pub(crate) struct Group {
pub title: Option<SharedString>,
pub rows: Vec<Row>,
}
/// The row under the keyboard, the query it was set for, and the list's scroll.
#[derive(Default)]
struct Cursor {
at: usize,
query: Option<String>,
scroll: ScrollHandle,
}
/// The query field kept for palette `id`.
pub(crate) fn query_field(
id: &ElementId,
placeholder: &'static str,
window: &mut Window,
cx: &mut App,
) -> Entity<TextInput> {
window.use_keyed_state((id.clone(), "query"), cx, |window, cx| {
TextInput::new(window, cx).placeholder(placeholder)
})
}
/// A query field over grouped rows. Arrows move, Enter or a click picks, Escape closes.
pub(crate) struct Palette {
pub id: ElementId,
pub input: Entity<TextInput>,
pub groups: Vec<Group>,
/// The row marked whenever the query changes.
pub start: usize,
/// Said when no row fits.
pub empty: SharedString,
pub on_pick: Option<OnValue>,
pub on_close: Run,
}
impl Palette {
/// Over a scrim; a click on it closes. Tab stays inside, and focus returns on every close.
pub(crate) fn overlay(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
let scope = takeover.read(cx).focus.clone();
if scope.is_focused(window) {
window.focus(&self.input.read(cx).focus().clone());
}
let close = self.on_close.clone();
let close: Run = Rc::new(move |window, cx| {
give_back(&takeover, window, cx);
close(window, cx)
});
self.on_close = close.clone();
Backdrop::new((self.id.clone(), "scrim"))
.on_dismiss(move |window, cx| close(window, cx))
.child(FocusScope::new(&scope).trap().child(self.card(window, cx)))
}
/// The card alone, as in a window of its own.
pub(crate) fn card(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let cursor =
window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| Cursor::default());
let values: Vec<SharedString> = self
.groups
.iter()
.flat_map(|group| group.rows.iter().map(|row| row.value.clone()))
.collect();
let count = values.len();
let text = self.input.read(cx).text().to_string();
if cursor.read(cx).query.as_deref() != Some(text.as_str()) {
let start = self.start.min(count.saturating_sub(1));
cursor.update(cx, |cursor, _| {
cursor.at = start;
cursor.query = Some(text);
cursor.scroll.set_offset(Point::default());
});
}
let at = cursor.read(cx).at.min(count.saturating_sub(1));
let scroll = cursor.read(cx).scroll.clone();
let (mut slots, mut heads) = (Vec::with_capacity(count), Vec::new());
let mut slot = 0;
for group in &self.groups {
if group.title.is_some() {
slot += 1;
heads.push(slots.len());
}
slots.extend(slot..slot + group.rows.len());
slot += group.rows.len();
}
let slots = Rc::new((slots, heads));
let go = {
let (cursor, slots) = (cursor.clone(), slots.clone());
move |by: isize, cx: &mut App| {
if count == 0 {
return;
}
cursor.update(cx, |cursor, cx| {
let at = cursor.at.min(count - 1) as isize;
cursor.at = (at + by).rem_euclid(count as isize) as usize;
let (slots, heads) = &*slots;
let head = heads.contains(&cursor.at) && (by < 0 || cursor.at == 0);
cursor
.scroll
.scroll_to_item(slots[cursor.at] - usize::from(head));
cx.notify();
})
}
};
let pick: Pick = {
let (id, on_pick, close) = (self.id.clone(), self.on_pick, self.on_close.clone());
Rc::new(move |ix, window, cx| {
let value = &values[ix];
log::info!("palette {id:?}: picked {value}");
close(window, cx);
if let Some(on_pick) = &on_pick {
on_pick(value, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let mut children: Vec<AnyElement> = Vec::with_capacity(slot);
let mut ix = 0;
for group in self.groups {
if let Some(title) = group.title {
children.push(
div()
.px_2()
.pt_3()
.pb_1()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_subtle)
.child(title)
.into_any_element(),
);
}
for row in group.rows {
let (here, pick, hover) = (ix, pick.clone(), cursor.clone());
children.push(
div()
.id(("row", ix))
.flex()
.flex_none()
.items_center()
.gap_3()
.h(theme.control_height(ControlSize::Lg))
.px_2()
.rounded(theme.radius(Radius::Md))
.when(ix == at, |row| row.bg(colors.hover))
.cursor_pointer()
.on_mouse_move(move |_, _, cx| {
if hover.read(cx).at != here {
hover.update(cx, |cursor, cx| {
cursor.at = here;
cx.notify();
});
}
})
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
pick(here, window, cx);
})
.when_some(row.icon, |line, icon| {
line.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
})
.child(
div()
.flex()
.flex_1()
.min_w_0()
.items_center()
.gap_2()
.overflow_hidden()
.whitespace_nowrap()
.child(div().flex_none().child(row.label))
.when_some(row.detail, |line, detail| {
line.child(
div()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.text_color(colors.fg_subtle)
.child(detail),
)
}),
)
.when_some(row.end, |line, end| {
line.child(div().flex_none().text_color(colors.fg_subtle).child(end))
})
.into_any_element(),
);
ix += 1;
}
}
let (up, down, escape) = (go.clone(), go, self.on_close);
let (enter, chosen) = (pick, cursor.clone());
div()
.id(self.id.clone())
.flex()
.flex_col()
.w(theme.palette_size().width)
.h(theme.palette_size().height)
.rounded(theme.radius(Radius::Xl))
.bg(colors.overlay)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Modal))
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.capture_action(move |_: &Up, _, cx| {
cx.stop_propagation();
up(-1, cx);
})
.capture_action(move |_: &Down, _, cx| {
cx.stop_propagation();
down(1, cx);
})
.capture_action(move |_: &Enter, window, cx| {
cx.stop_propagation();
if count > 0 {
enter(chosen.read(cx).at.min(count - 1), window, cx);
}
})
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
escape(window, cx);
}
})
.child(
div()
.flex()
.flex_none()
.items_center()
.gap_3()
.px_4()
.py_3()
.border_b_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Md))
.child(
Icon::new(IconName::Search)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
.child(div().flex_1().min_w_0().child(self.input.clone())),
)
.child(
div()
.id((self.id.clone(), "list"))
.flex()
.flex_col()
.flex_1()
.min_h_0()
.p_2()
.overflow_y_scroll()
.track_scroll(&scroll)
.children(children)
.when(count == 0, |list| {
list.child(
div()
.flex()
.flex_1()
.items_center()
.justify_center()
.text_color(colors.fg_subtle)
.child(self.empty),
)
}),
)
.with_animation(
(self.id, "in"),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
|card, t| card.opacity(t).mt(motion::NUDGE * (1.0 - t)),
)
}
}
+241
View File
@@ -0,0 +1,241 @@
use std::ops::Range;
use gpui::{
Context, FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement, Render,
SharedString, TestAppContext, VisualTestContext, Window, div, prelude::*,
};
use super::{Command, CommandPalette, Fit, QuickOpen, QuickSwitcher, SearchPalette, fuzzy};
use crate::{
forms::Choice,
primitives::{FocusNext, FocusScope},
theme::Theme,
};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
}
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
#[test]
fn fuzzy_finds_letters_in_order_and_ranks_word_starts() {
let found = fuzzy("tsb", "Toggle sidebar").expect("a fit");
assert_eq!(found.hits, [0..1, 7..8, 11..12]);
assert_eq!(
fuzzy("", "anything"),
Some(Fit {
score: 0,
hits: Vec::new()
})
);
assert!(fuzzy("xyz", "Toggle").is_none());
let score = |query, text| fuzzy(query, text).expect("a fit").score;
assert!(score("os", "Open settings") > score("os", "Close tab"));
assert!(score("set", "Settings") > score("set", "Reset"));
assert_eq!(
fuzzy("ÉL", "Élan").expect("a fit").hits,
[Range { start: 0, end: 3 }]
);
assert_eq!(
fuzzy("gtl", "GoToLine").expect("a fit").hits,
[0..1, 2..3, 4..5]
);
}
/// Opens one palette at a time and records what it handed back.
struct Host {
root: FocusHandle,
outside: FocusHandle,
open: bool,
got: Option<SharedString>,
kind: Kind,
}
#[derive(Clone, Copy)]
enum Kind {
Commands,
Files,
Switcher,
Search,
Disabled,
}
fn results(query: &str) -> Vec<(SharedString, Vec<Choice>)> {
let all = [
Choice::new("tokens", "Theme and tokens").note("Colors come from the theme"),
Choice::new("motion", "Motion").note("Springs stay in animators"),
];
let hits = all
.into_iter()
.filter(|found| query.is_empty() || found.label.to_lowercase().contains(query))
.collect();
vec![("Guides".into(), hits)]
}
impl Render for Host {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (view, close) = (cx.entity(), cx.entity());
let close = move |_: &mut Window, cx: &mut gpui::App| {
close.update(cx, |host, cx| {
host.open = false;
cx.notify();
})
};
let got = move |value: &SharedString, _: &mut Window, cx: &mut gpui::App| {
let value = value.clone();
view.update(cx, |host, _| host.got = Some(value));
};
let palette = match self.kind {
Kind::Commands => CommandPalette::new("commands", close)
.group(
"View",
[
Command::new("sidebar", "Toggle sidebar").keys("secondary-b"),
Command::new("zoom", "Zoom in"),
],
)
.group(
"Theme",
[
Command::new("dark", "Dark theme"),
Command::new("light", "Light theme"),
],
)
.recent(["dark"])
.on_run(got)
.into_any_element(),
Kind::Files => QuickOpen::new(
"files",
["src/lib.rs", "src/navigation/menu.rs", "src/menus/mod.rs"],
close,
)
.on_open(got)
.into_any_element(),
Kind::Switcher => QuickSwitcher::new(
"switcher",
["now", "before", "older"].map(|place| Choice::new(place, place)),
close,
)
.on_switch(got)
.into_any_element(),
Kind::Search => SearchPalette::new("search", results, close)
.on_open(got)
.into_any_element(),
Kind::Disabled => SearchPalette::new(
"search",
|_| vec![("Guides".into(), vec![Choice::new("old", "Old").disabled()])],
close,
)
.into_any_element(),
};
FocusScope::new(&self.root)
.size_full()
.child(div().id("outside").track_focus(&self.outside))
.when(self.open, |host| host.child(palette))
}
}
fn host(kind: Kind, cx: &mut TestAppContext) -> (gpui::Entity<Host>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(move |_, cx| Host {
root: cx.focus_handle(),
outside: cx.focus_handle().tab_stop(true),
open: true,
got: None,
kind,
});
settle(cx);
(view, cx)
}
fn got(view: &gpui::Entity<Host>, cx: &mut VisualTestContext) -> (bool, Option<SharedString>) {
view.read_with(cx, |host, _| (host.open, host.got.clone()))
}
fn reopen(view: &gpui::Entity<Host>, cx: &mut VisualTestContext) {
settle(cx);
view.update(cx, |host, cx| {
host.open = true;
cx.notify();
});
settle(cx);
}
#[gpui::test]
fn the_command_palette_leads_with_recent_and_runs_the_best_fit(cx: &mut TestAppContext) {
let (view, cx) = host(Kind::Commands, cx);
cx.simulate_keystrokes("enter");
assert_eq!(got(&view, cx), (false, Some("dark".into())));
reopen(&view, cx);
cx.simulate_input("zi");
settle(cx);
cx.simulate_keystrokes("enter");
assert_eq!(got(&view, cx), (false, Some("zoom".into())));
reopen(&view, cx);
cx.simulate_keystrokes("up enter");
assert_eq!(got(&view, cx), (false, Some("light".into())));
reopen(&view, cx);
cx.simulate_keystrokes("escape");
assert_eq!(got(&view, cx), (false, Some("light".into())));
}
#[gpui::test]
fn quick_open_ranks_the_whole_path(cx: &mut TestAppContext) {
let (view, cx) = host(Kind::Files, cx);
cx.simulate_input("navmenu");
settle(cx);
cx.simulate_keystrokes("enter");
assert_eq!(
got(&view, cx),
(false, Some("src/navigation/menu.rs".into()))
);
}
#[gpui::test]
fn the_switcher_opens_on_the_one_before(cx: &mut TestAppContext) {
let (view, cx) = host(Kind::Switcher, cx);
cx.simulate_keystrokes("enter");
assert_eq!(got(&view, cx), (false, Some("before".into())));
reopen(&view, cx);
cx.simulate_input("old");
settle(cx);
cx.simulate_keystrokes("enter");
assert_eq!(got(&view, cx), (false, Some("older".into())));
}
#[gpui::test]
fn search_walks_the_owners_results(cx: &mut TestAppContext) {
let (view, cx) = host(Kind::Search, cx);
cx.simulate_keystrokes("down enter");
assert_eq!(got(&view, cx), (false, Some("motion".into())));
reopen(&view, cx);
cx.simulate_input("theme");
settle(cx);
cx.simulate_keystrokes("down enter");
assert_eq!(got(&view, cx), (false, Some("tokens".into())));
}
#[gpui::test]
fn tab_stays_inside_an_open_palette(cx: &mut TestAppContext) {
let (view, cx) = host(Kind::Commands, cx);
cx.simulate_keystrokes("tab");
cx.simulate_input("zi");
settle(cx);
cx.simulate_keystrokes("enter");
assert_eq!(got(&view, cx), (false, Some("zoom".into())));
}
#[gpui::test]
#[should_panic(expected = "disabled")]
fn search_refuses_disabled_results(cx: &mut TestAppContext) {
host(Kind::Disabled, cx);
}