From a923def70817aa9bb22046ee06c96ba163f99b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 12:31:20 -0400 Subject: [PATCH] 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 --- AGENTS.md | 2 + TASKS.md | 4 +- examples/gallery/pages/navigation/mod.rs | 38 ++ examples/gallery/pages/navigation/palettes.rs | 423 ++++++++++++++++ examples/gallery/pages/shell/windows.rs | 2 +- src/forms/mod.rs | 2 +- src/navigation/mod.rs | 4 + src/navigation/palette/kinds.rs | 475 ++++++++++++++++++ src/navigation/palette/launcher.rs | 91 ++++ src/navigation/palette/mod.rs | 390 ++++++++++++++ src/navigation/palette/tests.rs | 241 +++++++++ src/theme/tokens.rs | 5 + tasks/ch01-10.md | 16 +- tasks/ch31-43.md | 2 +- 14 files changed, 1683 insertions(+), 12 deletions(-) create mode 100644 examples/gallery/pages/navigation/palettes.rs create mode 100644 src/navigation/palette/kinds.rs create mode 100644 src/navigation/palette/launcher.rs create mode 100644 src/navigation/palette/mod.rs create mode 100644 src/navigation/palette/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 1db0d12..84d12cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index 0ab21a5..e5bf068 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/navigation/mod.rs b/examples/gallery/pages/navigation/mod.rs index 193be96..cb80226 100644 --- a/examples/gallery/pages/navigation/mod.rs +++ b/examples/gallery/pages/navigation/mod.rs @@ -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() } diff --git a/examples/gallery/pages/navigation/palettes.rs b/examples/gallery/pages/navigation/palettes.rs new file mode 100644 index 0000000..a3d4364 --- /dev/null +++ b/examples/gallery/pages/navigation/palettes.rs @@ -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>, 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)> { + 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::, 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::, 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 { + 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::>() + }, + 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)> { + 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::, 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) +} diff --git a/examples/gallery/pages/shell/windows.rs b/examples/gallery/pages/shell/windows.rs index 7c301bb..4f524ec 100644 --- a/examples/gallery/pages/shell/windows.rs +++ b/examples/gallery/pages/shell/windows.rs @@ -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.", )) } diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 6f7dec1..c5bb18d 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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; diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs index 0ec460d..55959c4 100644 --- a/src/navigation/mod.rs +++ b/src/navigation/mod.rs @@ -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}; diff --git a/src/navigation/palette/kinds.rs b/src/navigation/palette/kinds.rs new file mode 100644 index 0000000..eed541a --- /dev/null +++ b/src/navigation/palette/kinds.rs @@ -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( + items: impl IntoIterator, + 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, + pub keys: Option, +} + +impl Command { + pub fn new(value: impl Into, label: impl Into) -> 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) -> Self { + self.keys = Some(keys.into()); + self + } + + fn row(&self, hits: Vec>, 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)>, + recent: Vec, + on_run: Option, + 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, + 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, + commands: impl IntoIterator, + ) -> 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>) -> 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, + recent: Vec, + on_open: Option, + on_close: Run, +} + +impl QuickOpen { + pub fn new( + id: impl Into, + paths: impl IntoIterator>, + 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>) -> 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], cx: &App) -> Row { + let name_at = path.rfind('/').map_or(0, |slash| slash + 1); + let within = |from: usize, to: usize| -> Vec> { + 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, + on_switch: Option, + on_close: Run, +} + +impl QuickSwitcher { + /// Each item's note says what it is. + pub fn new( + id: impl Into, + items: impl IntoIterator, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + let items: Vec = 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>| 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 Vec<(SharedString, Vec)>>; + +/// 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, + 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, + search: impl Fn(&str) -> Vec<(SharedString, Vec)> + '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, + 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) + } +} diff --git a/src/navigation/palette/launcher.rs b/src/navigation/palette/launcher.rs new file mode 100644 index 0000000..99714a5 --- /dev/null +++ b/src/navigation/palette/launcher.rs @@ -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)> + 'static, + on_open: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + cx: &mut App, + ) -> anyhow::Result> { + 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| { + 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) -> 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)) + } +} diff --git a/src/navigation/palette/mod.rs b/src/navigation/palette/mod.rs new file mode 100644 index 0000000..e8cc84f --- /dev/null +++ b/src/navigation/palette/mod.rs @@ -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>, +} + +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 { + let query: Vec = 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> = 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>, 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, + pub label: AnyElement, + pub detail: Option, + pub end: Option, +} + +/// Rows under an optional heading. +pub(crate) struct Group { + pub title: Option, + pub rows: Vec, +} + +/// The row under the keyboard, the query it was set for, and the list's scroll. +#[derive(Default)] +struct Cursor { + at: usize, + query: Option, + 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 { + 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, + pub groups: Vec, + /// The row marked whenever the query changes. + pub start: usize, + /// Said when no row fits. + pub empty: SharedString, + pub on_pick: Option, + 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 = 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 = 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)), + ) + } +} diff --git a/src/navigation/palette/tests.rs b/src/navigation/palette/tests.rs new file mode 100644 index 0000000..6263840 --- /dev/null +++ b/src/navigation/palette/tests.rs @@ -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, + kind: Kind, +} + +#[derive(Clone, Copy)] +enum Kind { + Commands, + Files, + Switcher, + Search, + Disabled, +} + +fn results(query: &str) -> Vec<(SharedString, Vec)> { + 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) -> 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, &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, cx: &mut VisualTestContext) -> (bool, Option) { + view.read_with(cx, |host, _| (host.open, host.got.clone())) +} + +fn reopen(view: &gpui::Entity, 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); +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 349df3c..ab4484e 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -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 { + size(px_to_rems(600.0), px_to_rems(400.0)) + } + /// Grab bar on a drawer. pub fn grip(&self) -> Size { size(px_to_rems(36.0), px_to_rems(4.0)) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 7c809d0..87ae64f 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -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` diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index f3805b1..e0d056b 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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)