use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter, Half as _, HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce, SharedString, Styled, StyledText, Subscription, Window, deferred, div, px, relative, }; use lsp_types::{CompletionItem, CompletionTextEdit}; use theme::ActiveTheme; const MAX_MENU_WIDTH: Pixels = px(320.); const MAX_MENU_HEIGHT: Pixels = px(240.); const POPOVER_GAP: Pixels = px(4.); use crate::input::popovers::{editor_popover, render_markdown}; use crate::input::{self, InputState, RopeExt}; use crate::list::{List, ListDelegate, ListEvent, ListState}; use crate::{IndexPath, Selectable, actions, h_flex}; struct ContextMenuDelegate { query: SharedString, menu: Entity, items: Vec>, selected_ix: usize, } impl ContextMenuDelegate { fn set_items(&mut self, items: Vec) { self.items = items.into_iter().map(Rc::new).collect(); self.selected_ix = 0; } fn selected_item(&self) -> Option<&Rc> { self.items.get(self.selected_ix) } } #[derive(IntoElement)] struct CompletionMenuItem { ix: usize, item: Rc, children: Vec, selected: bool, highlight_prefix: SharedString, } impl CompletionMenuItem { fn new(ix: usize, item: Rc) -> Self { Self { ix, item, children: vec![], selected: false, highlight_prefix: "".into(), } } fn highlight_prefix(mut self, s: impl Into) -> Self { self.highlight_prefix = s.into(); self } } impl Selectable for CompletionMenuItem { fn selected(mut self, selected: bool) -> Self { self.selected = selected; self } fn is_selected(&self) -> bool { self.selected } } impl ParentElement for CompletionMenuItem { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); } } impl RenderOnce for CompletionMenuItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let item = self.item; let matched_len = item .filter_text .as_ref() .map(|s| s.len()) .unwrap_or(self.highlight_prefix.len()) .min(item.label.len()); let highlights = vec![( 0..matched_len, HighlightStyle { color: Some(cx.theme().selection), ..Default::default() }, )]; h_flex() .id(self.ix) .gap_2() .p_1() .text_xs() .line_height(relative(1.)) .rounded(cx.theme().radius.half()) .when(item.deprecated.unwrap_or(false), |this| this.line_through()) .hover(|this| this.bg(cx.theme().secondary_hover)) .when(self.selected, |this| { this.bg(cx.theme().secondary_background) .text_color(cx.theme().secondary_foreground) }) .child(div().child(StyledText::new(item.label.clone()).with_highlights(highlights))) .children(self.children) } } impl EventEmitter for ContextMenuDelegate {} impl ListDelegate for ContextMenuDelegate { type Item = CompletionMenuItem; fn items_count(&self, _: usize, _: &gpui::App) -> usize { self.items.len() } fn render_item( &mut self, ix: crate::IndexPath, _: &mut Window, _: &mut Context>, ) -> Option { let item = self.items.get(ix.row)?; Some(CompletionMenuItem::new(ix.row, item.clone()).highlight_prefix(self.query.clone())) } fn set_selected_index( &mut self, ix: Option, _: &mut Window, cx: &mut Context>, ) { self.selected_ix = ix.map(|i| i.row).unwrap_or(0); cx.notify(); } fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { let Some(item) = self.selected_item() else { return; }; self.menu.update(cx, |this, cx| { this.select_item(&item, window, cx); }); } } /// A context menu for code completions and code actions. pub struct CompletionMenu { offset: usize, editor: Entity, list: Entity>, open: bool, /// The offset of the first character that triggered the completion. pub(crate) trigger_start_offset: Option, query: SharedString, _subscriptions: Vec, } impl CompletionMenu { /// Creates a new `CompletionMenu` with the given offset and completion items. /// /// NOTE: This element should not call from InputState::new, unless that will stack overflow. pub(crate) fn new( editor: Entity, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let view = cx.entity(); let menu = ContextMenuDelegate { query: SharedString::default(), menu: view, items: vec![], selected_ix: 0, }; let list = cx.new(|cx| ListState::new(menu, window, cx)); let _subscriptions = vec![ cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| { match ev { ListEvent::Confirm(_) => { this.hide(cx); } _ => {} } cx.notify(); }), ]; Self { offset: 0, editor, list, open: false, trigger_start_offset: None, query: SharedString::default(), _subscriptions, } }) } fn select_item(&mut self, item: &CompletionItem, window: &mut Window, cx: &mut Context) { let offset = self.offset; let item = item.clone(); let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset; let editor = self.editor.clone(); cx.spawn_in(window, async move |_, cx| { editor.update_in(cx, |editor, window, cx| { editor.completion_inserting = true; let mut new_text = item.label.clone(); if let Some(text_edit) = item.text_edit.as_ref() { match text_edit { CompletionTextEdit::Edit(edit) => { new_text = edit.new_text.clone(); range.start = editor.text.position_to_offset(&edit.range.start); range.end = editor.text.position_to_offset(&edit.range.end); } CompletionTextEdit::InsertAndReplace(edit) => { new_text = edit.new_text.clone(); range.start = editor.text.position_to_offset(&edit.replace.start); range.end = editor.text.position_to_offset(&edit.replace.end); } } } else if let Some(insert_text) = item.insert_text.clone() { new_text = insert_text; range = offset..offset; } editor.replace_text_in_range_silent( Some(editor.range_to_utf16(&range)), &new_text, window, cx, ); editor.completion_inserting = false; // FIXME: Input not get the focus editor.focus(window, cx); }) }) .detach(); self.hide(cx); } pub(crate) fn handle_action( &mut self, action: Box, window: &mut Window, cx: &mut Context, ) -> bool { if !self.open { return false; } cx.propagate(); if input::Enter::is_primary(&*action) { self.on_action_enter(window, cx); } else if action.partial_eq(&input::Escape) { self.on_action_escape(window, cx); } else if action.partial_eq(&input::MoveUp) { self.on_action_up(window, cx); } else if action.partial_eq(&input::MoveDown) { self.on_action_down(window, cx); } else { return false; } true } fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context) { let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else { return; }; self.select_item(&item, window, cx); } fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context) { self.hide(cx); } fn on_action_up(&mut self, window: &mut Window, cx: &mut Context) { self.list.update(cx, |this, cx| { this.on_action_select_prev(&actions::SelectUp, window, cx) }); } fn on_action_down(&mut self, window: &mut Window, cx: &mut Context) { self.list.update(cx, |this, cx| { this.on_action_select_next(&actions::SelectDown, window, cx) }); } pub(crate) fn is_open(&self) -> bool { self.open } /// Hide the completion menu and reset the trigger start offset. pub(crate) fn hide(&mut self, cx: &mut Context) { self.open = false; self.trigger_start_offset = None; cx.notify(); } /// Sets the trigger start offset if it is not already set. pub(crate) fn update_query(&mut self, start_offset: usize, query: impl Into) { if self.trigger_start_offset.is_none() { self.trigger_start_offset = Some(start_offset); } self.query = query.into(); } pub(crate) fn show( &mut self, offset: usize, items: impl Into>, window: &mut Window, cx: &mut Context, ) { let items = items.into(); self.offset = offset; self.open = true; self.list.update(cx, |this, cx| { let longest_ix = items .iter() .enumerate() .max_by_key(|(_, item)| { item.label.len() + item.detail.as_ref().map(|d| d.len()).unwrap_or(0) }) .map(|(ix, _)| ix) .unwrap_or(0); this.delegate_mut().query = self.query.clone(); this.delegate_mut().set_items(items); this.set_selected_index(Some(IndexPath::new(0)), window, cx); this.set_item_to_measure_index(IndexPath::new(longest_ix), window, cx); }); cx.notify(); } fn origin(&self, cx: &App) -> Option> { let editor = self.editor.read(cx); let Some(last_layout) = editor.last_layout.as_ref() else { return None; }; let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else { return None; }; let scroll_origin = self.editor.read(cx).scroll_handle.offset(); Some( scroll_origin + cursor_origin - editor.input_bounds.origin + Point::new(-px(4.), last_layout.line_height + px(4.)), ) } } impl Render for CompletionMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.open { return Empty.into_any_element(); } if self.list.read(cx).delegate().items.is_empty() { self.open = false; return Empty.into_any_element(); } let Some(pos) = self.origin(cx) else { return Empty.into_any_element(); }; let selected_documentation = self .list .read(cx) .delegate() .selected_item() .and_then(|item| item.documentation.clone()); let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x); let abs_pos = self.editor.read(cx).input_bounds.origin + pos; let vertical_layout = abs_pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP > window.bounds().size.width; deferred( div() .absolute() .left(pos.x) .top(pos.y) .flex() .flex_row() .gap(POPOVER_GAP) .items_start() .when(vertical_layout, |this| this.flex_col()) .child( editor_popover("completion-menu", cx) .max_w(max_width) .min_w(px(120.)) .child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)), ) .when_some(selected_documentation, |this, documentation| { let mut doc = match documentation { lsp_types::Documentation::String(s) => s.clone(), lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(), }; if vertical_layout { doc = doc.split("\n").next().unwrap_or_default().to_string(); } this.child( div().child( editor_popover("completion-menu", cx) .w(MAX_MENU_WIDTH) .px_2() .child(render_markdown("doc", doc, window, cx)), ), ) }) .on_mouse_down_out(cx.listener(|this, _, _, cx| { this.hide(cx); })), ) .into_any_element() } }