//! Элементы интерфейса автодополнения. //! //! Здесь живёт всплывающий список, который привязывается к карете редактора //! через свежую геометрию prepaint. Логика запросов к LSP и состояние выбора //! находятся в [`MyApp`](crate::app::MyApp): этот модуль занимается только //! отображением и небольшими преобразованиями элементов `CompletionItem`. use std::ops::Range; use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry, Rope}; use gpui_kit::{ AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId, Pixels, Point, Size, Style, WeakEntity, Window, px, }; use lsp_types::{ CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Documentation, InsertTextFormat, }; use crate::lsp::{offset_to_position, position_to_offset}; /// Функция, которая строит содержимое поповера в его `prepaint`. /// /// Второй аргумент — признак того, что поповер будет нарисован над строкой /// с каретой: в этом случае содержимое выравнивается по нижнему краю. type PopupBuilder = Box, bool, &mut Window, &mut App) -> AnyElement>; /// Отступ от краёв окна, в который поповер не заходит. pub(crate) const POPUP_MARGIN: f32 = 8.; /// Зазор между каретой и краем поповера. const POPUP_GAP: f32 = 4.; /// Всплывающий список, привязанный к карете редактора. /// /// Как и встроенные поповеры gpui-component, элемент разрешает позицию в /// `prepaint`, то есть по геометрии текущего кадра, а не предыдущего. /// /// Если снизу от кареты нет места, поповер раскрывается вверх; если он не /// помещается справа, он сдвигается влево, оставаясь внутри окна. pub struct CaretPopup { editor: WeakEntity, build: PopupBuilder, content: Option, } impl CaretPopup { pub fn new( editor: WeakEntity, build: impl Fn(Point, bool, &mut Window, &mut App) -> AnyElement + 'static, ) -> Self { Self { editor, build: Box::new(build), content: None, } } } impl Element for CaretPopup { type RequestLayoutState = (); type PrepaintState = (); fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { None } fn request_layout( &mut self, _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let layout = window.request_layout(Style::default(), [], cx); (layout, ()) } fn prepaint( &mut self, _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, _bounds: Bounds, _request_layout: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) { let Some(editor) = self.editor.upgrade() else { return; }; let Some(geometry) = editor.read(cx).prepaint_caret_geometry() else { return; }; let anchor = caret_anchor(geometry); let viewport = window.viewport_size(); let available = Size { width: AvailableSpace::Definite(viewport.width), height: AvailableSpace::Definite(viewport.height), }; // Сначала меряем содержимое, чтобы решить, помещается ли оно снизу. let mut content = (self.build)(anchor, false, window, cx); let size = content.layout_as_root(available, window, cx); let placement = popup_origin(anchor, caret_top(geometry), size, viewport); // Над строкой поповеры выравниваются по нижнему краю. Это свойство // их общего контейнера, поэтому для верхнего варианта собираем его // заново с другим выравниванием. if placement.above { content = (self.build)(anchor, true, window, cx); content.layout_as_root(available, window, cx); } content.prepaint_at(placement.origin, window, cx); self.content = Some(content); } fn paint( &mut self, _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, _bounds: Bounds, _request_layout: &mut Self::RequestLayoutState, _prepaint: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { if let Some(content) = self.content.as_mut() { content.paint(window, cx); } } } impl IntoElement for CaretPopup { type Element = Self; fn into_element(self) -> Self::Element { self } } fn caret_anchor(geometry: PrepaintCaretGeometry) -> Point { Point::new( geometry.cursor_bounds().origin.x - px(POPUP_GAP), caret_top(geometry) + geometry.line_height() + px(POPUP_GAP), ) } /// Верхняя граница строки с каретой в координатах окна. fn caret_top(geometry: PrepaintCaretGeometry) -> Pixels { geometry.cursor_bounds().origin.y + geometry.scroll_offset().y } /// Положение поповера и то, нарисован ли он над строкой с каретой. #[derive(Clone, Copy, Debug, PartialEq)] struct PopupPlacement { origin: Point, above: bool, } /// Выбирает положение поповера так, чтобы он целиком помещался в окне. /// /// По умолчанию поповер стоит под каретой. Если снизу места мало, он /// переносится над строкой; если не помещается справа, сдвигается влево. fn popup_origin( anchor: Point, caret_top: Pixels, size: Size, viewport: Size, ) -> PopupPlacement { let margin = px(POPUP_MARGIN); let mut origin = anchor; let mut above = false; if origin.y + size.height > viewport.height - margin { above = true; let above_y = caret_top - size.height - px(POPUP_GAP); origin.y = if above_y >= margin { above_y } else { (viewport.height - margin - size.height).max(margin) }; } origin.x = origin.x.max(margin); if origin.x + size.width > viewport.width - margin { origin.x = (viewport.width - margin - size.width).max(margin); } PopupPlacement { origin, above } } /// Начало слова под каретой (для замены префикса при вставке). /// Находит начало слова под курсором в обычной строке. /// /// Нужен только для тестов: редактор работает с [`word_start_rope`]. #[cfg(test)] pub fn word_start(text: &str, cursor: usize) -> usize { let cursor = clamp_offset(text, cursor); let mut start = cursor; for (index, ch) in text[..cursor].char_indices().rev() { if ch.is_alphanumeric() || ch == '_' { start = index; } else { break; } } start } /// То же для rope: обходит только символы слова, а не весь документ. pub fn word_start_rope(text: &Rope, cursor: usize) -> usize { let cursor = cursor.min(text.len()); let mut start = cursor; let mut char_index = text.byte_to_char_idx(cursor); while char_index > 0 { char_index -= 1; let byte = text.char_to_byte_idx(char_index); let ch = text.char(byte); if ch.is_alphanumeric() || ch == '_' { start = byte; } else { break; } } start } /// Символ перед байтовым смещением `cursor`. pub fn char_before(text: &Rope, cursor: usize) -> Option { let cursor = cursor.min(text.len()); let mut char_index = text.byte_to_char_idx(cursor); if char_index == 0 { return None; } char_index -= 1; let byte = text.char_to_byte_idx(char_index); text.get_char(byte).ok() } /// Разбирает ответ `textDocument/completion`. pub fn parse_completion_response(value: serde_json::Value) -> Vec { if value.is_null() { return Vec::new(); } serde_json::from_value::(value) .map(|response| match response { CompletionResponse::Array(items) => items, CompletionResponse::List(list) => list.items, }) .unwrap_or_default() } /// Текст документации элемента в виде Markdown. pub fn documentation_text(documentation: &Documentation) -> String { match documentation { Documentation::String(text) => text.clone(), Documentation::MarkupContent(content) => content.value.clone(), } } /// Запасной текст, если сервер не прислал документацию. pub fn fallback_documentation(item: &CompletionItem) -> String { let kind = completion_kind_name(item.kind); match item.detail.as_ref() { Some(detail) if !detail.is_empty() => format!("`{detail}`\n\n{kind}"), _ => format!("**{}**\n\n{kind}", item.label), } } /// Ключ кэша документации: разные элементы с одинаковым именем в разных /// контекстах (например, `len` у разных типов) не должны перепутаться. pub fn item_key(item: &CompletionItem) -> String { format!( "{}\u{1}{}\u{1}{}", item.label, item.detail.as_deref().unwrap_or_default(), item.kind .map(|kind| format!("{kind:?}")) .unwrap_or_default(), ) } /// Человекочитаемый тип элемента. pub fn completion_kind_name(kind: Option) -> &'static str { match kind { Some(CompletionItemKind::METHOD) => "метод", Some(CompletionItemKind::FUNCTION) => "функция", Some(CompletionItemKind::CONSTRUCTOR) => "конструктор", Some(CompletionItemKind::FIELD) => "поле", Some(CompletionItemKind::VARIABLE) => "переменная", Some(CompletionItemKind::CONSTANT) => "константа", Some(CompletionItemKind::STRUCT) => "структура", Some(CompletionItemKind::CLASS) => "класс", Some(CompletionItemKind::INTERFACE) => "интерфейс", Some(CompletionItemKind::ENUM) => "перечисление", Some(CompletionItemKind::ENUM_MEMBER) => "элемент перечисления", Some(CompletionItemKind::MODULE) => "модуль", Some(CompletionItemKind::PROPERTY) => "свойство", Some(CompletionItemKind::KEYWORD) => "ключевое слово", Some(CompletionItemKind::SNIPPET) => "шаблон", Some(CompletionItemKind::TYPE_PARAMETER) => "параметр типа", Some(CompletionItemKind::OPERATOR) => "оператор", Some(CompletionItemKind::EVENT) => "событие", Some(CompletionItemKind::FILE) => "файл", Some(CompletionItemKind::FOLDER) => "каталог", Some(CompletionItemKind::TEXT) => "текст", Some(_) => "значение", None => "элемент", } } /// Группа элемента автодополнения: по ней выбираются иконка и цвет. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CompletionKindGroup { Function, Type, Variable, Keyword, Module, File, Folder, Snippet, Event, TypeParameter, Operator, Other, Unknown, } fn kind_group(kind: Option) -> CompletionKindGroup { match kind { Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => { CompletionKindGroup::Function } Some( CompletionItemKind::STRUCT | CompletionItemKind::CLASS | CompletionItemKind::INTERFACE | CompletionItemKind::ENUM | CompletionItemKind::CONSTRUCTOR, ) => CompletionKindGroup::Type, Some( CompletionItemKind::VARIABLE | CompletionItemKind::FIELD | CompletionItemKind::PROPERTY | CompletionItemKind::CONSTANT | CompletionItemKind::ENUM_MEMBER, ) => CompletionKindGroup::Variable, Some(CompletionItemKind::KEYWORD) => CompletionKindGroup::Keyword, Some(CompletionItemKind::MODULE) => CompletionKindGroup::Module, Some(CompletionItemKind::FILE) => CompletionKindGroup::File, Some(CompletionItemKind::FOLDER) => CompletionKindGroup::Folder, Some(CompletionItemKind::SNIPPET) => CompletionKindGroup::Snippet, Some(CompletionItemKind::EVENT) => CompletionKindGroup::Event, Some(CompletionItemKind::TYPE_PARAMETER) => CompletionKindGroup::TypeParameter, Some(CompletionItemKind::OPERATOR) => CompletionKindGroup::Operator, Some(_) => CompletionKindGroup::Other, None => CompletionKindGroup::Unknown, } } /// Иконка для типа элемента автодополнения. /// /// Используется полный каталог иконок gpui-kit-assets, а не компактный набор /// компонента: в нём есть `square-function`, `variable`, `braces` и другие /// привычные символы IDE. pub fn completion_icon(kind: Option) -> gpui_kit::assets::IconName { use gpui_kit::assets::IconName as Icon; match kind_group(kind) { CompletionKindGroup::Function => Icon::SquareFunction, CompletionKindGroup::Type => Icon::Box, CompletionKindGroup::Variable => Icon::Variable, CompletionKindGroup::Keyword => Icon::Type, CompletionKindGroup::Module => Icon::Braces, CompletionKindGroup::File => Icon::FileText, CompletionKindGroup::Folder => Icon::Folder, CompletionKindGroup::Snippet => Icon::Component, CompletionKindGroup::Event => Icon::Activity, CompletionKindGroup::TypeParameter => Icon::TypeOutline, CompletionKindGroup::Operator => Icon::CaseSensitive, CompletionKindGroup::Other => Icon::Circle, CompletionKindGroup::Unknown => Icon::Sparkles, } } /// Цвет иконки для типа элемента. pub fn completion_icon_color( kind: Option, theme: &gpui_kit::component::Theme, ) -> Hsla { match kind_group(kind) { CompletionKindGroup::Function => theme.blue, CompletionKindGroup::Type => theme.yellow, CompletionKindGroup::Variable => theme.green, CompletionKindGroup::Keyword => theme.magenta, CompletionKindGroup::Module => theme.cyan, _ => theme.muted_foreground, } } /// Диапазон подсветки совпадения в подписи элемента. pub fn highlight_range(query: &str, label: &str) -> Option> { if query.is_empty() { return None; } let query = query.to_lowercase(); let lowercase = label.to_lowercase(); let start = lowercase.find(&query)?; // Нижний регистр иногда меняет длину строки, поэтому диапазон // проверяем по исходной метке и не отдаём разрезанный символ. let end = (start + query.len()).min(label.len()); if start < end && label.is_char_boundary(start) && label.is_char_boundary(end) { Some(start..end) } else { None } } /// Подготавливает замену текста при выборе элемента. /// /// Возвращает байтовый диапазон в документе и вставляемый текст. Если сервер /// прислал `textEdit`, используются его границы; иначе заменяется слово под /// каретой. Сниппеты раскрываются в плоский текст: табов-заглушек в нашем /// редакторе пока нет. pub fn completion_edit( item: &CompletionItem, text: &str, start: usize, cursor: usize, ) -> (Range, String) { let mut range = clamp_offset(text, start)..clamp_offset(text, cursor); let mut new_text = item .insert_text .clone() .unwrap_or_else(|| item.label.clone()); if let Some(edit) = &item.text_edit { match edit { CompletionTextEdit::Edit(edit) => { range = position_to_offset(text, edit.range.start) ..position_to_offset(text, edit.range.end); new_text.clone_from(&edit.new_text); } CompletionTextEdit::InsertAndReplace(edit) => { range = position_to_offset(text, edit.replace.start) ..position_to_offset(text, edit.replace.end); new_text.clone_from(&edit.new_text); } } } if item.insert_text_format == Some(InsertTextFormat::SNIPPET) { new_text = strip_snippet(&new_text); } let start = clamp_offset(text, range.start).min(text.len()); let end = clamp_offset(text, range.end).max(start).min(text.len()); (start..end, new_text) } /// Разворачивает LSP-сниппет в обычный текст. pub fn strip_snippet(snippet: &str) -> String { let chars: Vec = snippet.chars().collect(); let mut output = String::new(); let mut index = 0; while index < chars.len() { if chars[index] == '$' { if index + 1 < chars.len() && chars[index + 1] == '{' { if let Some(end) = (index + 2..chars.len()).find(|&pos| chars[pos] == '}') { let inner: String = chars[index + 2..end].iter().collect(); if let Some((_, default)) = inner.split_once(':') { output.push_str(default); } index = end + 1; continue; } } else if index + 1 < chars.len() && chars[index + 1].is_ascii_digit() { index += 2; continue; } else if index + 1 < chars.len() && chars[index + 1] == '$' { output.push('$'); index += 2; continue; } } output.push(chars[index]); index += 1; } output } fn clamp_offset(text: &str, offset: usize) -> usize { let mut offset = offset.min(text.len()); while offset > 0 && !text.is_char_boundary(offset) { offset -= 1; } offset } /// Позиция LSP для диапазона замены (удобно для `apply_lsp_edits`). pub fn range_for_lsp(text: &str, range: &Range) -> lsp_types::Range { lsp_types::Range { start: offset_to_position(text, range.start), end: offset_to_position(text, range.end), } } #[cfg(test)] mod tests { use super::*; fn item(label: &str) -> CompletionItem { CompletionItem { label: label.to_owned(), ..Default::default() } } #[test] fn word_start_stops_at_non_identifier_characters() { assert_eq!(word_start("let value", 9), 4); assert_eq!(word_start("obj.field", 9), 4); assert_eq!(word_start("obj.", 4), 4); assert_eq!(word_start("привет", 6), 0); } #[test] fn rope_word_start_matches_string_version() { for text in ["let value", "obj.field", "obj.", "привет"] { let rope = Rope::from(text); for cursor in 0..=text.len() { if text.is_char_boundary(cursor) { assert_eq!(word_start_rope(&rope, cursor), word_start(text, cursor)); } } } } #[test] fn snippets_lose_placeholders() { assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)"); assert_eq!(strip_snippet("foo($1, $0)"), "foo(, )"); assert_eq!(strip_snippet("cost $$5"), "cost $5"); } #[test] fn popup_drops_below_the_caret_when_there_is_room() { let viewport = Size { width: px(800.), height: px(600.), }; let anchor = Point::new(px(100.), px(200.)); let size = Size { width: px(300.), height: px(200.), }; let placement = popup_origin(anchor, px(180.), size, viewport); assert_eq!(placement.origin, Point::new(px(100.), px(200.))); assert!(!placement.above); } #[test] fn popup_flips_above_when_bottom_space_is_short() { let viewport = Size { width: px(800.), height: px(600.), }; let caret_top = px(480.); let anchor = Point::new(px(100.), px(500.)); let size = Size { width: px(300.), height: px(200.), }; let placement = popup_origin(anchor, caret_top, size, viewport); assert_eq!(placement.origin.y, caret_top - px(200.) - px(POPUP_GAP)); assert_eq!(placement.origin.x, px(100.)); assert!(placement.above); } #[test] fn popup_shifts_left_when_it_does_not_fit_on_the_right() { let viewport = Size { width: px(800.), height: px(600.), }; let anchor = Point::new(px(700.), px(100.)); let size = Size { width: px(300.), height: px(200.), }; let placement = popup_origin(anchor, px(80.), size, viewport); assert_eq!(placement.origin.x, px(800.) - px(POPUP_MARGIN) - px(300.)); assert_eq!(placement.origin.y, px(100.)); assert!(!placement.above); } #[test] fn completion_icons_exist_in_the_bundled_asset_catalog() { use gpui_kit::AssetSource; use gpui_kit::assets::AllAssets; // `Assets` — компактный набор компонента, и иконок автодополнения в // нём нет. Приложение подключает `AllAssets`; тест следит за тем, // чтобы каждая иконка списка действительно там была. let kinds = [ None, Some(CompletionItemKind::FUNCTION), Some(CompletionItemKind::METHOD), Some(CompletionItemKind::STRUCT), Some(CompletionItemKind::CLASS), Some(CompletionItemKind::INTERFACE), Some(CompletionItemKind::ENUM), Some(CompletionItemKind::CONSTRUCTOR), Some(CompletionItemKind::VARIABLE), Some(CompletionItemKind::FIELD), Some(CompletionItemKind::PROPERTY), Some(CompletionItemKind::CONSTANT), Some(CompletionItemKind::ENUM_MEMBER), Some(CompletionItemKind::KEYWORD), Some(CompletionItemKind::MODULE), Some(CompletionItemKind::FILE), Some(CompletionItemKind::FOLDER), Some(CompletionItemKind::SNIPPET), Some(CompletionItemKind::EVENT), Some(CompletionItemKind::TYPE_PARAMETER), Some(CompletionItemKind::OPERATOR), Some(CompletionItemKind::TEXT), ]; for kind in kinds { let path = completion_icon(kind).path(); assert!( AllAssets .load(&path) .expect("ошибка чтения ассетов") .is_some(), "в бандле нет иконки {path}" ); } } #[test] fn completion_edit_uses_the_lsp_range() { let text = "let value"; let mut item = item("value"); item.text_edit = Some(CompletionTextEdit::Edit(lsp_types::TextEdit { range: lsp_types::Range { start: lsp_types::Position::new(0, 4), end: lsp_types::Position::new(0, 9), }, new_text: "answer".to_owned(), })); let (range, replacement) = completion_edit(&item, text, 4, 9); assert_eq!(range, 4..9); assert_eq!(replacement, "answer"); } }