672 lines
25 KiB
Rust
672 lines
25 KiB
Rust
//! Элементы интерфейса автодополнения.
|
||
//!
|
||
//! Здесь живёт всплывающий список, который привязывается к карете редактора
|
||
//! через свежую геометрию 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<dyn Fn(Point<Pixels>, 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<EditorState>,
|
||
build: PopupBuilder,
|
||
content: Option<AnyElement>,
|
||
}
|
||
|
||
impl CaretPopup {
|
||
pub fn new(
|
||
editor: WeakEntity<EditorState>,
|
||
build: impl Fn(Point<Pixels>, 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<ElementId> {
|
||
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<Pixels>,
|
||
_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<Pixels>,
|
||
_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<Pixels> {
|
||
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<Pixels>,
|
||
above: bool,
|
||
}
|
||
|
||
/// Выбирает положение поповера так, чтобы он целиком помещался в окне.
|
||
///
|
||
/// По умолчанию поповер стоит под каретой. Если снизу места мало, он
|
||
/// переносится над строкой; если не помещается справа, сдвигается влево.
|
||
fn popup_origin(
|
||
anchor: Point<Pixels>,
|
||
caret_top: Pixels,
|
||
size: Size<Pixels>,
|
||
viewport: Size<Pixels>,
|
||
) -> 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<char> {
|
||
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<CompletionItem> {
|
||
if value.is_null() {
|
||
return Vec::new();
|
||
}
|
||
serde_json::from_value::<CompletionResponse>(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<CompletionItemKind>) -> &'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<CompletionItemKind>) -> 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<CompletionItemKind>) -> 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<CompletionItemKind>,
|
||
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<Range<usize>> {
|
||
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<usize>, 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<char> = 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<usize>) -> 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");
|
||
}
|
||
}
|