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:
14 files changed
+1683
-12
No files matched your search
+1
-1
@@ -88,7 +88,7 @@ pub use structure::{
|
||||
};
|
||||
pub use switch::Switch;
|
||||
pub use tags::TagInput;
|
||||
pub(crate) use text::{Enter, bind_keys};
|
||||
pub(crate) use text::{Down, Enter, Up, bind_keys};
|
||||
pub use text::{Highlight, InputEvent, TextInput};
|
||||
pub use transfer::TransferList;
|
||||
pub use unit::UnitInput;
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -228,6 +228,11 @@ impl Theme {
|
||||
px_to_rems(480.0)
|
||||
}
|
||||
|
||||
/// A palette's card; its list scrolls inside.
|
||||
pub fn palette_size(&self) -> Size<Rems> {
|
||||
size(px_to_rems(600.0), px_to_rems(400.0))
|
||||
}
|
||||
|
||||
/// Grab bar on a drawer.
|
||||
pub fn grip(&self) -> Size<Rems> {
|
||||
size(px_to_rems(36.0), px_to_rems(4.0))
|
||||
|
||||
Reference in new issue
Block a user