diff --git a/Cargo.lock b/Cargo.lock index 4cba8251cf40f80dc0940ae20848f7a7326c881d..0a4540ba39db3a6110d5bfcbb749de3bd201bc2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,6 +1143,23 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "command_palette" +version = "0.1.0" +dependencies = [ + "ctor", + "editor", + "env_logger 0.8.3", + "fuzzy", + "gpui", + "picker", + "serde_json", + "settings", + "theme", + "util", + "workspace", +] + [[package]] name = "comrak" version = "0.10.1" @@ -1890,6 +1907,7 @@ dependencies = [ "env_logger 0.8.3", "fuzzy", "gpui", + "picker", "postage", "project", "serde_json", @@ -3356,6 +3374,7 @@ dependencies = [ "gpui", "language", "ordered-float", + "picker", "postage", "settings", "smol", @@ -3529,6 +3548,21 @@ dependencies = [ "indexmap", ] +[[package]] +name = "picker" +version = "0.1.0" +dependencies = [ + "ctor", + "editor", + "env_logger 0.8.3", + "gpui", + "serde_json", + "settings", + "theme", + "util", + "workspace", +] + [[package]] name = "pico-args" version = "0.4.0" @@ -3762,6 +3796,7 @@ dependencies = [ "fuzzy", "gpui", "ordered-float", + "picker", "postage", "project", "settings", @@ -5278,6 +5313,7 @@ dependencies = [ "gpui", "log", "parking_lot", + "picker", "postage", "settings", "smol", @@ -6138,8 +6174,8 @@ dependencies = [ "client", "clock", "collections", + "command_palette", "contacts_panel", - "crossbeam-channel", "ctor", "diagnostics", "dirs 3.0.1", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index d9d5742b663ca80a52d88f906e9ba791c9dda707..506dd362d4c3ec8262ac472aa0321951a3a96ef3 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -16,7 +16,8 @@ "ctrl-n": "menu::SelectNext", "cmd-up": "menu::SelectFirst", "cmd-down": "menu::SelectLast", - "enter": "menu::Confirm" + "enter": "menu::Confirm", + "escape": "menu::Cancel" }, "Pane": { "shift-cmd-{": "pane::ActivatePrevItem", @@ -52,6 +53,7 @@ "cmd-k t": "theme_selector::Reload", "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", + "cmd-shift-P": "command_palette::Toggle", "alt-shift-D": "diagnostics::Deploy", "ctrl-alt-cmd-j": "journal::NewJournalEntry", "cmd-1": [ @@ -248,22 +250,10 @@ "\n" ] }, - "OutlineView": { - "escape": "outline::Toggle" - }, - "ProjectSymbolsView": { - "escape": "project_symbols::Toggle" - }, - "ThemeSelector": { - "escape": "theme_selector::Toggle" - }, "GoToLine": { "escape": "go_to_line::Toggle", "enter": "go_to_line::Confirm" }, - "FileFinder": { - "escape": "file_finder::Toggle" - }, "ChatPanel": { "enter": "chat_panel::Send" }, diff --git a/assets/themes/dark.json b/assets/themes/dark.json index 96f1ee0efc64e9c95536dcbf4086c05663509702..85822e8e05ec35f47a0f08bf42a53ed8db401efd 100644 --- a/assets/themes/dark.json +++ b/assets/themes/dark.json @@ -708,6 +708,29 @@ } } }, + "command_palette": { + "keystroke_spacing": 8, + "key": { + "text": { + "family": "Zed Mono", + "color": "#f1f1f1", + "size": 12 + }, + "corner_radius": 3, + "background": "#2472f2", + "border": { + "color": "#2472f2", + "width": 1 + }, + "padding": { + "left": 3, + "right": 3 + }, + "margin": { + "left": 3 + } + } + }, "project_panel": { "padding": { "top": 6, diff --git a/assets/themes/light.json b/assets/themes/light.json index c942dbb39b99a1b72e1cfea404ce05dab2e3dbad..9a93749bfb3e4fea1d158ea1a5118af88ba0531b 100644 --- a/assets/themes/light.json +++ b/assets/themes/light.json @@ -708,6 +708,29 @@ } } }, + "command_palette": { + "keystroke_spacing": 8, + "key": { + "text": { + "family": "Zed Mono", + "color": "#2b2b2b", + "size": 12 + }, + "corner_radius": 3, + "background": "#c5dafc", + "border": { + "color": "#9ec1fa", + "width": 1 + }, + "padding": { + "left": 3, + "right": 3 + }, + "margin": { + "left": 3 + } + } + }, "project_panel": { "padding": { "top": 6, diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..aeaffa3e6f0fd77082a1e4b9cd7896da9b713669 --- /dev/null +++ b/crates/command_palette/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "command_palette" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/command_palette.rs" +doctest = false + +[dependencies] +editor = { path = "../editor" } +fuzzy = { path = "../fuzzy" } +gpui = { path = "../gpui" } +picker = { path = "../picker" } +settings = { path = "../settings" } +util = { path = "../util" } +theme = { path = "../theme" } +workspace = { path = "../workspace" } + +[dev-dependencies] +gpui = { path = "../gpui", features = ["test-support"] } +editor = { path = "../editor", features = ["test-support"] } +serde_json = { version = "1.0.64", features = ["preserve_order"] } +workspace = { path = "../workspace", features = ["test-support"] } +ctor = "0.1" +env_logger = "0.8" diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs new file mode 100644 index 0000000000000000000000000000000000000000..b535450d12eb86903c4a381a2b70a814de04a565 --- /dev/null +++ b/crates/command_palette/src/command_palette.rs @@ -0,0 +1,362 @@ +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + actions, + elements::{ChildView, Flex, Label, ParentElement}, + keymap::Keystroke, + Action, Element, Entity, MutableAppContext, View, ViewContext, ViewHandle, +}; +use picker::{Picker, PickerDelegate}; +use settings::Settings; +use std::cmp; +use workspace::Workspace; + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(CommandPalette::toggle); + Picker::::init(cx); +} + +actions!(command_palette, [Toggle]); + +pub struct CommandPalette { + picker: ViewHandle>, + actions: Vec, + matches: Vec, + selected_ix: usize, + focused_view_id: usize, +} + +pub enum Event { + Dismissed, + Confirmed { + window_id: usize, + focused_view_id: usize, + action: Box, + }, +} + +struct Command { + name: String, + action: Box, + keystrokes: Vec, +} + +impl CommandPalette { + pub fn new(focused_view_id: usize, cx: &mut ViewContext) -> Self { + let this = cx.weak_handle(); + let actions = cx + .available_actions(cx.window_id(), focused_view_id) + .map(|(name, action, bindings)| Command { + name: humanize_action_name(name), + action, + keystrokes: bindings + .last() + .map_or(Vec::new(), |binding| binding.keystrokes().to_vec()), + }) + .collect(); + let picker = cx.add_view(|cx| Picker::new(this, cx)); + Self { + picker, + actions, + matches: vec![], + selected_ix: 0, + focused_view_id, + } + } + + fn toggle(_: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { + let workspace = cx.handle(); + let window_id = cx.window_id(); + let focused_view_id = cx.focused_view_id(window_id).unwrap_or(workspace.id()); + + cx.as_mut().defer(move |cx| { + let this = cx.add_view(window_id, |cx| Self::new(focused_view_id, cx)); + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(cx, |cx, _| { + cx.subscribe(&this, Self::on_event).detach(); + this + }); + }); + }); + } + + fn on_event( + workspace: &mut Workspace, + _: ViewHandle, + event: &Event, + cx: &mut ViewContext, + ) { + match event { + Event::Dismissed => workspace.dismiss_modal(cx), + Event::Confirmed { + window_id, + focused_view_id, + action, + } => { + let window_id = *window_id; + let focused_view_id = *focused_view_id; + let action = (*action).boxed_clone(); + workspace.dismiss_modal(cx); + cx.as_mut() + .defer(move |cx| cx.dispatch_action_at(window_id, focused_view_id, &*action)) + } + } + } +} + +impl Entity for CommandPalette { + type Event = Event; +} + +impl View for CommandPalette { + fn ui_name() -> &'static str { + "CommandPalette" + } + + fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox { + ChildView::new(self.picker.clone()).boxed() + } + + fn on_focus(&mut self, cx: &mut ViewContext) { + cx.focus(&self.picker); + } +} + +impl PickerDelegate for CommandPalette { + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_ix + } + + fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext) { + self.selected_ix = ix; + } + + fn update_matches( + &mut self, + query: String, + cx: &mut gpui::ViewContext, + ) -> gpui::Task<()> { + let candidates = self + .actions + .iter() + .enumerate() + .map(|(ix, command)| StringMatchCandidate { + id: ix, + string: command.name.to_string(), + char_bag: command.name.chars().collect(), + }) + .collect::>(); + cx.spawn(move |this, mut cx| async move { + let matches = if query.is_empty() { + candidates + .into_iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string, + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + fuzzy::match_strings( + &candidates, + &query, + true, + 10000, + &Default::default(), + cx.background(), + ) + .await + }; + this.update(&mut cx, |this, _| { + this.matches = matches; + if this.matches.is_empty() { + this.selected_ix = 0; + } else { + this.selected_ix = cmp::min(this.selected_ix, this.matches.len() - 1); + } + }); + }) + } + + fn dismiss(&mut self, cx: &mut ViewContext) { + cx.emit(Event::Dismissed); + } + + fn confirm(&mut self, cx: &mut ViewContext) { + if !self.matches.is_empty() { + let action_ix = self.matches[self.selected_ix].candidate_id; + cx.emit(Event::Confirmed { + window_id: cx.window_id(), + focused_view_id: self.focused_view_id, + action: self.actions.remove(action_ix).action, + }); + } else { + cx.emit(Event::Dismissed); + } + } + + fn render_match(&self, ix: usize, selected: bool, cx: &gpui::AppContext) -> gpui::ElementBox { + let mat = &self.matches[ix]; + let command = &self.actions[mat.candidate_id]; + let settings = cx.global::(); + let theme = &settings.theme; + let style = if selected { + &theme.selector.active_item + } else { + &theme.selector.item + }; + let key_style = &theme.command_palette.key; + let keystroke_spacing = theme.command_palette.keystroke_spacing; + + Flex::row() + .with_child( + Label::new(mat.string.clone(), style.label.clone()) + .with_highlights(mat.positions.clone()) + .boxed(), + ) + .with_children(command.keystrokes.iter().map(|keystroke| { + Flex::row() + .with_children( + [ + (keystroke.ctrl, "^"), + (keystroke.alt, "⎇"), + (keystroke.cmd, "⌘"), + (keystroke.shift, "⇧"), + ] + .into_iter() + .filter_map(|(modifier, label)| { + if modifier { + Some( + Label::new(label.into(), key_style.label.clone()) + .contained() + .with_style(key_style.container) + .boxed(), + ) + } else { + None + } + }), + ) + .with_child( + Label::new(keystroke.key.clone(), key_style.label.clone()) + .contained() + .with_style(key_style.container) + .boxed(), + ) + .contained() + .with_margin_left(keystroke_spacing) + .flex_float() + .boxed() + })) + .contained() + .with_style(style.container) + .boxed() + } +} + +fn humanize_action_name(name: &str) -> String { + let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count(); + let mut result = String::with_capacity(capacity); + for char in name.chars() { + if char == ':' { + if result.ends_with(':') { + result.push(' '); + } else { + result.push(':'); + } + } else if char.is_uppercase() { + if !result.ends_with(' ') { + result.push(' '); + } + result.extend(char.to_lowercase()); + } else { + result.push(char); + } + } + result +} + +impl std::fmt::Debug for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Command") + .field("name", &self.name) + .field("keystrokes", &self.keystrokes) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use editor::Editor; + use gpui::TestAppContext; + use workspace::{Workspace, WorkspaceParams}; + + #[test] + fn test_humanize_action_name() { + assert_eq!( + &humanize_action_name("editor::GoToDefinition"), + "editor: go to definition" + ); + assert_eq!( + &humanize_action_name("editor::Backspace"), + "editor: backspace" + ); + } + + #[gpui::test] + async fn test_command_palette(cx: &mut TestAppContext) { + let params = cx.update(WorkspaceParams::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(¶ms.client, cx); + init(cx); + }); + + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx)); + let editor = cx.add_view(window_id, |cx| { + let mut editor = Editor::single_line(None, cx); + editor.set_text("abc", cx); + editor + }); + + workspace.update(cx, |workspace, cx| { + cx.focus(editor.clone()); + workspace.add_item(Box::new(editor.clone()), cx) + }); + + workspace.update(cx, |workspace, cx| { + CommandPalette::toggle(workspace, &Toggle, cx) + }); + + let palette = workspace.read_with(cx, |workspace, _| { + workspace + .modal() + .unwrap() + .clone() + .downcast::() + .unwrap() + }); + + palette + .update(cx, |palette, cx| { + palette.update_matches("bcksp".to_string(), cx) + }) + .await; + + palette.update(cx, |palette, cx| { + assert_eq!(palette.matches[0].string, "editor: backspace"); + palette.confirm(cx); + }); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.text(cx), "ab"); + }); + } +} diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 47dd9b15bc07423c9cb4a86ef493f41ba7bf4c9b..cb85183ef073484c9cec7f3b5f2ad553a53bd992 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -11,6 +11,7 @@ doctest = false editor = { path = "../editor" } fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } +picker = { path = "../picker" } project = { path = "../project" } settings = { path = "../settings" } util = { path = "../util" } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f3f2c31d2cfd5268135570251ed1d0697ca3610c..9877cef3d80e8d4fa47fb1ddbbf4059ef1e93b46 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1,13 +1,12 @@ -use editor::Editor; use fuzzy::PathMatch; use gpui::{ - actions, elements::*, impl_internal_actions, keymap, AppContext, Axis, Entity, ModelHandle, - MutableAppContext, RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle, + actions, elements::*, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task, + View, ViewContext, ViewHandle, }; +use picker::{Picker, PickerDelegate}; use project::{Project, ProjectPath, WorktreeId}; use settings::Settings; use std::{ - cmp, path::Path, sync::{ atomic::{self, AtomicBool}, @@ -15,15 +14,11 @@ use std::{ }, }; use util::post_inc; -use workspace::{ - menu::{Confirm, SelectNext, SelectPrev}, - Workspace, -}; +use workspace::Workspace; pub struct FileFinder { - handle: WeakViewHandle, project: ModelHandle, - query_editor: ViewHandle, + picker: ViewHandle>, search_count: usize, latest_search_id: usize, latest_search_did_cancel: bool, @@ -31,21 +26,13 @@ pub struct FileFinder { matches: Vec, selected: Option<(usize, Arc)>, cancel_flag: Arc, - list_state: UniformListState, } -#[derive(Clone)] -pub struct Select(pub ProjectPath); - actions!(file_finder, [Toggle]); -impl_internal_actions!(file_finder, [Select]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(FileFinder::toggle); - cx.add_action(FileFinder::confirm); - cx.add_action(FileFinder::select); - cx.add_action(FileFinder::select_prev); - cx.add_action(FileFinder::select_next); + Picker::::init(cx); } pub enum Event { @@ -62,140 +49,16 @@ impl View for FileFinder { "FileFinder" } - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let settings = cx.global::(); - Align::new( - ConstrainedBox::new( - Container::new( - Flex::new(Axis::Vertical) - .with_child( - ChildView::new(&self.query_editor) - .contained() - .with_style(settings.theme.selector.input_editor.container) - .boxed(), - ) - .with_child( - FlexItem::new(self.render_matches(cx)) - .flex(1., false) - .boxed(), - ) - .boxed(), - ) - .with_style(settings.theme.selector.container) - .boxed(), - ) - .with_max_width(500.0) - .with_max_height(420.0) - .boxed(), - ) - .top() - .named("file finder") + fn render(&mut self, _: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone()).boxed() } fn on_focus(&mut self, cx: &mut ViewContext) { - cx.focus(&self.query_editor); - } - - fn keymap_context(&self, _: &AppContext) -> keymap::Context { - let mut cx = Self::default_keymap_context(); - cx.set.insert("menu".into()); - cx + cx.focus(&self.picker); } } impl FileFinder { - fn render_matches(&self, cx: &AppContext) -> ElementBox { - if self.matches.is_empty() { - let settings = cx.global::(); - return Container::new( - Label::new( - "No matches".into(), - settings.theme.selector.empty.label.clone(), - ) - .boxed(), - ) - .with_style(settings.theme.selector.empty.container) - .named("empty matches"); - } - - let handle = self.handle.clone(); - let list = - UniformList::new( - self.list_state.clone(), - self.matches.len(), - move |mut range, items, cx| { - let cx = cx.as_ref(); - let finder = handle.upgrade(cx).unwrap(); - let finder = finder.read(cx); - let start = range.start; - range.end = cmp::min(range.end, finder.matches.len()); - items.extend(finder.matches[range].iter().enumerate().map( - move |(i, path_match)| finder.render_match(path_match, start + i, cx), - )); - }, - ); - - Container::new(list.boxed()) - .with_margin_top(6.0) - .named("matches") - } - - fn render_match(&self, path_match: &PathMatch, index: usize, cx: &AppContext) -> ElementBox { - let selected_index = self.selected_index(); - let settings = cx.global::(); - let style = if index == selected_index { - &settings.theme.selector.active_item - } else { - &settings.theme.selector.item - }; - let (file_name, file_name_positions, full_path, full_path_positions) = - self.labels_for_match(path_match); - let container = Container::new( - Flex::row() - // .with_child( - // Container::new( - // LineBox::new( - // Svg::new("icons/file-16.svg") - // .with_color(style.label.text.color) - // .boxed(), - // style.label.text.clone(), - // ) - // .boxed(), - // ) - // .with_padding_right(6.0) - // .boxed(), - // ) - .with_child( - Flex::column() - .with_child( - Label::new(file_name.to_string(), style.label.clone()) - .with_highlights(file_name_positions) - .boxed(), - ) - .with_child( - Label::new(full_path, style.label.clone()) - .with_highlights(full_path_positions) - .boxed(), - ) - .flex(1., false) - .boxed(), - ) - .boxed(), - ) - .with_style(style.container); - - let action = Select(ProjectPath { - worktree_id: WorktreeId::from_usize(path_match.worktree_id), - path: path_match.path.clone(), - }); - EventHandler::new(container.boxed()) - .on_mouse_down(move |cx| { - cx.dispatch_action(action.clone()); - true - }) - .named("match") - } - fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec, String, Vec) { let path_string = path_match.path.to_string_lossy(); let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join(""); @@ -250,18 +113,11 @@ impl FileFinder { } pub fn new(project: ModelHandle, cx: &mut ViewContext) -> Self { + let handle = cx.weak_handle(); cx.observe(&project, Self::project_updated).detach(); - - let query_editor = cx.add_view(|cx| { - Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx) - }); - cx.subscribe(&query_editor, Self::on_query_editor_event) - .detach(); - Self { - handle: cx.weak_handle(), project, - query_editor, + picker: cx.add_view(|cx| Picker::new(handle, cx)), search_count: 0, latest_search_id: 0, latest_search_did_cancel: false, @@ -269,40 +125,60 @@ impl FileFinder { matches: Vec::new(), selected: None, cancel_flag: Arc::new(AtomicBool::new(false)), - list_state: Default::default(), } } fn project_updated(&mut self, _: ModelHandle, cx: &mut ViewContext) { - let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx)); - if let Some(task) = self.spawn_search(query, cx) { - task.detach(); - } + self.spawn_search(self.latest_search_query.clone(), cx) + .detach(); + } + + fn spawn_search(&mut self, query: String, cx: &mut ViewContext) -> Task<()> { + let search_id = util::post_inc(&mut self.search_count); + self.cancel_flag.store(true, atomic::Ordering::Relaxed); + self.cancel_flag = Arc::new(AtomicBool::new(false)); + let cancel_flag = self.cancel_flag.clone(); + let project = self.project.clone(); + cx.spawn(|this, mut cx| async move { + let matches = project + .read_with(&cx, |project, cx| { + project.match_paths(&query, false, false, 100, cancel_flag.as_ref(), cx) + }) + .await; + let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed); + this.update(&mut cx, |this, cx| { + this.set_matches(search_id, did_cancel, query, matches, cx) + }); + }) } - fn on_query_editor_event( + fn set_matches( &mut self, - _: ViewHandle, - event: &editor::Event, + search_id: usize, + did_cancel: bool, + query: String, + matches: Vec, cx: &mut ViewContext, ) { - match event { - editor::Event::BufferEdited { .. } => { - let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx)); - if query.is_empty() { - self.latest_search_id = post_inc(&mut self.search_count); - self.matches.clear(); - cx.notify(); - } else { - if let Some(task) = self.spawn_search(query, cx) { - task.detach(); - } - } + if search_id >= self.latest_search_id { + self.latest_search_id = search_id; + if self.latest_search_did_cancel && query == self.latest_search_query { + util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a)); + } else { + self.matches = matches; } - editor::Event::Blurred => cx.emit(Event::Dismissed), - _ => {} + self.latest_search_query = query; + self.latest_search_did_cancel = did_cancel; + cx.notify(); + self.picker.update(cx, |_, cx| cx.notify()); } } +} + +impl PickerDelegate for FileFinder { + fn match_count(&self) -> usize { + self.matches.len() + } fn selected_index(&self) -> usize { if let Some(selected) = self.selected.as_ref() { @@ -317,31 +193,24 @@ impl FileFinder { 0 } - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - let mut selected_index = self.selected_index(); - if selected_index > 0 { - selected_index -= 1; - let mat = &self.matches[selected_index]; - self.selected = Some((mat.worktree_id, mat.path.clone())); - } - self.list_state - .scroll_to(ScrollTarget::Show(selected_index)); + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext) { + let mat = &self.matches[ix]; + self.selected = Some((mat.worktree_id, mat.path.clone())); cx.notify(); } - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let mut selected_index = self.selected_index(); - if selected_index + 1 < self.matches.len() { - selected_index += 1; - let mat = &self.matches[selected_index]; - self.selected = Some((mat.worktree_id, mat.path.clone())); + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> Task<()> { + if query.is_empty() { + self.latest_search_id = post_inc(&mut self.search_count); + self.matches.clear(); + cx.notify(); + Task::ready(()) + } else { + self.spawn_search(query, cx) } - self.list_state - .scroll_to(ScrollTarget::Show(selected_index)); - cx.notify(); } - fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + fn confirm(&mut self, cx: &mut ViewContext) { if let Some(m) = self.matches.get(self.selected_index()) { cx.emit(Event::Selected(ProjectPath { worktree_id: WorktreeId::from_usize(m.worktree_id), @@ -350,57 +219,45 @@ impl FileFinder { } } - fn select(&mut self, Select(project_path): &Select, cx: &mut ViewContext) { - cx.emit(Event::Selected(project_path.clone())); + fn dismiss(&mut self, cx: &mut ViewContext) { + cx.emit(Event::Dismissed); } - #[must_use] - fn spawn_search(&mut self, query: String, cx: &mut ViewContext) -> Option> { - let search_id = util::post_inc(&mut self.search_count); - self.cancel_flag.store(true, atomic::Ordering::Relaxed); - self.cancel_flag = Arc::new(AtomicBool::new(false)); - let cancel_flag = self.cancel_flag.clone(); - let project = self.project.clone(); - Some(cx.spawn(|this, mut cx| async move { - let matches = project - .read_with(&cx, |project, cx| { - project.match_paths(&query, false, false, 100, cancel_flag.as_ref(), cx) - }) - .await; - let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed); - this.update(&mut cx, |this, cx| { - this.update_matches((search_id, did_cancel, query, matches), cx) - }); - })) - } - - fn update_matches( - &mut self, - (search_id, did_cancel, query, matches): (usize, bool, String, Vec), - cx: &mut ViewContext, - ) { - if search_id >= self.latest_search_id { - self.latest_search_id = search_id; - if self.latest_search_did_cancel && query == self.latest_search_query { - util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a)); - } else { - self.matches = matches; - } - self.latest_search_query = query; - self.latest_search_did_cancel = did_cancel; - self.list_state - .scroll_to(ScrollTarget::Show(self.selected_index())); - cx.notify(); - } + fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox { + let path_match = &self.matches[ix]; + let settings = cx.global::(); + let style = if selected { + &settings.theme.selector.active_item + } else { + &settings.theme.selector.item + }; + let (file_name, file_name_positions, full_path, full_path_positions) = + self.labels_for_match(path_match); + Flex::column() + .with_child( + Label::new(file_name.to_string(), style.label.clone()) + .with_highlights(file_name_positions) + .boxed(), + ) + .with_child( + Label::new(full_path, style.label.clone()) + .with_highlights(full_path_positions) + .boxed(), + ) + .flex(1., false) + .contained() + .with_style(style.container) + .named("match") } } #[cfg(test)] mod tests { use super::*; - use editor::Input; + use editor::{Editor, Input}; use serde_json::json; use std::path::PathBuf; + use workspace::menu::{Confirm, SelectNext}; use workspace::{Workspace, WorkspaceParams}; #[ctor::ctor] @@ -514,7 +371,6 @@ mod tests { let query = "hi".to_string(); finder .update(cx, |f, cx| f.spawn_search(query.clone(), cx)) - .unwrap() .await; finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 5)); @@ -523,26 +379,22 @@ mod tests { // Simulate a search being cancelled after the time limit, // returning only a subset of the matches that would have been found. - finder.spawn_search(query.clone(), cx).unwrap().detach(); - finder.update_matches( - ( - finder.latest_search_id, - true, // did-cancel - query.clone(), - vec![matches[1].clone(), matches[3].clone()], - ), + finder.spawn_search(query.clone(), cx).detach(); + finder.set_matches( + finder.latest_search_id, + true, // did-cancel + query.clone(), + vec![matches[1].clone(), matches[3].clone()], cx, ); // Simulate another cancellation. - finder.spawn_search(query.clone(), cx).unwrap().detach(); - finder.update_matches( - ( - finder.latest_search_id, - true, // did-cancel - query.clone(), - vec![matches[0].clone(), matches[2].clone(), matches[3].clone()], - ), + finder.spawn_search(query.clone(), cx).detach(); + finder.set_matches( + finder.latest_search_id, + true, // did-cancel + query.clone(), + vec![matches[0].clone(), matches[2].clone(), matches[3].clone()], cx, ); @@ -576,7 +428,6 @@ mod tests { // is included in the matching, because the worktree is a single file. finder .update(cx, |f, cx| f.spawn_search("thf".into(), cx)) - .unwrap() .await; cx.read(|cx| { let finder = finder.read(cx); @@ -594,7 +445,6 @@ mod tests { // not match anything. finder .update(cx, |f, cx| f.spawn_search("thf/".into(), cx)) - .unwrap() .await; finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 0)); } @@ -633,16 +483,15 @@ mod tests { // Run a search that matches two files with the same relative path. finder .update(cx, |f, cx| f.spawn_search("a.t".into(), cx)) - .unwrap() .await; // Can switch between different matches with the same relative path. finder.update(cx, |f, cx| { assert_eq!(f.matches.len(), 2); assert_eq!(f.selected_index(), 0); - f.select_next(&SelectNext, cx); + f.set_selected_index(1, cx); assert_eq!(f.selected_index(), 1); - f.select_prev(&SelectPrev, cx); + f.set_selected_index(0, cx); assert_eq!(f.selected_index(), 0); }); } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index acc84588b09eef2152e077dae4deda14a97a09c4..683ea46999297e162ba8fa4228b6b9a917b290c0 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -3,7 +3,7 @@ pub mod action; use crate::{ elements::ElementBox, executor::{self, Task}, - keymap::{self, Keystroke}, + keymap::{self, Binding, Keystroke}, platform::{self, CursorStyle, Platform, PromptLevel, WindowOptions}, presenter::Presenter, util::post_inc, @@ -17,6 +17,7 @@ use lazy_static::lazy_static; use parking_lot::Mutex; use platform::Event; use postage::oneshot; +use smallvec::SmallVec; use smol::prelude::*; use std::{ any::{type_name, Any, TypeId}, @@ -726,7 +727,7 @@ pub struct MutableAppContext { foreground_platform: Rc, assets: Arc, cx: AppContext, - action_deserializers: HashMap<&'static str, DeserializeActionCallback>, + action_deserializers: HashMap<&'static str, (TypeId, DeserializeActionCallback)>, capture_actions: HashMap>>>, actions: HashMap>>>, global_actions: HashMap>, @@ -877,7 +878,8 @@ impl MutableAppContext { let callback = self .action_deserializers .get(name) - .ok_or_else(|| anyhow!("unknown action {}", name))?; + .ok_or_else(|| anyhow!("unknown action {}", name))? + .1; callback(argument.unwrap_or("{}")) .with_context(|| format!("invalid data for action {}", name)) } @@ -926,7 +928,7 @@ impl MutableAppContext { self.action_deserializers .entry(A::qualified_name()) - .or_insert(A::from_json_str); + .or_insert((TypeId::of::(), A::from_json_str)); let actions = if capture { &mut self.capture_actions @@ -965,7 +967,7 @@ impl MutableAppContext { self.action_deserializers .entry(A::qualified_name()) - .or_insert(A::from_json_str); + .or_insert((TypeId::of::(), A::from_json_str)); if self .global_actions @@ -1304,6 +1306,57 @@ impl MutableAppContext { } } + pub fn available_actions( + &self, + window_id: usize, + view_id: usize, + ) -> impl Iterator, SmallVec<[&Binding; 1]>)> { + let mut action_types: HashSet<_> = self.global_actions.keys().copied().collect(); + + let presenter = self + .presenters_and_platform_windows + .get(&window_id) + .unwrap() + .0 + .clone(); + let dispatch_path = presenter.borrow().dispatch_path_from(view_id); + for view_id in dispatch_path { + if let Some(view) = self.views.get(&(window_id, view_id)) { + let view_type = view.as_any().type_id(); + if let Some(actions) = self.actions.get(&view_type) { + action_types.extend(actions.keys().copied()); + } + } + } + + self.action_deserializers + .iter() + .filter_map(move |(name, (type_id, deserialize))| { + if action_types.contains(type_id) { + Some(( + *name, + deserialize("{}").ok()?, + self.keystroke_matcher + .bindings_for_action_type(*type_id) + .collect(), + )) + } else { + None + } + }) + } + + pub fn dispatch_action_at(&mut self, window_id: usize, view_id: usize, action: &dyn Action) { + let presenter = self + .presenters_and_platform_windows + .get(&window_id) + .unwrap() + .0 + .clone(); + let dispatch_path = presenter.borrow().dispatch_path_from(view_id); + self.dispatch_action_any(window_id, &dispatch_path, action); + } + pub fn dispatch_action( &mut self, window_id: usize, diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 2c3a8df8a1468a127589bef5ea8f12ec427b8e28..24ab663071b4c47da2468763d41994e9068ea2b2 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, Result}; use async_task::Runnable; +use futures::channel::mpsc; use smol::{channel, prelude::*, Executor}; use std::{ any::Any, @@ -621,17 +622,13 @@ impl Background { Err(async { *future.await.downcast().unwrap() }) } - pub async fn scoped<'scope, F>(&self, scheduler: F) + pub async fn scoped<'scope, F>(self: &Arc, scheduler: F) where F: FnOnce(&mut Scope<'scope>), { - let mut scope = Scope { - futures: Default::default(), - _phantom: PhantomData, - }; + let mut scope = Scope::new(self.clone()); (scheduler)(&mut scope); - let spawned = scope - .futures + let spawned = mem::take(&mut scope.futures) .into_iter() .map(|f| self.spawn(f)) .collect::>(); @@ -668,25 +665,56 @@ impl Background { } pub struct Scope<'a> { + executor: Arc, futures: Vec + Send + 'static>>>, + tx: Option>, + rx: mpsc::Receiver<()>, _phantom: PhantomData<&'a ()>, } impl<'a> Scope<'a> { + fn new(executor: Arc) -> Self { + let (tx, rx) = mpsc::channel(1); + Self { + executor, + tx: Some(tx), + rx, + futures: Default::default(), + _phantom: PhantomData, + } + } + pub fn spawn(&mut self, f: F) where F: Future + Send + 'a, { + let tx = self.tx.clone().unwrap(); + + // Safety: The 'a lifetime is guaranteed to outlive any of these futures because + // dropping this `Scope` blocks until all of the futures have resolved. let f = unsafe { mem::transmute::< Pin + Send + 'a>>, Pin + Send + 'static>>, - >(Box::pin(f)) + >(Box::pin(async move { + f.await; + drop(tx); + })) }; self.futures.push(f); } } +impl<'a> Drop for Scope<'a> { + fn drop(&mut self) { + self.tx.take().unwrap(); + + // Wait until the channel is closed, which means that all of the spawned + // futures have resolved. + self.executor.block(self.rx.next()); + } +} + impl Task { pub fn ready(value: T) -> Self { Self::Ready(Some(value)) diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 723403f16012641924051ac4dd5d80b61cfbbb6f..c42fbff907061e5e6813da2411477b62a697c28c 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -1,7 +1,8 @@ use crate::Action; use anyhow::{anyhow, Result}; +use smallvec::SmallVec; use std::{ - any::Any, + any::{Any, TypeId}, collections::{HashMap, HashSet}, fmt::Debug, }; @@ -23,7 +24,10 @@ struct Pending { } #[derive(Default)] -pub struct Keymap(Vec); +pub struct Keymap { + bindings: Vec, + binding_indices_by_action_type: HashMap>, +} pub struct Binding { keystrokes: Vec, @@ -111,6 +115,10 @@ impl Matcher { self.keymap.clear(); } + pub fn bindings_for_action_type(&self, action_type: TypeId) -> impl Iterator { + self.keymap.bindings_for_action_type(action_type) + } + pub fn clear_pending(&mut self) { self.pending.clear(); } @@ -132,7 +140,7 @@ impl Matcher { pending.keystrokes.push(keystroke); let mut retain_pending = false; - for binding in self.keymap.0.iter().rev() { + for binding in self.keymap.bindings.iter().rev() { if binding.keystrokes.starts_with(&pending.keystrokes) && binding.context.as_ref().map(|c| c.eval(cx)).unwrap_or(true) { @@ -163,15 +171,44 @@ impl Default for Matcher { impl Keymap { pub fn new(bindings: Vec) -> Self { - Self(bindings) + let mut binding_indices_by_action_type = HashMap::new(); + for (ix, binding) in bindings.iter().enumerate() { + binding_indices_by_action_type + .entry(binding.action.as_any().type_id()) + .or_insert_with(|| SmallVec::new()) + .push(ix); + } + Self { + binding_indices_by_action_type, + bindings, + } + } + + fn bindings_for_action_type<'a>( + &'a self, + action_type: TypeId, + ) -> impl Iterator { + self.binding_indices_by_action_type + .get(&action_type) + .map(SmallVec::as_slice) + .unwrap_or(&[]) + .iter() + .map(|ix| &self.bindings[*ix]) } fn add_bindings>(&mut self, bindings: T) { - self.0.extend(bindings.into_iter()); + for binding in bindings { + self.binding_indices_by_action_type + .entry(binding.action.as_any().type_id()) + .or_default() + .push(self.bindings.len()); + self.bindings.push(binding); + } } fn clear(&mut self) { - self.0.clear(); + self.bindings.clear(); + self.binding_indices_by_action_type.clear(); } } @@ -198,6 +235,10 @@ impl Binding { context, }) } + + pub fn keystrokes(&self) -> &[Keystroke] { + &self.keystrokes + } } impl Keystroke { @@ -446,7 +487,7 @@ mod tests { a: &'static str, } - let keymap = Keymap(vec![ + let keymap = Keymap::new(vec![ Binding::new("a", A("x".to_string()), Some("a")), Binding::new("b", B, Some("a")), Binding::new("a b", Ab, Some("a || b")), diff --git a/crates/gpui/src/presenter.rs b/crates/gpui/src/presenter.rs index 4585e321c442feffd7948bf399d2a08bb0929a63..793f41f48782079cf8008cc896587cbb5a50a5bc 100644 --- a/crates/gpui/src/presenter.rs +++ b/crates/gpui/src/presenter.rs @@ -51,15 +51,21 @@ impl Presenter { } pub fn dispatch_path(&self, app: &AppContext) -> Vec { + if let Some(view_id) = app.focused_view_id(self.window_id) { + self.dispatch_path_from(view_id) + } else { + Vec::new() + } + } + + pub(crate) fn dispatch_path_from(&self, mut view_id: usize) -> Vec { let mut path = Vec::new(); - if let Some(mut view_id) = app.focused_view_id(self.window_id) { - path.push(view_id); - while let Some(parent_id) = self.parents.get(&view_id).copied() { - path.push(parent_id); - view_id = parent_id; - } - path.reverse(); + path.push(view_id); + while let Some(parent_id) = self.parents.get(&view_id).copied() { + path.push(parent_id); + view_id = parent_id; } + path.reverse(); path } diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index fc9444a14c705b9fdd6aeb5a1bb1b8b0f6e6c851..5b4751e620485b81931099cd980d4e2a35524d92 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -12,6 +12,7 @@ editor = { path = "../editor" } fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } language = { path = "../language" } +picker = { path = "../picker" } settings = { path = "../settings" } text = { path = "../text" } workspace = { path = "../workspace" } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 08bc18a016a62f72d3aaf4b60c7aff33bb4cbfde..a5492a8f69214e7447485d2442871ed8b5c3d33d 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -4,38 +4,31 @@ use editor::{ }; use fuzzy::StringMatch; use gpui::{ - actions, elements::*, geometry::vector::Vector2F, keymap, AppContext, Axis, Entity, - MutableAppContext, RenderContext, View, ViewContext, ViewHandle, WeakViewHandle, + actions, elements::*, geometry::vector::Vector2F, AppContext, Entity, MutableAppContext, + RenderContext, Task, View, ViewContext, ViewHandle, }; use language::Outline; use ordered_float::OrderedFloat; +use picker::{Picker, PickerDelegate}; use settings::Settings; use std::cmp::{self, Reverse}; -use workspace::{ - menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}, - Workspace, -}; +use workspace::Workspace; actions!(outline, [Toggle]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(OutlineView::toggle); - cx.add_action(OutlineView::confirm); - cx.add_action(OutlineView::select_prev); - cx.add_action(OutlineView::select_next); - cx.add_action(OutlineView::select_first); - cx.add_action(OutlineView::select_last); + Picker::::init(cx); } struct OutlineView { - handle: WeakViewHandle, + picker: ViewHandle>, active_editor: ViewHandle, outline: Outline, selected_match_index: usize, prev_scroll_position: Option, matches: Vec, - query_editor: ViewHandle, - list_state: UniformListState, + last_query: String, } pub enum Event { @@ -55,38 +48,12 @@ impl View for OutlineView { "OutlineView" } - fn keymap_context(&self, _: &AppContext) -> keymap::Context { - let mut cx = Self::default_keymap_context(); - cx.set.insert("menu".into()); - cx - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let settings = cx.global::(); - - Flex::new(Axis::Vertical) - .with_child( - Container::new(ChildView::new(&self.query_editor).boxed()) - .with_style(settings.theme.selector.input_editor.container) - .boxed(), - ) - .with_child( - FlexItem::new(self.render_matches(cx)) - .flex(1.0, false) - .boxed(), - ) - .contained() - .with_style(settings.theme.selector.container) - .constrained() - .with_max_width(800.0) - .with_max_height(1200.0) - .aligned() - .top() - .named("outline view") + fn render(&mut self, _: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone()).boxed() } fn on_focus(&mut self, cx: &mut ViewContext) { - cx.focus(&self.query_editor); + cx.focus(&self.picker); } } @@ -96,24 +63,16 @@ impl OutlineView { editor: ViewHandle, cx: &mut ViewContext, ) -> Self { - let query_editor = cx.add_view(|cx| { - Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx) - }); - cx.subscribe(&query_editor, Self::on_query_editor_event) - .detach(); - - let mut this = Self { - handle: cx.weak_handle(), + let handle = cx.weak_handle(); + Self { + picker: cx.add_view(|cx| Picker::new(handle, cx).with_max_size(800., 1200.)), + last_query: Default::default(), matches: Default::default(), selected_match_index: 0, prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))), active_editor: editor, outline, - query_editor, - list_state: Default::default(), - }; - this.update_matches(cx); - this + } } fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { @@ -137,34 +96,18 @@ impl OutlineView { } } - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - if self.selected_match_index > 0 { - self.select(self.selected_match_index - 1, true, false, cx); - } - } - - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - if self.selected_match_index + 1 < self.matches.len() { - self.select(self.selected_match_index + 1, true, false, cx); - } - } - - fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { - self.select(0, true, false, cx); - } - - fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { - self.select(self.matches.len().saturating_sub(1), true, false, cx); + fn restore_active_editor(&mut self, cx: &mut MutableAppContext) { + self.active_editor.update(cx, |editor, cx| { + editor.highlight_rows(None); + if let Some(scroll_position) = self.prev_scroll_position { + editor.set_scroll_position(scroll_position, cx); + } + }) } - fn select(&mut self, index: usize, navigate: bool, center: bool, cx: &mut ViewContext) { - self.selected_match_index = index; - self.list_state.scroll_to(if center { - ScrollTarget::Center(index) - } else { - ScrollTarget::Show(index) - }); - if navigate { + fn set_selected_index(&mut self, ix: usize, navigate: bool, cx: &mut ViewContext) { + self.selected_match_index = ix; + if navigate && !self.matches.is_empty() { let selected_match = &self.matches[self.selected_match_index]; let outline_item = &self.outline.items[selected_match.candidate_id]; self.active_editor.update(cx, |active_editor, cx| { @@ -181,27 +124,6 @@ impl OutlineView { cx.notify(); } - fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - self.prev_scroll_position.take(); - self.active_editor.update(cx, |active_editor, cx| { - if let Some(rows) = active_editor.highlighted_rows() { - let snapshot = active_editor.snapshot(cx).display_snapshot; - let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot); - active_editor.select_ranges([position..position], Some(Autoscroll::Center), cx); - } - }); - cx.emit(Event::Dismissed); - } - - fn restore_active_editor(&mut self, cx: &mut MutableAppContext) { - self.active_editor.update(cx, |editor, cx| { - editor.highlight_rows(None); - if let Some(scroll_position) = self.prev_scroll_position { - editor.set_scroll_position(scroll_position, cx); - } - }) - } - fn on_event( workspace: &mut Workspace, _: ViewHandle, @@ -212,24 +134,27 @@ impl OutlineView { Event::Dismissed => workspace.dismiss_modal(cx), } } +} - fn on_query_editor_event( - &mut self, - _: ViewHandle, - event: &editor::Event, - cx: &mut ViewContext, - ) { - match event { - editor::Event::Blurred => cx.emit(Event::Dismissed), - editor::Event::BufferEdited { .. } => self.update_matches(cx), - _ => {} - } +impl PickerDelegate for OutlineView { + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_match_index } - fn update_matches(&mut self, cx: &mut ViewContext) { + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext) { + self.set_selected_index(ix, true, cx); + } + + fn center_selection_after_match_updates(&self) -> bool { + true + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> Task<()> { let selected_index; - let navigate_to_selected_index; - let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx)); if query.is_empty() { self.restore_active_editor(cx); self.matches = self @@ -271,7 +196,6 @@ impl OutlineView { .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance))) .map(|(ix, _, _)| ix) .unwrap_or(0); - navigate_to_selected_index = false; } else { self.matches = smol::block_on(self.outline.search(&query, cx.background().clone())); selected_index = self @@ -281,57 +205,33 @@ impl OutlineView { .max_by_key(|(_, m)| OrderedFloat(m.score)) .map(|(ix, _)| ix) .unwrap_or(0); - navigate_to_selected_index = !self.matches.is_empty(); } - self.select(selected_index, navigate_to_selected_index, true, cx); + self.last_query = query; + self.set_selected_index(selected_index, false, cx); + Task::ready(()) } - fn render_matches(&self, cx: &AppContext) -> ElementBox { - if self.matches.is_empty() { - let settings = cx.global::(); - return Container::new( - Label::new( - "No matches".into(), - settings.theme.selector.empty.label.clone(), - ) - .boxed(), - ) - .with_style(settings.theme.selector.empty.container) - .named("empty matches"); - } - - let handle = self.handle.clone(); - let list = UniformList::new( - self.list_state.clone(), - self.matches.len(), - move |mut range, items, cx| { - let cx = cx.as_ref(); - let view = handle.upgrade(cx).unwrap(); - let view = view.read(cx); - let start = range.start; - range.end = cmp::min(range.end, view.matches.len()); - items.extend( - view.matches[range] - .iter() - .enumerate() - .map(move |(ix, m)| view.render_match(m, start + ix, cx)), - ); - }, - ); + fn confirm(&mut self, cx: &mut ViewContext) { + self.prev_scroll_position.take(); + self.active_editor.update(cx, |active_editor, cx| { + if let Some(rows) = active_editor.highlighted_rows() { + let snapshot = active_editor.snapshot(cx).display_snapshot; + let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot); + active_editor.select_ranges([position..position], Some(Autoscroll::Center), cx); + } + }); + cx.emit(Event::Dismissed); + } - Container::new(list.boxed()) - .with_margin_top(6.0) - .named("matches") + fn dismiss(&mut self, cx: &mut ViewContext) { + self.restore_active_editor(cx); + cx.emit(Event::Dismissed); } - fn render_match( - &self, - string_match: &StringMatch, - index: usize, - cx: &AppContext, - ) -> ElementBox { + fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox { let settings = cx.global::(); - let style = if index == self.selected_match_index { + let string_match = &self.matches[ix]; + let style = if selected { &settings.theme.selector.active_item } else { &settings.theme.selector.item diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..86e657ecad9073dba73d82e7c6d285b0426e79cb --- /dev/null +++ b/crates/picker/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "picker" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/picker.rs" +doctest = false + +[dependencies] +editor = { path = "../editor" } +gpui = { path = "../gpui" } +settings = { path = "../settings" } +util = { path = "../util" } +theme = { path = "../theme" } +workspace = { path = "../workspace" } + +[dev-dependencies] +gpui = { path = "../gpui", features = ["test-support"] } +serde_json = { version = "1.0.64", features = ["preserve_order"] } +workspace = { path = "../workspace", features = ["test-support"] } +ctor = "0.1" +env_logger = "0.8" diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs new file mode 100644 index 0000000000000000000000000000000000000000..f23467247820bf4117a8f5606dec9a009489fb61 --- /dev/null +++ b/crates/picker/src/picker.rs @@ -0,0 +1,272 @@ +use editor::Editor; +use gpui::{ + elements::{ + ChildView, EventHandler, Flex, Label, ParentElement, ScrollTarget, UniformList, + UniformListState, + }, + geometry::vector::{vec2f, Vector2F}, + keymap, AppContext, Axis, Element, ElementBox, Entity, MutableAppContext, RenderContext, Task, + View, ViewContext, ViewHandle, WeakViewHandle, +}; +use settings::Settings; +use std::cmp; +use workspace::menu::{ + Cancel, Confirm, SelectFirst, SelectIndex, SelectLast, SelectNext, SelectPrev, +}; + +pub struct Picker { + delegate: WeakViewHandle, + query_editor: ViewHandle, + list_state: UniformListState, + update_task: Option>, + max_size: Vector2F, + confirmed: bool, +} + +pub trait PickerDelegate: View { + fn match_count(&self) -> usize; + fn selected_index(&self) -> usize; + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext); + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> Task<()>; + fn confirm(&mut self, cx: &mut ViewContext); + fn dismiss(&mut self, cx: &mut ViewContext); + fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox; + fn center_selection_after_match_updates(&self) -> bool { + false + } +} + +impl Entity for Picker { + type Event = (); +} + +impl View for Picker { + fn ui_name() -> &'static str { + "Picker" + } + + fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { + let settings = cx.global::(); + let delegate = self.delegate.clone(); + let match_count = if let Some(delegate) = delegate.upgrade(cx.app) { + delegate.read(cx).match_count() + } else { + 0 + }; + + Flex::new(Axis::Vertical) + .with_child( + ChildView::new(&self.query_editor) + .contained() + .with_style(settings.theme.selector.input_editor.container) + .boxed(), + ) + .with_child( + if match_count == 0 { + Label::new( + "No matches".into(), + settings.theme.selector.empty.label.clone(), + ) + .contained() + .with_style(settings.theme.selector.empty.container) + } else { + UniformList::new( + self.list_state.clone(), + match_count, + move |mut range, items, cx| { + let cx = cx.as_ref(); + let delegate = delegate.upgrade(cx).unwrap(); + let delegate = delegate.read(cx); + let selected_ix = delegate.selected_index(); + range.end = cmp::min(range.end, delegate.match_count()); + items.extend(range.map(move |ix| { + EventHandler::new(delegate.render_match(ix, ix == selected_ix, cx)) + .on_mouse_down(move |cx| { + cx.dispatch_action(SelectIndex(ix)); + true + }) + .boxed() + })); + }, + ) + .contained() + .with_margin_top(6.0) + } + .flex(1., false) + .boxed(), + ) + .contained() + .with_style(settings.theme.selector.container) + .constrained() + .with_max_width(self.max_size.x()) + .with_max_height(self.max_size.y()) + .aligned() + .top() + .named("picker") + } + + fn keymap_context(&self, _: &AppContext) -> keymap::Context { + let mut cx = Self::default_keymap_context(); + cx.set.insert("menu".into()); + cx + } + + fn on_focus(&mut self, cx: &mut ViewContext) { + cx.focus(&self.query_editor); + } +} + +impl Picker { + pub fn init(cx: &mut MutableAppContext) { + cx.add_action(Self::select_first); + cx.add_action(Self::select_last); + cx.add_action(Self::select_next); + cx.add_action(Self::select_prev); + cx.add_action(Self::select_index); + cx.add_action(Self::confirm); + cx.add_action(Self::cancel); + } + + pub fn new(delegate: WeakViewHandle, cx: &mut ViewContext) -> Self { + let query_editor = cx.add_view(|cx| { + Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx) + }); + cx.subscribe(&query_editor, Self::on_query_editor_event) + .detach(); + let this = Self { + query_editor, + list_state: Default::default(), + update_task: None, + delegate, + max_size: vec2f(500., 420.), + confirmed: false, + }; + cx.defer(|this, cx| this.update_matches(cx)); + this + } + + pub fn with_max_size(mut self, width: f32, height: f32) -> Self { + self.max_size = vec2f(width, height); + self + } + + fn on_query_editor_event( + &mut self, + _: ViewHandle, + event: &editor::Event, + cx: &mut ViewContext, + ) { + match event { + editor::Event::BufferEdited { .. } => self.update_matches(cx), + editor::Event::Blurred if !self.confirmed => { + if let Some(delegate) = self.delegate.upgrade(cx) { + delegate.update(cx, |delegate, cx| { + delegate.dismiss(cx); + }) + } + } + _ => {} + } + } + + fn update_matches(&mut self, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let query = self.query_editor.read(cx).text(cx); + let update = delegate.update(cx, |d, cx| d.update_matches(query, cx)); + cx.notify(); + self.update_task = Some(cx.spawn(|this, mut cx| async move { + update.await; + this.update(&mut cx, |this, cx| { + if let Some(delegate) = this.delegate.upgrade(cx) { + let delegate = delegate.read(cx); + let index = delegate.selected_index(); + let target = if delegate.center_selection_after_match_updates() { + ScrollTarget::Center(index) + } else { + ScrollTarget::Show(index) + }; + this.list_state.scroll_to(target); + cx.notify(); + this.update_task.take(); + } + }); + })); + } + } + + pub fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = 0; + delegate.update(cx, |delegate, cx| delegate.set_selected_index(0, cx)); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + pub fn select_index(&mut self, action: &SelectIndex, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = action.0; + self.confirmed = true; + delegate.update(cx, |delegate, cx| { + delegate.set_selected_index(index, cx); + delegate.confirm(cx); + }); + } + } + + pub fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = delegate.update(cx, |delegate, cx| { + let match_count = delegate.match_count(); + let index = if match_count > 0 { match_count - 1 } else { 0 }; + delegate.set_selected_index(index, cx); + index + }); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + pub fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = delegate.update(cx, |delegate, cx| { + let mut selected_index = delegate.selected_index(); + if selected_index + 1 < delegate.match_count() { + selected_index += 1; + delegate.set_selected_index(selected_index, cx); + } + selected_index + }); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = delegate.update(cx, |delegate, cx| { + let mut selected_index = delegate.selected_index(); + if selected_index > 0 { + selected_index -= 1; + delegate.set_selected_index(selected_index, cx); + } + selected_index + }); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + self.confirmed = true; + delegate.update(cx, |delegate, cx| delegate.confirm(cx)); + } + } + + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + delegate.update(cx, |delegate, cx| delegate.dismiss(cx)); + } + } +} diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml index de22c0eda027a4a3ec1e0a6c0f0bc4024fb58747..e199b700f6860db74ce2da5c09b7d0474cb84c5c 100644 --- a/crates/project_symbols/Cargo.toml +++ b/crates/project_symbols/Cargo.toml @@ -11,6 +11,7 @@ doctest = false editor = { path = "../editor" } fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } +picker = { path = "../picker" } project = { path = "../project" } text = { path = "../text" } settings = { path = "../settings" } diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index c14f7bea33b70089f3988766e64ea38fe8f1d11e..8e89951a00d913f079b62e4c4285cf72d778b575 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -3,43 +3,32 @@ use editor::{ }; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - actions, elements::*, keymap, AppContext, Axis, Entity, ModelHandle, MutableAppContext, - RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle, + actions, elements::*, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task, + View, ViewContext, ViewHandle, }; use ordered_float::OrderedFloat; +use picker::{Picker, PickerDelegate}; use project::{Project, Symbol}; use settings::Settings; -use std::{ - borrow::Cow, - cmp::{self, Reverse}, -}; +use std::{borrow::Cow, cmp::Reverse}; use util::ResultExt; -use workspace::{ - menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}, - Workspace, -}; +use workspace::Workspace; actions!(project_symbols, [Toggle]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(ProjectSymbolsView::toggle); - cx.add_action(ProjectSymbolsView::confirm); - cx.add_action(ProjectSymbolsView::select_prev); - cx.add_action(ProjectSymbolsView::select_next); - cx.add_action(ProjectSymbolsView::select_first); - cx.add_action(ProjectSymbolsView::select_last); + Picker::::init(cx); } pub struct ProjectSymbolsView { - handle: WeakViewHandle, + picker: ViewHandle>, project: ModelHandle, selected_match_index: usize, - list_state: UniformListState, symbols: Vec, match_candidates: Vec, + show_worktree_root_name: bool, matches: Vec, - pending_symbols_task: Task>, - query_editor: ViewHandle, } pub enum Event { @@ -56,60 +45,27 @@ impl View for ProjectSymbolsView { "ProjectSymbolsView" } - fn keymap_context(&self, _: &AppContext) -> keymap::Context { - let mut cx = Self::default_keymap_context(); - cx.set.insert("menu".into()); - cx - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let settings = cx.global::(); - Flex::new(Axis::Vertical) - .with_child( - Container::new(ChildView::new(&self.query_editor).boxed()) - .with_style(settings.theme.selector.input_editor.container) - .boxed(), - ) - .with_child( - FlexItem::new(self.render_matches(cx)) - .flex(1., false) - .boxed(), - ) - .contained() - .with_style(settings.theme.selector.container) - .constrained() - .with_max_width(500.0) - .with_max_height(420.0) - .aligned() - .top() - .named("project symbols view") + fn render(&mut self, _: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone()).boxed() } fn on_focus(&mut self, cx: &mut ViewContext) { - cx.focus(&self.query_editor); + cx.focus(&self.picker); } } impl ProjectSymbolsView { fn new(project: ModelHandle, cx: &mut ViewContext) -> Self { - let query_editor = cx.add_view(|cx| { - Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx) - }); - cx.subscribe(&query_editor, Self::on_query_editor_event) - .detach(); - let mut this = Self { - handle: cx.weak_handle(), + let handle = cx.weak_handle(); + Self { project, + picker: cx.add_view(|cx| Picker::new(handle, cx)), selected_match_index: 0, - list_state: Default::default(), symbols: Default::default(), match_candidates: Default::default(), matches: Default::default(), - pending_symbols_task: Task::ready(None), - query_editor, - }; - this.update_matches(cx); - this + show_worktree_root_name: false, + } } fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { @@ -121,72 +77,7 @@ impl ProjectSymbolsView { }); } - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - if self.selected_match_index > 0 { - self.select(self.selected_match_index - 1, cx); - } - } - - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - if self.selected_match_index + 1 < self.matches.len() { - self.select(self.selected_match_index + 1, cx); - } - } - - fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { - self.select(0, cx); - } - - fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { - self.select(self.matches.len().saturating_sub(1), cx); - } - - fn select(&mut self, index: usize, cx: &mut ViewContext) { - self.selected_match_index = index; - self.list_state.scroll_to(ScrollTarget::Show(index)); - cx.notify(); - } - - fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - if let Some(symbol) = self - .matches - .get(self.selected_match_index) - .map(|mat| self.symbols[mat.candidate_id].clone()) - { - cx.emit(Event::Selected(symbol)); - } - } - - fn update_matches(&mut self, cx: &mut ViewContext) { - self.filter(cx); - let query = self.query_editor.read(cx).text(cx); - let symbols = self - .project - .update(cx, |project, cx| project.symbols(&query, cx)); - self.pending_symbols_task = cx.spawn_weak(|this, mut cx| async move { - let symbols = symbols.await.log_err()?; - if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, cx| { - this.match_candidates = symbols - .iter() - .enumerate() - .map(|(id, symbol)| { - StringMatchCandidate::new( - id, - symbol.label.text[symbol.label.filter_range.clone()].to_string(), - ) - }) - .collect(); - this.symbols = symbols; - this.filter(cx); - }); - } - None - }); - } - - fn filter(&mut self, cx: &mut ViewContext) { - let query = self.query_editor.read(cx).text(cx); + fn filter(&mut self, query: &str, cx: &mut ViewContext) { let mut matches = if query.is_empty() { self.match_candidates .iter() @@ -201,7 +92,7 @@ impl ProjectSymbolsView { } else { smol::block_on(fuzzy::match_strings( &self.match_candidates, - &query, + query, false, 100, &Default::default(), @@ -225,57 +116,111 @@ impl ProjectSymbolsView { } self.matches = matches; - self.select_first(&SelectFirst, cx); + self.set_selected_index(0, cx); cx.notify(); } - fn render_matches(&self, cx: &AppContext) -> ElementBox { - if self.matches.is_empty() { - let settings = cx.global::(); - return Container::new( - Label::new( - "No matches".into(), - settings.theme.selector.empty.label.clone(), - ) - .boxed(), - ) - .with_style(settings.theme.selector.empty.container) - .named("empty matches"); + fn on_event( + workspace: &mut Workspace, + _: ViewHandle, + event: &Event, + cx: &mut ViewContext, + ) { + match event { + Event::Dismissed => workspace.dismiss_modal(cx), + Event::Selected(symbol) => { + let buffer = workspace + .project() + .update(cx, |project, cx| project.open_buffer_for_symbol(symbol, cx)); + + let symbol = symbol.clone(); + cx.spawn(|workspace, mut cx| async move { + let buffer = buffer.await?; + workspace.update(&mut cx, |workspace, cx| { + let position = buffer + .read(cx) + .clip_point_utf16(symbol.range.start, Bias::Left); + + let editor = workspace.open_project_item::(buffer, cx); + editor.update(cx, |editor, cx| { + editor.select_ranges( + [position..position], + Some(Autoscroll::Center), + cx, + ); + }); + }); + Ok::<_, anyhow::Error>(()) + }) + .detach_and_log_err(cx); + workspace.dismiss_modal(cx); + } } + } +} - let handle = self.handle.clone(); - let list = UniformList::new( - self.list_state.clone(), - self.matches.len(), - move |mut range, items, cx| { - let cx = cx.as_ref(); - let view = handle.upgrade(cx).unwrap(); - let view = view.read(cx); - let start = range.start; - range.end = cmp::min(range.end, view.matches.len()); +impl PickerDelegate for ProjectSymbolsView { + fn confirm(&mut self, cx: &mut ViewContext) { + if let Some(symbol) = self + .matches + .get(self.selected_match_index) + .map(|mat| self.symbols[mat.candidate_id].clone()) + { + cx.emit(Event::Selected(symbol)); + } + } - let show_worktree_root_name = - view.project.read(cx).visible_worktrees(cx).count() > 1; - items.extend(view.matches[range].iter().enumerate().map(move |(ix, m)| { - view.render_match(m, start + ix, show_worktree_root_name, cx) - })); - }, - ); + fn dismiss(&mut self, cx: &mut ViewContext) { + cx.emit(Event::Dismissed); + } - Container::new(list.boxed()) - .with_margin_top(6.0) - .named("matches") + fn match_count(&self) -> usize { + self.matches.len() } - fn render_match( - &self, - string_match: &StringMatch, - index: usize, - show_worktree_root_name: bool, - cx: &AppContext, - ) -> ElementBox { + fn selected_index(&self) -> usize { + self.selected_match_index + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext) { + self.selected_match_index = ix; + cx.notify(); + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> Task<()> { + self.filter(&query, cx); + self.show_worktree_root_name = self.project.read(cx).visible_worktrees(cx).count() > 1; + let symbols = self + .project + .update(cx, |project, cx| project.symbols(&query, cx)); + cx.spawn_weak(|this, mut cx| async move { + let symbols = symbols.await.log_err(); + if let Some(this) = this.upgrade(&cx) { + if let Some(symbols) = symbols { + this.update(&mut cx, |this, cx| { + this.match_candidates = symbols + .iter() + .enumerate() + .map(|(id, symbol)| { + StringMatchCandidate::new( + id, + symbol.label.text[symbol.label.filter_range.clone()] + .to_string(), + ) + }) + .collect(); + this.symbols = symbols; + this.filter(&query, cx); + }); + } + } + }) + } + + fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox { + let string_match = &self.matches[ix]; let settings = cx.global::(); - let style = if index == self.selected_match_index { + let style = if selected { &settings.theme.selector.active_item } else { &settings.theme.selector.item @@ -284,7 +229,7 @@ impl ProjectSymbolsView { let syntax_runs = styled_runs_for_code_label(&symbol.label, &settings.theme.editor.syntax); let mut path = symbol.path.to_string_lossy(); - if show_worktree_root_name { + if self.show_worktree_root_name { let project = self.project.read(cx); if let Some(worktree) = project.worktree_for_id(symbol.worktree_id, cx) { path = Cow::Owned(format!( @@ -317,55 +262,4 @@ impl ProjectSymbolsView { .with_style(style.container) .boxed() } - - fn on_query_editor_event( - &mut self, - _: ViewHandle, - event: &editor::Event, - cx: &mut ViewContext, - ) { - match event { - editor::Event::Blurred => cx.emit(Event::Dismissed), - editor::Event::BufferEdited { .. } => self.update_matches(cx), - _ => {} - } - } - - fn on_event( - workspace: &mut Workspace, - _: ViewHandle, - event: &Event, - cx: &mut ViewContext, - ) { - match event { - Event::Dismissed => workspace.dismiss_modal(cx), - Event::Selected(symbol) => { - let buffer = workspace - .project() - .update(cx, |project, cx| project.open_buffer_for_symbol(symbol, cx)); - - let symbol = symbol.clone(); - cx.spawn(|workspace, mut cx| async move { - let buffer = buffer.await?; - workspace.update(&mut cx, |workspace, cx| { - let position = buffer - .read(cx) - .clip_point_utf16(symbol.range.start, Bias::Left); - - let editor = workspace.open_project_item::(buffer, cx); - editor.update(cx, |editor, cx| { - editor.select_ranges( - [position..position], - Some(Autoscroll::Center), - cx, - ); - }); - }); - Ok::<_, anyhow::Error>(()) - }) - .detach_and_log_err(cx); - workspace.dismiss_modal(cx); - } - } - } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index b3c239b4b9f119159f76a3ed23ec477069c6bbd8..c831738423af54b16dada92589838667dc819938 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -21,6 +21,7 @@ pub struct Theme { pub chat_panel: ChatPanel, pub contacts_panel: ContactsPanel, pub project_panel: ProjectPanel, + pub command_palette: CommandPalette, pub selector: Selector, pub editor: Editor, pub search: Search, @@ -187,6 +188,12 @@ pub struct ProjectPanelEntry { pub icon_spacing: f32, } +#[derive(Debug, Deserialize, Default)] +pub struct CommandPalette { + pub key: ContainedLabel, + pub keystroke_spacing: f32, +} + #[derive(Deserialize, Default)] pub struct ContactsPanel { #[serde(flatten)] @@ -259,7 +266,7 @@ pub struct ContainedText { pub text: TextStyle, } -#[derive(Clone, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default)] pub struct ContainedLabel { #[serde(flatten)] pub container: ContainerStyle, diff --git a/crates/theme_selector/Cargo.toml b/crates/theme_selector/Cargo.toml index 9993287c8580b65870e20736c51ea66ca87f90c6..804eff2c7a6cbab7035811391f17ff63ad34a8b8 100644 --- a/crates/theme_selector/Cargo.toml +++ b/crates/theme_selector/Cargo.toml @@ -11,6 +11,7 @@ doctest = false editor = { path = "../editor" } fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } +picker = { path = "../picker" } theme = { path = "../theme" } settings = { path = "../settings" } workspace = { path = "../workspace" } diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 37099eb510a9b476d59c396dd3b564a6acb581e8..f1e933774f8e0dfab2ff6b65b30fb5bb465432c7 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -1,35 +1,30 @@ -use editor::Editor; use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; use gpui::{ - actions, elements::*, keymap, AppContext, Axis, Element, ElementBox, Entity, MutableAppContext, + actions, elements::*, AppContext, Element, ElementBox, Entity, MutableAppContext, RenderContext, View, ViewContext, ViewHandle, }; +use picker::{Picker, PickerDelegate}; use settings::Settings; -use std::{cmp, sync::Arc}; +use std::sync::Arc; use theme::{Theme, ThemeRegistry}; -use workspace::{ - menu::{Confirm, SelectNext, SelectPrev}, - Workspace, -}; +use workspace::Workspace; pub struct ThemeSelector { - themes: Arc, + registry: Arc, + theme_names: Vec, matches: Vec, - query_editor: ViewHandle, - list_state: UniformListState, - selected_index: usize, original_theme: Arc, + picker: ViewHandle>, selection_completed: bool, + selected_index: usize, } actions!(theme_selector, [Toggle, Reload]); pub fn init(cx: &mut MutableAppContext) { - cx.add_action(ThemeSelector::confirm); - cx.add_action(ThemeSelector::select_prev); - cx.add_action(ThemeSelector::select_next); cx.add_action(ThemeSelector::toggle); cx.add_action(ThemeSelector::reload); + Picker::::init(cx); } pub enum Event { @@ -38,38 +33,38 @@ pub enum Event { impl ThemeSelector { fn new(registry: Arc, cx: &mut ViewContext) -> Self { - let query_editor = cx.add_view(|cx| { - Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx) - }); - - cx.subscribe(&query_editor, Self::on_query_editor_event) - .detach(); - + let handle = cx.weak_handle(); + let picker = cx.add_view(|cx| Picker::new(handle, cx)); let original_theme = cx.global::().theme.clone(); - + let theme_names = registry.list().collect::>(); + let matches = theme_names + .iter() + .map(|name| StringMatch { + candidate_id: 0, + score: 0.0, + positions: Default::default(), + string: name.clone(), + }) + .collect(); let mut this = Self { - themes: registry, - query_editor, - matches: Vec::new(), - list_state: Default::default(), - selected_index: 0, // Default index for now + registry, + theme_names, + matches, + picker, original_theme: original_theme.clone(), + selected_index: 0, selection_completed: false, }; - this.update_matches(cx); - - // Set selected index to current theme this.select_if_matching(&original_theme.name); - this } fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { let themes = workspace.themes(); workspace.toggle_modal(cx, |cx, _| { - let selector = cx.add_view(|cx| Self::new(themes, cx)); - cx.subscribe(&selector, Self::on_event).detach(); - selector + let this = cx.add_view(|cx| Self::new(themes, cx)); + cx.subscribe(&this, Self::on_event).detach(); + this }); } @@ -88,36 +83,9 @@ impl ThemeSelector { } } - fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - self.selection_completed = true; - cx.emit(Event::Dismissed); - } - - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - if self.selected_index > 0 { - self.selected_index -= 1; - } - self.list_state - .scroll_to(ScrollTarget::Show(self.selected_index)); - - self.show_selected_theme(cx); - cx.notify(); - } - - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - if self.selected_index + 1 < self.matches.len() { - self.selected_index += 1; - } - self.list_state - .scroll_to(ScrollTarget::Show(self.selected_index)); - - self.show_selected_theme(cx); - cx.notify(); - } - fn show_selected_theme(&mut self, cx: &mut ViewContext) { if let Some(mat) = self.matches.get(self.selected_index) { - match self.themes.get(&mat.string) { + match self.registry.get(&mat.string) { Ok(theme) => Self::set_theme(theme, cx), Err(error) => { log::error!("error loading theme {}: {}", mat.string, error) @@ -134,49 +102,6 @@ impl ThemeSelector { .unwrap_or(self.selected_index); } - fn update_matches(&mut self, cx: &mut ViewContext) { - let background = cx.background().clone(); - let candidates = self - .themes - .list() - .enumerate() - .map(|(id, name)| StringMatchCandidate { - id, - char_bag: name.as_str().into(), - string: name, - }) - .collect::>(); - let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx)); - - self.matches = if query.is_empty() { - candidates - .into_iter() - .enumerate() - .map(|(index, candidate)| StringMatch { - candidate_id: index, - string: candidate.string, - positions: Vec::new(), - score: 0.0, - }) - .collect() - } else { - smol::block_on(match_strings( - &candidates, - &query, - false, - 100, - &Default::default(), - background, - )) - }; - - self.selected_index = self - .selected_index - .min(self.matches.len().saturating_sub(1)); - - cx.notify(); - } - fn on_event( workspace: &mut Workspace, _: ViewHandle, @@ -190,89 +115,104 @@ impl ThemeSelector { } } - fn on_query_editor_event( - &mut self, - _: ViewHandle, - event: &editor::Event, - cx: &mut ViewContext, - ) { - match event { - editor::Event::BufferEdited { .. } => { - self.update_matches(cx); - self.select_if_matching(&cx.global::().theme.name); - self.show_selected_theme(cx); - } - editor::Event::Blurred => cx.emit(Event::Dismissed), - _ => {} - } + fn set_theme(theme: Arc, cx: &mut MutableAppContext) { + cx.update_global::(|settings, cx| { + settings.theme = theme; + cx.refresh_windows(); + }); } +} - fn render_matches(&self, cx: &mut RenderContext) -> ElementBox { - if self.matches.is_empty() { - let settings = cx.global::(); - return Container::new( - Label::new( - "No matches".into(), - settings.theme.selector.empty.label.clone(), - ) - .boxed(), - ) - .with_style(settings.theme.selector.empty.container) - .named("empty matches"); +impl PickerDelegate for ThemeSelector { + fn match_count(&self) -> usize { + self.matches.len() + } + + fn confirm(&mut self, cx: &mut ViewContext) { + self.selection_completed = true; + cx.emit(Event::Dismissed); + } + + fn dismiss(&mut self, cx: &mut ViewContext) { + if !self.selection_completed { + Self::set_theme(self.original_theme.clone(), cx); + self.selection_completed = true; } + cx.emit(Event::Dismissed); + } - let handle = cx.handle(); - let list = - UniformList::new( - self.list_state.clone(), - self.matches.len(), - move |mut range, items, cx| { - let cx = cx.as_ref(); - let selector = handle.upgrade(cx).unwrap(); - let selector = selector.read(cx); - let start = range.start; - range.end = cmp::min(range.end, selector.matches.len()); - items.extend(selector.matches[range].iter().enumerate().map( - move |(i, path_match)| selector.render_match(path_match, start + i, cx), - )); - }, - ); + fn selected_index(&self) -> usize { + self.selected_index + } - Container::new(list.boxed()) - .with_margin_top(6.0) - .named("matches") + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext) { + self.selected_index = ix; + self.show_selected_theme(cx); } - fn render_match(&self, theme_match: &StringMatch, index: usize, cx: &AppContext) -> ElementBox { - let settings = cx.global::(); - let theme = &settings.theme; + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> gpui::Task<()> { + let background = cx.background().clone(); + let candidates = self + .theme_names + .iter() + .enumerate() + .map(|(id, name)| StringMatchCandidate { + id, + char_bag: name.as_str().into(), + string: name.clone(), + }) + .collect::>(); - let container = Container::new( - Label::new( - theme_match.string.clone(), - if index == self.selected_index { - theme.selector.active_item.label.clone() - } else { - theme.selector.item.label.clone() - }, - ) - .with_highlights(theme_match.positions.clone()) - .boxed(), - ) - .with_style(if index == self.selected_index { - theme.selector.active_item.container - } else { - theme.selector.item.container - }); + cx.spawn(|this, mut cx| async move { + let matches = if query.is_empty() { + candidates + .into_iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string, + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + match_strings( + &candidates, + &query, + false, + 100, + &Default::default(), + background, + ) + .await + }; - container.boxed() + this.update(&mut cx, |this, cx| { + this.matches = matches; + this.selected_index = this + .selected_index + .min(this.matches.len().saturating_sub(1)); + this.show_selected_theme(cx); + cx.notify(); + }); + }) } - fn set_theme(theme: Arc, cx: &mut MutableAppContext) { - cx.update_global::(|settings, cx| { - settings.theme = theme; - cx.refresh_windows(); - }); + fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox { + let settings = cx.global::(); + let theme = &settings.theme; + let theme_match = &self.matches[ix]; + let style = if selected { + &theme.selector.active_item + } else { + &theme.selector.item + }; + + Label::new(theme_match.string.clone(), style.label.clone()) + .with_highlights(theme_match.positions.clone()) + .contained() + .with_style(style.container) + .boxed() } } @@ -291,43 +231,11 @@ impl View for ThemeSelector { "ThemeSelector" } - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let theme = cx.global::().theme.clone(); - Align::new( - ConstrainedBox::new( - Container::new( - Flex::new(Axis::Vertical) - .with_child( - ChildView::new(&self.query_editor) - .contained() - .with_style(theme.selector.input_editor.container) - .boxed(), - ) - .with_child( - FlexItem::new(self.render_matches(cx)) - .flex(1., false) - .boxed(), - ) - .boxed(), - ) - .with_style(theme.selector.container) - .boxed(), - ) - .with_max_width(600.0) - .with_max_height(400.0) - .boxed(), - ) - .top() - .named("theme selector") + fn render(&mut self, _: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone()).boxed() } fn on_focus(&mut self, cx: &mut ViewContext) { - cx.focus(&self.query_editor); - } - - fn keymap_context(&self, _: &AppContext) -> keymap::Context { - let mut cx = Self::default_keymap_context(); - cx.set.insert("menu".into()); - cx + cx.focus(&self.picker); } } diff --git a/crates/workspace/src/menu.rs b/crates/workspace/src/menu.rs index 33de4a677a3d0503c4dee46243ec1841bbaa449a..c37ad530bbfb13d0b2c9cc18e50b7f6ff62e1369 100644 --- a/crates/workspace/src/menu.rs +++ b/crates/workspace/src/menu.rs @@ -1,4 +1,16 @@ +#[derive(Clone)] +pub struct SelectIndex(pub usize); + gpui::actions!( menu, - [Confirm, SelectPrev, SelectNext, SelectFirst, SelectLast] + [ + Cancel, + Confirm, + SelectPrev, + SelectNext, + SelectFirst, + SelectLast + ] ); + +gpui::impl_internal_actions!(menu, [SelectIndex]); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 05efff0ebd64b397cd707ab3de19ca9e59175325..7411b53441ab7d5c48c934c3c7892add8652e1c5 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -33,6 +33,7 @@ assets = { path = "../assets" } breadcrumbs = { path = "../breadcrumbs" } chat_panel = { path = "../chat_panel" } collections = { path = "../collections" } +command_palette = { path = "../command_palette" } client = { path = "../client" } clock = { path = "../clock" } contacts_panel = { path = "../contacts_panel" } @@ -64,7 +65,6 @@ anyhow = "1.0.38" async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } async-recursion = "0.3" async-trait = "0.1" -crossbeam-channel = "0.5.0" ctor = "0.1.20" dirs = "3.0" easy-parallel = "3.1.0" diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 41e27eb49425513fc9377642b4af7a4f60eb31dd..cead3ac39040f2371ed8e3d3908820b1bf1d0afb 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -98,6 +98,7 @@ fn main() { project::Project::init(&client); client::Channel::init(&client); client::init(client.clone(), cx); + command_palette::init(cx); workspace::init(&client, cx); editor::init(cx); go_to_line::init(cx); diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index 03e527d03dec98755e84b789ad016a7aa67a3306..1f98f8ba9c52bbf01e3f61c24175a9e463478b60 100644 --- a/styles/src/styleTree/app.ts +++ b/styles/src/styleTree/app.ts @@ -2,6 +2,7 @@ import Theme from "../themes/theme"; import chatPanel from "./chatPanel"; import { text } from "./components"; import contactsPanel from "./contactsPanel"; +import commandPalette from "./commandPalette"; import editor from "./editor"; import projectPanel from "./projectPanel"; import search from "./search"; @@ -29,6 +30,7 @@ export default function app(theme: Theme): Object { }, }, }, + commandPalette: commandPalette(theme), projectPanel: projectPanel(theme), chatPanel: chatPanel(theme), contactsPanel: contactsPanel(theme), diff --git a/styles/src/styleTree/commandPalette.ts b/styles/src/styleTree/commandPalette.ts new file mode 100644 index 0000000000000000000000000000000000000000..00005d619796be416d32407d976571d267c26d77 --- /dev/null +++ b/styles/src/styleTree/commandPalette.ts @@ -0,0 +1,21 @@ +import Theme from "../themes/theme"; +import { text, backgroundColor, border } from "./components"; + +export default function commandPalette(theme: Theme) { + return { + keystrokeSpacing: 8, + key: { + text: text(theme, "mono", "primary", { size: "xs" }), + cornerRadius: 3, + background: backgroundColor(theme, "info", "base"), + border: border(theme, "info"), + padding: { + left: 3, + right: 3, + }, + margin: { + left: 3 + }, + } + } +} diff --git a/styles/src/styleTree/selectorModal.ts b/styles/src/styleTree/selectorModal.ts index 90570cc09374290dd842296e96f97ce0127be05d..1bb44a776fb7382b205ce7bd45db85afcd7aab47 100644 --- a/styles/src/styleTree/selectorModal.ts +++ b/styles/src/styleTree/selectorModal.ts @@ -38,7 +38,7 @@ export default function selectorModal(theme: Theme): Object { }, inputEditor: { background: backgroundColor(theme, 500), - corner_radius: 6, + cornerRadius: 6, placeholderText: text(theme, "sans", "placeholder"), selection: player(theme, 1).selection, text: text(theme, "mono", "primary"),