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

+2
View File
@@ -57,6 +57,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A tracked `FocusHandle` is a Tab stop only when built with `.tab_stop(true)`. `tab_index` on the div reaches only handles gpui makes itself. `primitives::tab_stop` keeps one per id.
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger.
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike.
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
- A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`.
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`).
- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint.
@@ -68,6 +69,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `ScrollHandle::scroll_to_item` runs in the container's prepaint before its bounds are stored, so a first-frame call misfires. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). Never poll layout with `request_animation_frame`.
- The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots.
- An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`.
- Keyed state lives while its element renders in consecutive frames. An overlay rendered only while open starts fresh each time.
- Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::glyphs`).
- Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones.
- Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state.
+3 -1
View File
@@ -31,7 +31,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T06g Forms · Structure
- [x] T07a Navigation · Tabs & paths
- [x] T07b Navigation · Places
- [ ] T07c Navigation · Palettes & tour
- [x] T07c Navigation · Palettes & tour
- [ ] T08 Menus
- [ ] T09 Overlays
- [ ] T10 Feedback
@@ -143,3 +143,5 @@ Per-component lines live in `tasks/`. Tags there:
| T07a | 3 | FAIL | AGENTS.md said the test platform gives text no width; it gives each character a fixed advance (fixed after the cap) |
| T07b | 1 | FAIL | an open navigation menu kept entry and link indexes past lists that shrank, and disabled links still fired by click or Enter |
| T07b | 2 | PASS | |
| T07c | 1 | FAIL | SearchPalette let a disabled result run on Enter, and the palette tests compiled without test-support |
| T07c | 2 | PASS | |
+38
View File
@@ -1,3 +1,4 @@
mod palettes;
mod paths;
mod places;
mod tabs;
@@ -78,6 +79,39 @@ const SCRIPT: &[Step] = &[
Step::Wait(200),
Step::Shot("goto"),
Step::Key("enter"),
Step::Click("commands"),
Step::Wait(500),
Step::Shot("command-palette"),
Step::Type("tog"),
Step::Wait(300),
Step::Shot("command-fuzzy"),
Step::Key("escape"),
Step::Click("files"),
Step::Wait(400),
Step::Type("navmenu"),
Step::Wait(300),
Step::Shot("quick-open"),
Step::Key("escape"),
Step::Click("switcher"),
Step::Wait(500),
Step::Shot("quick-switcher"),
Step::Key("enter"),
Step::Click("search"),
Step::Wait(400),
Step::Type("the"),
Step::Wait(300),
Step::Shot("search"),
Step::Key("escape"),
Step::Click("launcher"),
Step::Wait(800),
Step::KeyWindow("launcher", "t"),
Step::KeyWindow("launcher", "o"),
Step::KeyWindow("launcher", "k"),
Step::Wait(300),
Step::ShotWindow("launcher", "launcher"),
Step::KeyWindow("launcher", "escape"),
Step::Wait(300),
Step::ExpectClosed("launcher"),
Step::Rest,
];
@@ -93,5 +127,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(places::history(window, cx))
.child(places::contents(window, cx))
.child(places::go_to_line(window, cx))
.child(palettes::command_palette(window, cx))
.child(palettes::quick_open(window, cx))
.child(palettes::quick_switcher(window, cx))
.child(palettes::search_palette(window, cx))
.into_any_element()
}
@@ -0,0 +1,423 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::Choice,
navigation::{Command, CommandPalette, QuickLauncher, QuickOpen, QuickSwitcher, SearchPalette},
primitives::IconName,
};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Window};
use crate::{
probe::{Opened, probe},
ui::{keep, row, section, set, specimen},
};
/// Moves `value` to the front of a recent list, keeping `size` entries.
fn remember(list: &Entity<Vec<SharedString>>, value: &SharedString, size: usize, cx: &mut App) {
list.update(cx, |list, cx| {
list.retain(|kept| kept != value);
list.insert(0, value.clone());
list.truncate(size);
cx.notify();
});
}
fn commands() -> Vec<(&'static str, Vec<Command>)> {
let command =
|value: &'static str, label: &'static str, icon| Command::new(value, label).icon(icon);
vec![
(
"File",
vec![
command("new", "New file", IconName::FilePlus).keys("secondary-n"),
command("open", "Open…", IconName::FolderOpen).keys("secondary-o"),
command("save", "Save", IconName::Save).keys("secondary-s"),
command("share", "Share…", IconName::Share2),
],
),
(
"View",
vec![
command("sidebar", "Toggle sidebar", IconName::PanelLeft).keys("secondary-b"),
command("terminal", "Toggle terminal", IconName::Terminal).keys("ctrl-`"),
command("zoom-in", "Zoom in", IconName::ZoomIn).keys("secondary-="),
command("zoom-out", "Zoom out", IconName::ZoomOut).keys("secondary--"),
],
),
(
"Theme",
vec![
command("light", "Light theme", IconName::Sun),
command("dark", "Dark theme", IconName::Moon),
command("system", "Match the system", IconName::Monitor),
],
),
(
"Help",
vec![
command("keys", "Keyboard shortcuts", IconName::Keyboard).keys("secondary-/"),
command("bug", "Report a bug", IconName::Bug),
command("new-in", "What's new", IconName::Sparkles),
],
),
]
}
pub fn command_palette(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = keep("commands-open", || false, window, cx);
let recent = keep(
"commands-recent",
|| vec!["sidebar".into(), "dark".into()],
window,
cx,
);
let ran = keep("commands-ran", || None::<SharedString>, window, cx);
let caption = ran
.read(cx)
.clone()
.map_or("apps open it with ⌘K".into(), |value| {
format!("ran {value}")
});
let (opener, closer) = (open.clone(), open.clone());
let palette = (*open.read(cx)).then(|| {
let recents = recent.read(cx).clone();
commands()
.into_iter()
.fold(
CommandPalette::new("commands", move |_, cx| set(&closer, false, cx)),
|palette, (title, commands)| palette.group(title, commands),
)
.recent(recents)
.on_run(move |value, _, cx| {
remember(&recent, value, 3, cx);
set(&ran, Some(value.clone()), cx);
})
});
section(
"CommandPalette",
"Every command in one place. Recent ones lead; typing ranks fuzzy matches and marks the letters that matched.",
cx,
)
.child(specimen(
caption,
probe(
"commands",
Button::new("commands-button", "Command palette")
.icon(IconName::Command)
.on_click(move |_, _, cx| set(&opener, true, cx)),
),
cx,
))
.children(palette)
}
const FILES: [&str; 24] = [
"Cargo.toml",
"README.md",
"AGENTS.md",
"src/lib.rs",
"src/theme/mod.rs",
"src/theme/palette.rs",
"src/theme/tokens.rs",
"src/motion/mod.rs",
"src/motion/slide.rs",
"src/primitives/icon.rs",
"src/primitives/focus.rs",
"src/buttons/button.rs",
"src/buttons/segmented.rs",
"src/forms/select.rs",
"src/forms/combobox.rs",
"src/forms/text/mod.rs",
"src/forms/date/picker.rs",
"src/navigation/tabs.rs",
"src/navigation/menu.rs",
"src/navigation/palette.rs",
"src/navigation/toc.rs",
"examples/gallery/main.rs",
"examples/gallery/pages/navigation/palettes.rs",
"frontend/package.json",
];
pub fn quick_open(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = keep("files-open", || false, window, cx);
let recent = keep(
"files-recent",
|| vec!["src/theme/tokens.rs".into(), "AGENTS.md".into()],
window,
cx,
);
let opened = keep("files-opened", || None::<SharedString>, window, cx);
let caption = opened
.read(cx)
.clone()
.map_or("fuzzy over the whole path".into(), |path| {
format!("opened {path}")
});
let (opener, closer) = (open.clone(), open.clone());
let palette = (*open.read(cx)).then(|| {
QuickOpen::new("files", FILES, move |_, cx| set(&closer, false, cx))
.recent(recent.read(cx).clone())
.on_open(move |path, _, cx| {
remember(&recent, path, 4, cx);
set(&opened, Some(path.clone()), cx);
})
});
section(
"QuickOpen",
"Files by path. The name leads and its folder follows; recent files show before you type.",
cx,
)
.child(specimen(
caption,
probe(
"files",
Button::new("files-button", "Go to file")
.icon(IconName::File)
.on_click(move |_, _, cx| set(&opener, true, cx)),
),
cx,
))
.children(palette)
}
fn places() -> Vec<Choice> {
vec![
Choice::new("roadmap", "Roadmap 2027")
.icon(IconName::FileText)
.note("Document"),
Choice::new("standup", "Design standup")
.icon(IconName::MessageSquare)
.note("Session"),
Choice::new("ely", "Ely GPUI Component")
.icon(IconName::Box)
.note("Project"),
Choice::new("brand", "Brand guidelines")
.icon(IconName::FileText)
.note("Document"),
Choice::new("research", "User research")
.icon(IconName::MessageSquare)
.note("Session"),
Choice::new("site", "ely.dev")
.icon(IconName::Globe)
.note("Project"),
]
}
pub fn quick_switcher(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = keep("switch-open", || false, window, cx);
let order = keep(
"switch-order",
|| {
places()
.into_iter()
.map(|place| place.value)
.collect::<Vec<_>>()
},
window,
cx,
);
let (opener, closer) = (open.clone(), open.clone());
let current = order.read(cx)[0].clone();
let palette = (*open.read(cx)).then(|| {
let all = places();
let items = order.read(cx).iter().map(|value| {
all.iter()
.find(|place| place.value == *value)
.expect("every kept place exists")
.clone()
});
QuickSwitcher::new("switcher", items, move |_, cx| set(&closer, false, cx))
.on_switch(move |value, _, cx| remember(&order, value, 6, cx))
});
section(
"QuickSwitcher",
"Documents, sessions and projects, most recent first. It opens on the one before, so Enter goes back.",
cx,
)
.child(specimen(
format!("in {current}"),
probe(
"switcher",
Button::new("switcher-button", "Switch")
.icon(IconName::Layers)
.on_click(move |_, _, cx| set(&opener, true, cx)),
),
cx,
))
.children(palette)
}
type Found = (
&'static str,
&'static str,
&'static str,
&'static str,
IconName,
);
const FOUND: [Found; 12] = [
(
"Components",
"palette",
"Command palette",
"Navigation · every command in one place",
IconName::Command,
),
(
"Components",
"date",
"Date picker",
"Forms · a calendar under a field",
IconName::Calendar,
),
(
"Components",
"color",
"Color picker",
"Forms · hue, saturation and alpha",
IconName::Pipette,
),
(
"Components",
"tabs",
"Tabs",
"Navigation · a line slides to the chosen tab",
IconName::PanelLeft,
),
(
"Components",
"slider",
"Slider",
"Forms · a thumb on a track, with steps",
IconName::SlidersHorizontal,
),
(
"Guides",
"tokens",
"Theme and tokens",
"Colors, sizes and radii come from the theme",
IconName::Palette,
),
(
"Guides",
"motion",
"Motion",
"Durations honor reduced motion; springs stay in animators",
IconName::Sparkles,
),
(
"Guides",
"focus",
"Focus",
"Tab and Shift-Tab walk every stop in order",
IconName::Keyboard,
),
(
"Guides",
"capture",
"Captures",
"The gallery photographs its own windows",
IconName::Image,
),
(
"People",
"ada",
"Ada Lovelace",
"Design · London",
IconName::User,
),
(
"People",
"grace",
"Grace Hopper",
"Engineering · Arlington",
IconName::User,
),
(
"People",
"alan",
"Alan Kay",
"Research · Los Angeles",
IconName::User,
),
];
/// Case-insensitive search over titles and notes; the empty query suggests a few.
fn search(query: &str) -> Vec<(SharedString, Vec<Choice>)> {
let query = query.to_lowercase();
let result =
|(_, value, label, note, icon): &Found| Choice::new(*value, *label).icon(*icon).note(*note);
if query.is_empty() {
let suggested = FOUND
.iter()
.filter(|found| ["palette", "tokens", "motion", "ada"].contains(&found.1));
return vec![("Suggested".into(), suggested.map(result).collect())];
}
["Components", "Guides", "People"]
.into_iter()
.map(|group| {
let hits = FOUND.iter().filter(|found| {
found.0 == group
&& (found.2.to_lowercase().contains(&query)
|| found.3.to_lowercase().contains(&query))
});
(group.into(), hits.map(result).collect())
})
.collect()
}
pub fn search_palette(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = keep("search-open", || false, window, cx);
let went = keep("search-went", || None::<SharedString>, window, cx);
let caption = went
.read(cx)
.clone()
.map_or("results grouped by where they were found".into(), |value| {
format!("opened {value}")
});
let (opener, closer, launched) = (open.clone(), open.clone(), went.clone());
let palette = (*open.read(cx)).then(|| {
SearchPalette::new("search", search, move |_, cx| set(&closer, false, cx))
.on_open(move |value, _, cx| set(&went, Some(value.clone()), cx))
});
section(
"SearchPalette / SpotlightSearch / QuickLauncher",
"One field searches everywhere and marks each match. As a launcher it opens alone, above every window, and closes when you look away.",
cx,
)
.child(
row()
.child(specimen(
caption,
probe(
"search",
Button::new("search-button", "Search everything")
.icon(IconName::Search)
.on_click(move |_, _, cx| set(&opener, true, cx)),
),
cx,
))
.child(specimen(
"a window above all others",
probe(
"launcher",
Button::new("launcher-button", "Open the launcher")
.variant(ButtonVariant::Ghost)
.icon(IconName::Rocket)
.on_click(move |_, _, cx| {
let went = launched.clone();
let opened = QuickLauncher::open(
search,
move |value, _, cx| set(&went, Some(value.clone()), cx),
cx,
);
match opened {
Ok(handle) => Opened::insert("launcher", handle.into(), cx),
Err(error) => log::error!("gallery: launcher: {error:#}"),
}
}),
),
cx,
)),
)
.children(palette)
}
+1 -1
View File
@@ -246,7 +246,7 @@ pub fn quick_launcher(cx: &App) -> impl IntoElement + use<> {
cx,
)
.child(Caption::new(
"It needs text input and the palette, so it lands with Navigation, chapter 7.",
"It is `navigation::QuickLauncher`; the Navigation page, chapter 7, opens one.",
))
}
+1 -1
View File
@@ -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;
+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);
}
+5
View File
@@ -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))
+8 -8
View File
@@ -132,7 +132,7 @@
- [x] SystemNotification — blocked: an unbundled app has no notification center; gpui 0.2.2 has no API
- [x] MultiWindow Manager
- [x] WindowSwitcher
- [x] QuickLauncher → `navigation::SearchPalette` in an always-on-top window (T07)
- [x] QuickLauncher → `navigation::QuickLauncher` (T07c): a SearchPalette in an always-on-top window
- [x] MiniWindow / CompactMode
- [x] AlwaysOnTop — native `WindowKind::PopUp`
- [x] PictureInPicture → `shell::MiniWindow` at a screen corner; the host supplies the view
@@ -300,12 +300,12 @@
## T07c Navigation · Palettes & tour
- [ ] CommandPalette
- [ ] QuickOpen
- [ ] QuickSwitcher
- [ ] SearchPalette
- [ ] SpotlightSearch → `navigation::SearchPalette`; T04 QuickLauncher opens it always on top
- [ ] Tour / Onboarding
- [x] CommandPalette → `navigation::CommandPalette`
- [x] QuickOpen → `navigation::QuickOpen`
- [x] QuickSwitcher → `navigation::QuickSwitcher`
- [x] SearchPalette → `navigation::SearchPalette`
- [x] SpotlightSearch → `navigation::SearchPalette`; T04 QuickLauncher opens it always on top
- [x] Tour / Onboarding → lands with Overlays (T09): its steps are Coachmarks
## T08 Menus — `src/menus`
@@ -336,7 +336,7 @@
- [ ] Sheet → `layout::Sheet`
- [ ] Drawer → `layout::Drawer`
- [ ] Lightbox
- [ ] Spotlight / Coachmark
- [ ] Spotlight / Coachmark — also T07c Tour / Onboarding and T37 WelcomeTour
- [ ] FloatingToolbar
- [ ] Callout / Peek
- [ ] DropdownPanel → `overlays::Popover`
+1 -1
View File
@@ -167,7 +167,7 @@
## T37 Onboarding & Help — `src/onboarding`
- [ ] OnboardingWizard
- [ ] WelcomeTour → `navigation::Tour`
- [ ] WelcomeTour → `overlays::Tour` (T09)
- [ ] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here
- [ ] FeatureHighlight / NewBadge
- [ ] Checklist (first-run tasks)