feat: add live-cycling LSP completion

This commit is contained in:
2026-10-10 18:32:39 +02:00
parent 9d3b25e9d3
commit b6059d5ed9
11 changed files with 1916 additions and 55 deletions
+163 -4
View File
@@ -23,7 +23,7 @@ use crossterm::{
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Rect},
layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
@@ -32,7 +32,9 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
files::{DockSide, FilePicker, FilePresentation, FileView},
plugin::{
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
@@ -446,6 +448,7 @@ fn draw(
let height = usize::from(viewport.height);
let width = usize::from(viewport.width);
let cursor = editor.cursor();
let mut document_caret = None;
let carets = editor.carets();
let selections = editor.selections();
if document.manual.as_ref().is_some_and(|manual| {
@@ -820,13 +823,20 @@ fn draw(
&& column >= *left
&& column < left.saturating_add(width)
{
frame.set_cursor_position((
let position = Position::new(
viewport.x + (column - *left) as u16,
viewport.y + (cursor.row - *top) as u16,
));
);
frame.set_cursor_position(position);
document_caret = Some(position);
}
}
}
if let Some(completion) = editor.completion()
&& let Some(caret) = document_caret
{
panels.completion = draw_completion(frame, completion, viewport, caret);
}
if let Some(picker) = editor.file_picker() {
if picker.presentation == FilePresentation::Floating {
let panel = floating_panel(area);
@@ -916,6 +926,7 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
#[derive(Default)]
struct WheelPanels {
completion: Option<Rect>,
hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
@@ -952,6 +963,7 @@ impl WheelPanels {
self.manual_files = None;
}
self.hover = None;
self.completion = None;
self.files = None;
}
@@ -992,6 +1004,13 @@ impl WheelPanels {
return true;
}
let position = ratatui::layout::Position::new(mouse.column, mouse.row);
if editor.completion().is_some()
&& self
.completion
.is_some_and(|panel| panel.contains(position))
{
return true;
}
if let Some(hover) = editor.hover()
&& let Some((panel, height)) = self.hover
&& panel.contains(position)
@@ -1042,6 +1061,69 @@ impl WheelPanels {
}
}
fn draw_completion(
frame: &mut Frame,
completion: &Completion,
area: Rect,
caret: Position,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 || !area.contains(caret) {
return None;
}
let count = completion.items.len().saturating_add(1);
let selected = completion.selected.min(count - 1);
let desired_height = count.min(8) as u16;
let below = area.bottom() - caret.y - 1;
let above = caret.y - area.y;
let downward = below >= desired_height || below >= above;
let height = desired_height.min(if downward { below } else { above }.max(1));
let first = selected.saturating_sub(usize::from(height) - 1);
let lines: Vec<Line> = (first..count)
.take(usize::from(height))
.map(|index| {
let label = if index == 0 {
"Original text".to_owned()
} else {
let item = &completion.items[index - 1];
let mut label = display_text(&item.label);
if let Some(detail) = &item.detail {
label.push_str(" · ");
label.push_str(&display_text(detail));
}
label
};
let style = if index == selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().fg(Color::Gray).bg(Color::Black)
};
Line::styled(
visible_line(&label, 0, usize::from(area.width.min(60))),
style,
)
})
.collect();
let width = lines.iter().map(Line::width).max().unwrap_or(1).max(1) as u16;
let panel = Rect::new(
caret.x.min(area.right() - width),
if below == 0 && above == 0 {
caret.y
} else if downward {
caret.y + 1
} else {
caret.y - height
},
width,
height,
);
frame.render_widget(Clear, panel);
frame.render_widget(
Paragraph::new(lines).style(Style::default().fg(Color::Gray).bg(Color::Black)),
panel,
);
Some(panel)
}
fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPanels) {
if area.width == 0 || area.height == 0 {
return;
@@ -1548,6 +1630,83 @@ mod tests {
.join("\n")
}
#[test]
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
let mut completion = Completion {
items: (0..20)
.map(|index| crate::plugin::CompletionItem {
label: if index == 19 {
"界e\u{301}👩‍🚀\u{1b}".into()
} else {
format!("item-{index:02}")
},
detail: Some("\tTAIL".into()),
primary_edit: 0,
edits: vec![],
})
.collect(),
selected: 20,
};
for (width, height) in [(14, 6), (9, 1), (5, 1)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let workspace = Rect::new(2, 0, width - 4, height);
let caret = Position::new(2, 0);
let mut panel = None;
completion.selected = 20;
terminal
.draw(|frame| {
let rows: Vec<Line> = (0..height)
.map(|_| Line::from(format!("GG{}EE", ".".repeat(usize::from(width - 4)))))
.collect();
frame.render_widget(Paragraph::new(rows), frame.area());
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret);
})
.unwrap();
let mut panel = panel.unwrap();
let selected_y = panel.bottom() - 1;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(panel.x, selected_y)].bg, Color::Cyan);
assert!(!screen_text(&terminal).contains("item-00"));
for y in 0..height {
for x in [0, 1] {
assert_eq!(buffer[(x, y)].symbol(), "G");
}
for x in [width - 2, width - 1] {
assert_eq!(buffer[(x, y)].symbol(), "E");
}
}
if workspace.width >= 5 {
assert_eq!(buffer[(panel.x, selected_y)].symbol(), "界");
assert_eq!(buffer[(panel.x + 2, selected_y)].symbol(), "e\u{301}");
assert_eq!(buffer[(panel.x + 3, selected_y)].symbol(), "👩‍🚀");
} else {
assert_eq!(buffer[(panel.x, selected_y)].symbol(), " ");
}
if workspace.width >= 6 {
assert_eq!(buffer[(panel.x + 5, selected_y)].symbol(), "�");
}
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
completion.selected = 0;
terminal
.draw(|frame| {
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret).unwrap();
})
.unwrap();
assert_eq!(
terminal.backend().buffer()[(panel.x, panel.y)].symbol(),
"O"
);
assert_eq!(
terminal.backend().buffer()[(panel.x, panel.y)].bg,
Color::Cyan
);
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
}
}
#[test]
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
let mut editor = Editor::open(None, vec![]).unwrap();