diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index e038a5b1dd..ae7f425a10 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -116,6 +116,7 @@ actions!( MoveToNextWord, Escape, ToggleCodeActions, + ShowCompletions, Search, Replace, GoToDefinition, diff --git a/crates/base/src/input/editor/lsp/completions.rs b/crates/base/src/input/editor/lsp/completions.rs index cd33147d6b..8c4ebfba15 100644 --- a/crates/base/src/input/editor/lsp/completions.rs +++ b/crates/base/src/input/editor/lsp/completions.rs @@ -9,7 +9,7 @@ use lsp_types::{ use ropey::Rope; use std::{cell::RefCell, ops::Range, rc::Rc, time::Duration}; -use crate::input::InputBaseState; +use crate::input::{InputBaseState, ShowCompletions}; /// Default debounce duration for inline completions. const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300); @@ -183,6 +183,93 @@ impl InputBaseState { ) .map(|s| s.trim().to_string()) .unwrap_or_default(); + self.request_completions( + provider, + start_offset, + query.clone(), + CompletionContext { + trigger_kind: lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(query), + }, + window, + cx, + ); + } + + pub(crate) fn on_action_show_completions( + &mut self, + _: &ShowCompletions, + window: &mut Window, + cx: &mut Context, + ) { + self.show_completions(window, cx); + } + + /// Request completions at the caret and focus the editor, without editing text. + /// + /// The current identifier prefix is used to refine the menu. An empty + /// prefix still asks the provider for suggestions. There is no default + /// keybinding; applications can dispatch [`ShowCompletions`] in the + /// `Input && mode == editor` context or call this method from a command. + /// + /// Disabled/readonly editors, IME preedit, and an already-open completion + /// menu are left alone. A new request cancels the previous overlay request + /// and inline suggestion, and closes the code-action menu. + pub fn show_completions(&mut self, window: &mut Window, cx: &mut Context) { + if self.disabled + || self.readonly + || self.completion_inserting + || self.ime_marked_range.is_some() + || self.extras.context_menu_content.completion.open + { + return; + } + let Some(provider) = self.extras.lsp.completion_provider.clone() else { + return; + }; + + let offset = self.cursor(); + let word_len: usize = self + .text + .chars_at(offset) + .reversed() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .map(char::len_utf8) + .sum(); + let start_offset = offset - word_len; + let query = self.text.slice(start_offset..offset).to_string(); + + self.hide_context_menu(cx); + self.clear_inline_completion(cx); + self.focus(window, cx); + + self.request_completions( + provider, + start_offset, + query, + CompletionContext { + trigger_kind: lsp_types::CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + window, + cx, + ); + } + + /// Record the word a menu is for and ask `provider` about the caret. + fn request_completions( + &mut self, + provider: Rc, + start_offset: usize, + query: String, + completion_context: CompletionContext, + window: &mut Window, + cx: &mut Context, + ) { + let new_offset = self.cursor(); + let revision = self.document_revision; + self.extras.context_menu_task = Task::ready(Ok(())); + self.extras.context_menu_content.code_action.open = false; self.extras .context_menu_content .completion @@ -193,11 +280,6 @@ impl InputBaseState { .query .clone_from(&query); - let completion_context = CompletionContext { - trigger_kind: lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER, - trigger_character: Some(query), - }; - let provider_responses = provider.completions(&self.text, new_offset, completion_context, window, cx); self.extras.context_menu_task = cx.spawn_in(window, async move |editor, cx| { @@ -209,31 +291,21 @@ impl InputBaseState { } } - if completions.is_empty() { - editor.update(cx, |editor, cx| { - editor.extras.context_menu_content.completion.open = false; - editor.extras.context_menu_content.completion.items.clear(); - editor.extras.context_menu_content.completion.bump(); - cx.notify(); - })?; - return Ok(()); - } - editor .update_in(cx, |editor, window, cx| { - if !editor.focus_handle.is_focused(window) { + if !editor.focus_handle.is_focused(window) + || editor.document_revision != revision + || editor.cursor() != new_offset + || editor.ime_marked_range.is_some() + || editor.disabled + || editor.readonly + { return; } + editor.extras.context_menu_content.completion.open = !completions.is_empty(); editor.extras.context_menu_content.completion.items = completions; - editor.extras.context_menu_content.completion.open = !editor - .extras - .context_menu_content - .completion - .items - .is_empty(); editor.extras.context_menu_content.completion.bump(); - cx.notify(); }) .ok(); diff --git a/crates/base/src/input/editor/mod.rs b/crates/base/src/input/editor/mod.rs index 1ee09d0a4b..6850df71b2 100644 --- a/crates/base/src/input/editor/mod.rs +++ b/crates/base/src/input/editor/mod.rs @@ -186,7 +186,9 @@ impl InputModeKind for EditorMode { window: &mut Window, ) -> Stateful
{ element + .key_context("Input mode=editor") .on_action(window.listener_for(entity, InputBaseState::on_action_toggle_code_actions)) + .on_action(window.listener_for(entity, InputBaseState::on_action_show_completions)) .on_action(window.listener_for(entity, InputBaseState::on_action_go_to_definition)) } } diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index 569dab883b..4ef229f8f9 100644 --- a/crates/component/src/input/mod.rs +++ b/crates/component/src/input/mod.rs @@ -34,8 +34,8 @@ pub use gpui_base::input::{ MoveUp, Outdent, OutdentInline, Paste, Point, RangeDecoration, RangeDecorationCollection, RangeDecorationStyle, Redo, Replace, Rope, RopeExt, RopeLines, Search, SelectAll, SelectToEnd, SelectToEndOfLine, SelectToNextWordEnd, SelectToPreviousWordStart, SelectToStart, - SelectToStartOfLine, Selection, ShowCharacterPalette, ShowDocumentHandler, TabSize, - TextDecoration, TextDecorationCollection, TextareaState, ToggleCodeActions, Undo, + SelectToStartOfLine, Selection, ShowCharacterPalette, ShowCompletions, ShowDocumentHandler, + TabSize, TextDecoration, TextDecorationCollection, TextareaState, ToggleCodeActions, Undo, WrappingIndent, }; pub use gpui_base::input::{EditorMode, InputMode, InputModeKind, TextareaMode}; diff --git a/crates/kit/tests/input/completions.rs b/crates/kit/tests/input/completions.rs index f28619ff90..ff2d95db1e 100644 --- a/crates/kit/tests/input/completions.rs +++ b/crates/kit/tests/input/completions.rs @@ -14,7 +14,7 @@ use gpui_kit::{ Window, WindowHandle, component::input::{ CodeActionProvider, CompletionProvider, Editor, EditorState, Input, InputEvent, InputState, - Rope, + Rope, ShowCompletions, }, div, prelude::*, @@ -286,6 +286,14 @@ impl Fixture { self.settle(cx); } + fn show_completions(&self, cx: &mut TestAppContext) { + cx.update_window(self.handle.into(), |_, window, cx| { + window.dispatch_action(Box::new(ShowCompletions), cx); + }) + .unwrap(); + self.settle(cx); + } + fn assert_editor(&self, value: &str, cx: &mut TestAppContext) { cx.update_window(self.handle.into(), |_, window, cx| { window.render_frame(cx); @@ -341,6 +349,267 @@ fn escape_cancels_completion_and_preserves_editor_text_and_focus(cx: &mut TestAp assert_eq!(fixture.provider.requests.borrow().len(), 1); } +#[gpui_kit::test] +fn manual_opens_completion_for_the_word_before_the_caret(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.start_completion(cx); + fixture.input("ri", cx); + fixture.press("escape", cx); + fixture.assert_editor("pri", cx); + let typed = fixture.provider.requests.borrow().len(); + + fixture.show_completions(cx); + assert_eq!( + fixture.provider.requests.borrow().get(typed), + Some(&CompletionRequest { + text: "pri".into(), + offset: 3, + trigger: CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + }) + ); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); +} + +#[gpui_kit::test] +fn manual_with_no_word_before_the_caret_asks_with_an_empty_query(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.show_completions(cx); + assert_eq!( + *fixture.provider.requests.borrow(), + vec![CompletionRequest { + text: "".into(), + offset: 0, + trigger: CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + }] + ); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); +} + +#[gpui_kit::test] +fn manual_asks_nothing_of_a_readonly_editor(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.protect(true, false, cx); + fixture.show_completions(cx); + assert!(fixture.provider.requests.borrow().is_empty()); +} + +#[gpui_kit::test] +fn manual_completion_has_no_default_shortcut_and_host_bindings_are_editor_scoped( + cx: &mut TestAppContext, +) { + let fixture = Fixture::new(cx); + fixture.press("ctrl-space", cx); + assert!(fixture.provider.requests.borrow().is_empty()); + cx.update(|cx| { + cx.bind_keys([gpui_kit::KeyBinding::new( + "ctrl-alt-j", + ShowCompletions, + Some("Input && mode == editor"), + )]) + }); + cx.update_window(fixture.handle.into(), |_, window, cx| { + window.click("other", cx); + window.press("ctrl-alt-j", cx); + }) + .unwrap(); + fixture.settle(cx); + assert!(fixture.provider.requests.borrow().is_empty()); + cx.update_window(fixture.handle.into(), |_, window, cx| { + window.click(("input", fixture.state.entity_id()), cx); + window.press("ctrl-alt-j", cx); + }) + .unwrap(); + fixture.settle(cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); +} + +#[gpui_kit::test] +fn manual_completion_keeps_an_open_menu_and_its_selected_item(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.show_completions(cx); + fixture.press("down", cx); + fixture.show_completions(cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); + fixture.press("enter", cx); + fixture.assert_editor("println", cx); +} + +#[gpui_kit::test] +fn manual_completion_cancels_an_older_pending_request(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.show_completions(cx); + fixture.show_completions(cx); + assert_eq!(fixture.provider.requests.borrow().len(), 2); + fixture.provider.respond(0, Some("private")); + fixture.settle(cx); + fixture.provider.respond(1, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); +} + +#[gpui_kit::test] +fn manual_completion_is_cancelled_by_escape_or_a_caret_round_trip(cx: &mut TestAppContext) { + for escape in [true, false] { + let fixture = Fixture::deferred(cx); + fixture.set_value("pri", cx); + fixture.select(3..3, cx); + fixture.show_completions(cx); + if escape { + fixture.press("escape", cx); + } else { + fixture.press("left", cx); + fixture.press("right", cx); + } + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("pri\n", cx); + } +} + +#[gpui_kit::test] +fn manual_completion_does_not_request_during_ime_preedit(cx: &mut TestAppContext) { + use gpui_kit::{ElementInputHandler, InputHandler as _}; + let fixture = Fixture::new(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + let bounds = window.find(("input", fixture.state.entity_id())).bounds(); + let mut handler = ElementInputHandler::new(bounds, fixture.state.clone()); + handler.replace_and_mark_text_in_range(None, "中", Some(1..1), window, cx); + assert_eq!(handler.marked_text_range(window, cx), Some(0..1)); + }) + .unwrap(); + fixture.settle(cx); + fixture.show_completions(cx); + assert!(fixture.provider.requests.borrow().is_empty()); + cx.update_window(fixture.handle.into(), |_, window, cx| { + let bounds = window.find(("input", fixture.state.entity_id())).bounds(); + let mut handler = ElementInputHandler::new(bounds, fixture.state.clone()); + handler.replace_text_in_range(None, "中", window, cx); + assert_eq!(handler.marked_text_range(window, cx), None); + }) + .unwrap(); + fixture.settle(cx); + fixture.show_completions(cx); + let requests = fixture.provider.requests.borrow(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].offset, 3); + assert_eq!( + requests[0].trigger.trigger_kind, + CompletionTriggerKind::INVOKED + ); + assert_eq!(requests[0].trigger.trigger_character, None); + fixture.state.read_with(cx, |state, _| { + assert_eq!(state.completion_menu_state().query, "中") + }); +} + +#[gpui_kit::test] +fn manual_completion_ignores_disabled_editors_and_missing_providers(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.protect(false, true, cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + fixture + .state + .update(cx, |state, cx| state.show_completions(window, cx)); + }) + .unwrap(); + assert!(fixture.provider.requests.borrow().is_empty()); + fixture.protect(false, false, cx); + fixture + .state + .update(cx, |state, _| state.lsp_mut().completion_provider = None); + fixture.show_completions(cx); + assert!(fixture.provider.requests.borrow().is_empty()); +} + +#[gpui_kit::test] +fn manual_completion_clears_inline_suggestions_before_a_pending_request(cx: &mut TestAppContext) { + let (fixture, inline) = inline_fixture(cx, false); + let provider = Rc::new(Suggestions { + deferred: true, + ..Default::default() + }); + fixture.state.update(cx, |state, _| { + state.lsp_mut().completion_provider = Some(provider.clone()) + }); + fixture.show_completions(cx); + fixture.press("tab", cx); + fixture.assert_editor("p ", cx); + assert_eq!(inline.requests.borrow().len(), 1); + provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p \n", cx); +} + +#[gpui_kit::test] +fn manual_completion_cancels_pending_inline_debounce(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + let inline = Rc::new(InlineSuggestions::default()); + fixture.state.update(cx, |state, _| { + state.lsp_mut().completion_provider = Some(inline.clone()) + }); + fixture.input("p", cx); + assert!(inline.requests.borrow().is_empty()); + fixture.state.update(cx, |state, _| { + state.lsp_mut().completion_provider = Some(fixture.provider.clone()) + }); + fixture.show_completions(cx); + cx.background_executor + .advance_clock(std::time::Duration::from_millis(1)); + fixture.settle(cx); + assert!(inline.requests.borrow().is_empty()); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); +} + +#[gpui_kit::test] +fn manual_completion_replaces_the_code_action_menu(cx: &mut TestAppContext) { + let actions = Actions::new("Action", false); + let fixture = Fixture::new(cx); + fixture.state.update(cx, |state, _| { + state.lsp_mut().code_action_providers = vec![actions.clone()] + }); + fixture.press(CODE_ACTIONS, cx); + fixture + .state + .read_with(cx, |state, _| assert!(state.code_action_menu_state().open)); + fixture.show_completions(cx); + fixture + .state + .read_with(cx, |state, _| assert!(!state.code_action_menu_state().open)); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); + assert!(actions.performed.borrow().is_empty()); +} + +#[gpui_kit::test] +fn manual_completion_api_focuses_the_editor_from_an_application_command(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + window.click("other", cx); + assert_eq!(window.find("other").focused(), Some(true)); + fixture + .state + .update(cx, |state, cx| state.show_completions(window, cx)); + }) + .unwrap(); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); +} + #[gpui_kit::test] fn arrow_navigation_accepts_the_selected_completion(cx: &mut TestAppContext) { let fixture = Fixture::new(cx); diff --git a/website/component/editor.md b/website/component/editor.md index f4fb532335..bc972f47cd 100644 --- a/website/component/editor.md +++ b/website/component/editor.md @@ -118,6 +118,35 @@ let editor = cx.new(|cx| { }); ``` +## Manual completions + +`ShowCompletions` requests suggestions without editing text. It has **no default +shortcut**, so applications can choose bindings that fit the platform and avoid +input-method shortcuts such as Ctrl+Space. Editor bindings can use the context +expression `Input && mode == editor`; existing `Input` bindings continue to work. + +```rust +use gpui_kit::component::input::ShowCompletions; + +// Dispatch to the focused editor from an application command. +window.dispatch_action(Box::new(ShowCompletions), cx); + +// Or address a specific editor; this also focuses it. +editor.update(cx, |state, cx| state.show_completions(window, cx)); +``` + +The menu refines against the identifier prefix before the caret, using the same +letter, digit, and underscore boundaries as the editor's word helpers. An empty +prefix still requests suggestions. Manual requests use the LSP `INVOKED` trigger +kind without a trigger character. Disabled/readonly editors, editors without a +provider, and IME preedit do not issue a request. After committing IME text, the +command is available again. + +If the completion menu is already open, the command preserves its selection. +Otherwise it cancels the previous overlay request and inline suggestion and +closes code actions before requesting completions. Late results cannot reopen +suggestions after editing, moving the caret, dismissing, or losing focus. + ## Keyboard shortcuts and column selection These defaults apply while the editor is focused. On macOS, Option is the Alt diff --git a/website/zh-CN/component/editor.md b/website/zh-CN/component/editor.md index 650fb9d3ad..8fb4e2b228 100644 --- a/website/zh-CN/component/editor.md +++ b/website/zh-CN/component/editor.md @@ -104,6 +104,24 @@ let editor = cx.new(|cx| { }); ``` +## 主动补全 + +`ShowCompletions` 可以在不修改文本的情况下请求补全,**没有默认快捷键**。应用应结合平台习惯选择绑定,避开 Ctrl+Space 等输入法快捷键。Editor 的绑定可以使用 `Input && mode == editor` 条件;现有 `Input` 绑定仍然有效。 + +```rust +use gpui_kit::component::input::ShowCompletions; + +// 从应用命令分发给当前聚焦的 Editor。 +window.dispatch_action(Box::new(ShowCompletions), cx); + +// 也可以指定 Editor;此方法同时将焦点交给它。 +editor.update(cx, |state, cx| state.show_completions(window, cx)); +``` + +菜单使用光标前的标识符前缀,沿用编辑器按字母、数字和下划线识别单词的规则;前缀为空时仍会请求建议。手动请求使用 LSP 的 `INVOKED` 类型,不传触发字符。禁用、只读、没有 provider 或处于 IME 预编辑状态的 Editor 不会发出请求。IME 文本提交后,可以再次调用命令。 + +补全菜单已打开时,命令保留当前选择。否则,它会取消上一轮浮层请求和 inline suggestion,并关闭 code action 菜单,再请求补全。编辑、移动光标、关闭菜单或失去焦点后,迟到的结果不会重新打开补全。 + ## 快捷键与矩形列选 以下默认快捷键在编辑器聚焦时生效。macOS 的 Option 对应 Alt 修饰键;Linux 的这些操作不使用 Super/Win。