8664d70e73
This is necessary to workaround ownership issues across function calls. The issue notably arised when implementing the registers into `Editor` and I was getting annoyed again when implementing copy/pasting into system clipboard. The problem is addressed by using macro calls instead of function calls. There is no notable side effect.
292 lines
12 KiB
Rust
292 lines
12 KiB
Rust
use crate::compositor::{Component, Compositor, Context, EventResult};
|
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
|
use tui::{
|
|
buffer::Buffer as Surface,
|
|
layout::Rect,
|
|
style::{Color, Style},
|
|
};
|
|
|
|
use std::borrow::Cow;
|
|
|
|
use helix_core::{Position, Transaction};
|
|
use helix_view::Editor;
|
|
|
|
use crate::commands;
|
|
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
|
|
|
|
use helix_lsp::lsp;
|
|
use lsp::CompletionItem;
|
|
|
|
impl menu::Item for CompletionItem {
|
|
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::EnumMember) => "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::TypeParameter) => "type_param",
|
|
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>>, // TODO: Popup<Menu> need to be able to access contents.
|
|
trigger_offset: usize,
|
|
// TODO: maintain a completioncontext with trigger kind & trigger char
|
|
}
|
|
|
|
impl Completion {
|
|
pub fn new(
|
|
items: Vec<CompletionItem>,
|
|
offset_encoding: helix_lsp::OffsetEncoding,
|
|
trigger_offset: usize,
|
|
) -> Self {
|
|
// let items: Vec<CompletionItem> = Vec::new();
|
|
let mut menu = Menu::new(items, move |editor: &mut Editor, item, event| {
|
|
match event {
|
|
PromptEvent::Abort => {
|
|
// revert state
|
|
// let id = editor.view().doc;
|
|
// let doc = &mut editor.documents[id];
|
|
// doc.state = snapshot.clone();
|
|
}
|
|
PromptEvent::Validate => {
|
|
let (view, doc) = current!(editor);
|
|
|
|
// revert state to what it was before the last update
|
|
// doc.state = snapshot.clone();
|
|
|
|
// extract as fn(doc, item):
|
|
|
|
// TODO: need to apply without composing state...
|
|
// TODO: need to update lsp on accept/cancel by diffing the snapshot with
|
|
// the final state?
|
|
// -> on update simply update the snapshot, then on accept redo the call,
|
|
// finally updating doc.changes + notifying lsp.
|
|
//
|
|
// or we could simply use doc.undo + apply when changing between options
|
|
|
|
// always present here
|
|
let item = item.unwrap();
|
|
|
|
use helix_lsp::{lsp, util};
|
|
|
|
// if more text was entered, remove it
|
|
let cursor = doc.selection(view.id).cursor();
|
|
if trigger_offset < cursor {
|
|
let remove = Transaction::change(
|
|
doc.text(),
|
|
vec![(trigger_offset, cursor, None)].into_iter(),
|
|
);
|
|
doc.apply(&remove, view.id);
|
|
}
|
|
|
|
use helix_lsp::OffsetEncoding;
|
|
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);
|
|
let cursor = doc.selection(view.id).cursor();
|
|
Transaction::change(
|
|
doc.text(),
|
|
vec![(cursor, cursor, Some(text.as_str().into()))].into_iter(),
|
|
)
|
|
};
|
|
|
|
doc.apply(&transaction, view.id);
|
|
|
|
// TODO: merge edit with additional_text_edits
|
|
if let Some(additional_edits) = &item.additional_text_edits {
|
|
// gopls uses this to add extra imports
|
|
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(menu);
|
|
Self {
|
|
popup,
|
|
trigger_offset,
|
|
}
|
|
}
|
|
|
|
pub fn update(&mut self, cx: &mut commands::Context) {
|
|
// recompute menu based on matches
|
|
let menu = self.popup.contents_mut();
|
|
let (view, doc) = current!(cx.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).cursor();
|
|
if self.trigger_offset <= cursor {
|
|
let fragment = doc.text().slice(self.trigger_offset..cursor);
|
|
let text = Cow::from(fragment);
|
|
// TODO: logic is same as ui/picker
|
|
menu.score(&text);
|
|
}
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.popup.contents().is_empty()
|
|
}
|
|
}
|
|
|
|
// need to:
|
|
// - trigger on the right trigger char
|
|
// - detect previous open instance and recycle
|
|
// - update after input, but AFTER the document has changed
|
|
// - if no more matches, need to auto close
|
|
//
|
|
// missing bits:
|
|
// - a more robust hook system: emit to a channel, process in main loop
|
|
// - a way to find specific layers in compositor
|
|
// - components register for hooks, then unregister when terminated
|
|
// ... since completion is a special case, maybe just build it into doc/render?
|
|
|
|
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;
|
|
}
|
|
self.popup.handle_event(event, cx)
|
|
}
|
|
|
|
fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
|
|
self.popup.required_size(viewport)
|
|
}
|
|
|
|
fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) {
|
|
self.popup.render(area, surface, cx);
|
|
|
|
// TODO: 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!(cx.editor);
|
|
let language = doc
|
|
.language()
|
|
.and_then(|scope| scope.strip_prefix("source."))
|
|
.unwrap_or("");
|
|
|
|
let 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()
|
|
))
|
|
}
|
|
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()
|
|
))
|
|
}
|
|
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(),
|
|
))
|
|
}
|
|
None => return,
|
|
};
|
|
|
|
let half = area.height / 2;
|
|
let height = 15.min(half);
|
|
// -2 to subtract command line + statusline. a bit of a hack, because of splits.
|
|
let area = Rect::new(0, area.height - height - 2, area.width, height);
|
|
|
|
// clear area
|
|
let background = cx.editor.theme.get("ui.popup");
|
|
surface.clear_with(area, background);
|
|
doc.render(area, surface, cx);
|
|
}
|
|
}
|
|
}
|