Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-view/src/ui/completion.rs')
-rw-r--r--helix-view/src/ui/completion.rs415
1 files changed, 415 insertions, 0 deletions
diff --git a/helix-view/src/ui/completion.rs b/helix-view/src/ui/completion.rs
new file mode 100644
index 00000000..c6ffe462
--- /dev/null
+++ b/helix-view/src/ui/completion.rs
@@ -0,0 +1,415 @@
+use crate::compositor::{self, Component, Context, Event, EventResult};
+use crate::editor::CompleteAction;
+
+use std::borrow::Cow;
+
+use crate::{
+ graphics::Rect,
+ input::{KeyCode, KeyEvent},
+ Document, Editor,
+};
+use helix_core::{Change, Transaction};
+
+use crate::commands;
+use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
+
+use helix_lsp::{lsp, util};
+use lsp::CompletionItem;
+
+impl menu::Item for CompletionItem {
+ fn sort_text(&self) -> &str {
+ self.filter_text.as_ref().unwrap_or(&self.label).as_str()
+ }
+
+ fn filter_text(&self) -> &str {
+ self.filter_text.as_ref().unwrap_or(&self.label).as_str()
+ }
+
+ fn label(&self) -> &str {
+ self.label.as_str()
+ }
+
+ fn row(&self) -> menu::Row {
+ menu::Row::new(vec![
+ menu::Cell::from(self.label.as_str()),
+ menu::Cell::from(match self.kind {
+ Some(lsp::CompletionItemKind::TEXT) => "text",
+ Some(lsp::CompletionItemKind::METHOD) => "method",
+ Some(lsp::CompletionItemKind::FUNCTION) => "function",
+ Some(lsp::CompletionItemKind::CONSTRUCTOR) => "constructor",
+ Some(lsp::CompletionItemKind::FIELD) => "field",
+ Some(lsp::CompletionItemKind::VARIABLE) => "variable",
+ Some(lsp::CompletionItemKind::CLASS) => "class",
+ Some(lsp::CompletionItemKind::INTERFACE) => "interface",
+ Some(lsp::CompletionItemKind::MODULE) => "module",
+ Some(lsp::CompletionItemKind::PROPERTY) => "property",
+ Some(lsp::CompletionItemKind::UNIT) => "unit",
+ Some(lsp::CompletionItemKind::VALUE) => "value",
+ Some(lsp::CompletionItemKind::ENUM) => "enum",
+ Some(lsp::CompletionItemKind::KEYWORD) => "keyword",
+ Some(lsp::CompletionItemKind::SNIPPET) => "snippet",
+ Some(lsp::CompletionItemKind::COLOR) => "color",
+ Some(lsp::CompletionItemKind::FILE) => "file",
+ Some(lsp::CompletionItemKind::REFERENCE) => "reference",
+ Some(lsp::CompletionItemKind::FOLDER) => "folder",
+ Some(lsp::CompletionItemKind::ENUM_MEMBER) => "enum_member",
+ Some(lsp::CompletionItemKind::CONSTANT) => "constant",
+ Some(lsp::CompletionItemKind::STRUCT) => "struct",
+ Some(lsp::CompletionItemKind::EVENT) => "event",
+ Some(lsp::CompletionItemKind::OPERATOR) => "operator",
+ Some(lsp::CompletionItemKind::TYPE_PARAMETER) => "type_param",
+ Some(kind) => unimplemented!("{:?}", kind),
+ None => "",
+ }),
+ // self.detail.as_deref().unwrap_or("")
+ // self.label_details
+ // .as_ref()
+ // .or(self.detail())
+ // .as_str(),
+ ])
+ }
+}
+
+/// Wraps a Menu.
+pub struct Completion {
+ popup: Popup<Menu<CompletionItem>>,
+ start_offset: usize,
+ #[allow(dead_code)]
+ trigger_offset: usize,
+ // TODO: maintain a completioncontext with trigger kind & trigger char
+}
+
+impl Completion {
+ pub fn new(
+ editor: &Editor,
+ items: Vec<CompletionItem>,
+ offset_encoding: helix_lsp::OffsetEncoding,
+ start_offset: usize,
+ trigger_offset: usize,
+ ) -> Self {
+ let menu = Menu::new(items, move |editor: &mut Editor, item, event| {
+ fn item_to_transaction(
+ doc: &Document,
+ item: &CompletionItem,
+ offset_encoding: helix_lsp::OffsetEncoding,
+ start_offset: usize,
+ trigger_offset: usize,
+ ) -> Transaction {
+ let transaction = if let Some(edit) = &item.text_edit {
+ let edit = match edit {
+ lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
+ lsp::CompletionTextEdit::InsertAndReplace(item) => {
+ unimplemented!("completion: insert_and_replace {:?}", item)
+ }
+ };
+
+ util::generate_transaction_from_edits(
+ doc.text(),
+ vec![edit],
+ offset_encoding, // TODO: should probably transcode in Client
+ )
+ } else {
+ let text = item.insert_text.as_ref().unwrap_or(&item.label);
+ // Some LSPs just give you an insertText with no offset ¯\_(ツ)_/¯
+ // in these cases we need to check for a common prefix and remove it
+ let prefix = Cow::from(doc.text().slice(start_offset..trigger_offset));
+ let text = text.trim_start_matches::<&str>(&prefix);
+ Transaction::change(
+ doc.text(),
+ vec![(trigger_offset, trigger_offset, Some(text.into()))].into_iter(),
+ )
+ };
+
+ transaction
+ }
+
+ fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec<Change> {
+ transaction
+ .changes_iter()
+ .filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
+ .collect()
+ }
+
+ let (view, doc) = current!(editor);
+
+ // if more text was entered, remove it
+ doc.restore(view.id);
+
+ match event {
+ PromptEvent::Abort => {
+ doc.restore(view.id);
+ editor.last_completion = None;
+ }
+ PromptEvent::Update => {
+ // always present here
+ let item = item.unwrap();
+
+ let transaction = item_to_transaction(
+ doc,
+ item,
+ offset_encoding,
+ start_offset,
+ trigger_offset,
+ );
+
+ // initialize a savepoint
+ doc.savepoint();
+ doc.apply(&transaction, view.id);
+
+ editor.last_completion = Some(CompleteAction {
+ trigger_offset,
+ changes: completion_changes(&transaction, trigger_offset),
+ });
+ }
+ PromptEvent::Validate => {
+ // always present here
+ let item = item.unwrap();
+
+ let transaction = item_to_transaction(
+ doc,
+ item,
+ offset_encoding,
+ start_offset,
+ trigger_offset,
+ );
+
+ doc.apply(&transaction, view.id);
+
+ editor.last_completion = Some(CompleteAction {
+ trigger_offset,
+ changes: completion_changes(&transaction, trigger_offset),
+ });
+
+ // apply additional edits, mostly used to auto import unqualified types
+ let resolved_item = if item
+ .additional_text_edits
+ .as_ref()
+ .map(|edits| !edits.is_empty())
+ .unwrap_or(false)
+ {
+ None
+ } else {
+ Self::resolve_completion_item(doc, item.clone())
+ };
+
+ if let Some(additional_edits) = resolved_item
+ .as_ref()
+ .and_then(|item| item.additional_text_edits.as_ref())
+ .or(item.additional_text_edits.as_ref())
+ {
+ if !additional_edits.is_empty() {
+ let transaction = util::generate_transaction_from_edits(
+ doc.text(),
+ additional_edits.clone(),
+ offset_encoding, // TODO: should probably transcode in Client
+ );
+ doc.apply(&transaction, view.id);
+ }
+ }
+ }
+ };
+ });
+ let popup = Popup::new("completion", menu);
+ let mut completion = Self {
+ popup,
+ start_offset,
+ trigger_offset,
+ };
+
+ // need to recompute immediately in case start_offset != trigger_offset
+ completion.recompute_filter(editor);
+
+ completion
+ }
+
+ fn resolve_completion_item(
+ doc: &Document,
+ completion_item: lsp::CompletionItem,
+ ) -> Option<CompletionItem> {
+ let language_server = doc.language_server()?;
+ let completion_resolve_provider = language_server
+ .capabilities()
+ .completion_provider
+ .as_ref()?
+ .resolve_provider;
+ if completion_resolve_provider != Some(true) {
+ return None;
+ }
+
+ let future = language_server.resolve_completion_item(completion_item);
+ let response = helix_lsp::block_on(future);
+ match response {
+ Ok(completion_item) => Some(completion_item),
+ Err(err) => {
+ log::error!("execute LSP command: {}", err);
+ None
+ }
+ }
+ }
+
+ pub fn recompute_filter(&mut self, editor: &Editor) {
+ // recompute menu based on matches
+ let menu = self.popup.contents_mut();
+ let (view, doc) = current_ref!(editor);
+
+ // cx.hooks()
+ // cx.add_hook(enum type, ||)
+ // cx.trigger_hook(enum type, &str, ...) <-- there has to be enough to identify doc/view
+ // callback with editor & compositor
+ //
+ // trigger_hook sends event into channel, that's consumed in the global loop and
+ // triggers all registered callbacks
+ // TODO: hooks should get processed immediately so maybe do it after select!(), before
+ // looping?
+
+ let cursor = doc
+ .selection(view.id)
+ .primary()
+ .cursor(doc.text().slice(..));
+ if self.trigger_offset <= cursor {
+ let fragment = doc.text().slice(self.start_offset..cursor);
+ let text = Cow::from(fragment);
+ // TODO: logic is same as ui/picker
+ menu.score(&text);
+ } else {
+ // we backspaced before the start offset, clear the menu
+ // this will cause the editor to remove the completion popup
+ menu.clear();
+ }
+ }
+
+ pub fn update(&mut self, cx: &mut commands::Context) {
+ self.recompute_filter(cx.editor)
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.popup.contents().is_empty()
+ }
+}
+
+impl Component for Completion {
+ fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
+ // let the Editor handle Esc instead
+ if let Event::Key(KeyEvent {
+ code: KeyCode::Esc, ..
+ }) = event
+ {
+ return EventResult::Ignored(None);
+ }
+ self.popup.handle_event(event, cx)
+ }
+
+ fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
+ self.popup.required_size(viewport)
+ }
+}
+
+#[cfg(feature = "term")]
+impl compositor::term::Render for Completion {
+ fn render(&mut self, area: Rect, cx: &mut compositor::term::RenderContext<'_>) {
+ self.popup.render(area, cx);
+
+ // if we have a selection, render a markdown popup on top/below with info
+ if let Some(option) = self.popup.contents().selection() {
+ // need to render:
+ // option.detail
+ // ---
+ // option.documentation
+
+ let (view, doc) = current_ref!(cx.editor);
+ let language = doc
+ .language()
+ .and_then(|scope| scope.strip_prefix("source."))
+ .unwrap_or("");
+ let text = doc.text().slice(..);
+ let cursor_pos = doc.selection(view.id).primary().cursor(text);
+ let coords = helix_core::visual_coords_at_pos(text, cursor_pos, doc.tab_width());
+ let cursor_pos = (coords.row - view.offset.row) as u16;
+
+ let mut markdown_doc = match &option.documentation {
+ Some(lsp::Documentation::String(contents))
+ | Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
+ kind: lsp::MarkupKind::PlainText,
+ value: contents,
+ })) => {
+ // TODO: convert to wrapped text
+ Markdown::new(
+ format!(
+ "```{}\n{}\n```\n{}",
+ language,
+ option.detail.as_deref().unwrap_or_default(),
+ contents.clone()
+ ),
+ cx.editor.syn_loader.clone(),
+ )
+ }
+ Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
+ kind: lsp::MarkupKind::Markdown,
+ value: contents,
+ })) => {
+ // TODO: set language based on doc scope
+ Markdown::new(
+ format!(
+ "```{}\n{}\n```\n{}",
+ language,
+ option.detail.as_deref().unwrap_or_default(),
+ contents.clone()
+ ),
+ cx.editor.syn_loader.clone(),
+ )
+ }
+ None if option.detail.is_some() => {
+ // TODO: copied from above
+
+ // TODO: set language based on doc scope
+ Markdown::new(
+ format!(
+ "```{}\n{}\n```",
+ language,
+ option.detail.as_deref().unwrap_or_default(),
+ ),
+ cx.editor.syn_loader.clone(),
+ )
+ }
+ None => return,
+ };
+
+ let (popup_x, popup_y) = self.popup.get_rel_position(area, cx.editor);
+ let (popup_width, _popup_height) = self.popup.get_size();
+ let mut width = area
+ .width
+ .saturating_sub(popup_x)
+ .saturating_sub(popup_width);
+ let area = if width > 30 {
+ let mut height = area.height.saturating_sub(popup_y);
+ let x = popup_x + popup_width;
+ let y = popup_y;
+
+ if let Some((rel_width, rel_height)) = markdown_doc.required_size((width, height)) {
+ width = rel_width.min(width);
+ height = rel_height.min(height);
+ }
+ Rect::new(x, y, width, height)
+ } else {
+ let half = area.height / 2;
+ let height = 15.min(half);
+ // we want to make sure the cursor is visible (not hidden behind the documentation)
+ let y = if cursor_pos + area.y
+ >= (cx.editor.tree.area().height - height - 2/* statusline + commandline */)
+ {
+ 0
+ } else {
+ // -2 to subtract command line + statusline. a bit of a hack, because of splits.
+ area.height.saturating_sub(height).saturating_sub(2)
+ };
+
+ Rect::new(0, y, area.width, height)
+ };
+
+ // clear area
+ let background = cx.editor.theme.get("ui.popup");
+ cx.surface.clear_with(area, background);
+ markdown_doc.render(area, cx);
+ }
+ }
+}