feat: add native LSP hover action and popup

This commit is contained in:
Valdemar Lorenzen
2026-10-09 14:29:58 +02:00
parent 529d1fd5b3
commit f093091002
10 changed files with 842 additions and 49 deletions
+208 -2
View File
@@ -62,6 +62,35 @@ pub struct Palette {
pub selected: usize,
}
pub struct Hover {
text: String,
line_starts: Vec<usize>,
/// First visible logical line.
pub top: usize,
/// First visible extended grapheme, independent of terminal geometry.
pub left: usize,
max_column: usize,
buffer_id: usize,
revision: u64,
caret: Cursor,
}
impl Hover {
pub fn text(&self) -> &str {
&self.text
}
pub fn lines(&self) -> impl ExactSizeIterator<Item = &str> {
self.line_starts.iter().enumerate().map(|(index, &start)| {
let end = self
.line_starts
.get(index + 1)
.map_or(self.text.len(), |next| next - 1);
&self.text[start..end]
})
}
}
pub struct PaletteEntry {
pub name: String,
pub label: String,
@@ -255,6 +284,7 @@ enum Action {
FilePicker,
FileExplorer,
LspDefinition,
LspHover,
LspDiagnostics,
}
@@ -438,6 +468,7 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
Action::LspDefinition,
&[Mode::Normal],
),
("core.lsp_hover", Action::LspHover, &[Mode::Normal]),
(
"core.lsp_diagnostics",
Action::LspDiagnostics,
@@ -837,6 +868,7 @@ pub struct Editor {
leader_suffix: Vec<Input>,
leader_display: String,
palette: Option<Palette>,
hover: Option<Hover>,
project_root: PathBuf,
file_picker: Option<FilePicker>,
file_explorer_side: DockSide,
@@ -894,6 +926,7 @@ impl Editor {
leader_suffix: Vec::new(),
leader_display: String::new(),
palette: None,
hover: None,
project_root: std::env::current_dir()?,
file_picker: None,
file_explorer_side: DockSide::Right,
@@ -921,6 +954,7 @@ impl Editor {
"bdelete",
"bdelete!",
"lsp_definition",
"lsp_hover",
"lsp_diagnostics",
]
.into_iter()
@@ -1136,6 +1170,7 @@ impl Editor {
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
self.clear_hover();
if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) {
return Err("Navigation path must be nonempty and contain no controls".into());
}
@@ -1173,6 +1208,91 @@ impl Editor {
self.palette.as_ref()
}
/// Borrowed popup state, visible only while its source remains current.
pub fn hover(&self) -> Option<&Hover> {
self.hover.as_ref().filter(|hover| {
hover.buffer_id == self.active.id
&& hover.revision == self.text_revision()
&& hover.caret == self.cursor()
&& self.mode() == Mode::Normal
&& self.palette.is_none()
&& !self
.file_picker
.as_ref()
.is_some_and(|picker| picker.focused)
})
}
pub fn show_hover(&mut self, text: String) -> Result<(), String> {
validate_hover_text(&text)?;
if self.mode() != Mode::Normal
|| self.palette.is_some()
|| self
.file_picker
.as_ref()
.is_some_and(|picker| picker.focused)
{
return Err("Hover requires Normal editor focus".into());
}
self.hover = if text.is_empty() {
None
} else {
let text = match normalize_newlines(&text) {
Cow::Borrowed(_) => text,
Cow::Owned(normalized) => normalized,
};
let mut line_starts = Vec::new();
let mut offset = 0;
let mut max_column = 0;
for line in text.split('\n') {
line_starts.push(offset);
offset += line.len() + 1;
max_column = max_column.max(grapheme_count(line).saturating_sub(1));
}
Some(Hover {
text,
line_starts,
top: 0,
left: 0,
max_column,
buffer_id: self.active.id,
revision: self.text_revision(),
caret: self.cursor(),
})
};
Ok(())
}
pub fn clear_hover(&mut self) {
self.hover = None;
}
fn handle_hover(&mut self, input: &Input) -> bool {
if self.hover().is_none() {
self.clear_hover();
return false;
}
let hover = self.hover.as_mut().expect("current hover exists");
match input {
Input::Up | Input::Ctrl('k') => hover.top = hover.top.saturating_sub(1),
Input::Down | Input::Ctrl('j') => {
hover.top = hover.top.saturating_add(1).min(hover.line_starts.len() - 1);
}
Input::Left | Input::Ctrl('h') => hover.left = hover.left.saturating_sub(1),
Input::Right | Input::Ctrl('l') => {
hover.left = hover.left.saturating_add(1).min(hover.max_column);
}
Input::Home => hover.left = 0,
Input::End => hover.left = hover.max_column,
Input::Escape => self.clear_hover(),
_ => {
self.clear_hover();
return false;
}
}
true
}
pub fn file_picker(&self) -> Option<&FilePicker> {
self.file_picker.as_ref()
}
@@ -1852,6 +1972,9 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch),
input => input,
};
if self.handle_hover(&input) {
return None;
}
let picker_focused = self
.file_picker
.as_ref()
@@ -1975,6 +2098,7 @@ impl Editor {
}
fn act(&mut self, action: Action) -> Option<Invocation> {
self.clear_hover();
let count = self.count.take().unwrap_or(1);
self.clear_leader();
match action {
@@ -1990,6 +2114,7 @@ impl Editor {
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
Action::LspDefinition => return self.execute("lsp_definition"),
Action::LspHover => return self.execute("lsp_hover"),
Action::LspDiagnostics => return self.execute("lsp_diagnostics"),
Action::Insert
| Action::InsertFirstNonblank
@@ -2202,6 +2327,7 @@ impl Editor {
}
fn park_source(&mut self) {
self.clear_hover();
if self.active.mode == Mode::Insert {
self.finish_insert();
}
@@ -2379,6 +2505,7 @@ impl Editor {
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
self.clear_hover();
self.count = None;
self.clear_leader();
self.palette = None;
@@ -2410,10 +2537,10 @@ impl Editor {
});
}
match name {
"lsp_definition" | "lsp_diagnostics" if !args.is_empty() => {
"lsp_definition" | "lsp_hover" | "lsp_diagnostics" if !args.is_empty() => {
self.set_message(format!("The {name} command takes no arguments"));
}
"lsp_definition" | "lsp_diagnostics" => {
"lsp_definition" | "lsp_hover" | "lsp_diagnostics" => {
return Some(Invocation {
plugin: "core".into(),
command: name.into(),
@@ -2496,6 +2623,7 @@ impl Editor {
})
.then(|| self.snapshot());
if before.is_some() {
self.clear_hover();
self.count = None;
self.clear_leader();
self.clear_selection();
@@ -3920,6 +4048,14 @@ fn valid_command(command: &str) -> bool {
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}
pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> {
if text.len() > MAX_EDIT_BYTES {
Err("Hover exceeds 4 MiB".into())
} else {
validate_text(text)
}
}
fn validate_paste_text(text: &str) -> Result<(), String> {
if text.len() > MAX_EDIT_BYTES {
Err("Paste exceeds 4 MiB".into())
@@ -4117,6 +4253,76 @@ mod tests {
editor
}
#[test]
fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("alpha\nbeta".into())])
.unwrap();
set_carets(&mut editor, &[(0, 1), (1, 2)], 1);
let targets = editor.active.targets.clone();
let primary = editor.active.primary;
let text = editor.context().text;
let revision = editor.text_revision();
let history = (editor.active.undo.len(), editor.active.redo.len());
editor
.bind("normal", "K", Some("core.lsp_hover".into()))
.unwrap();
type_text(&mut editor, "9");
assert_eq!(
editor.handle(Input::Char('K')),
Some(Invocation {
plugin: "core".into(),
command: "lsp_hover".into(),
args: String::new(),
})
);
assert_eq!(editor.count(), None);
editor
.show_hover("界e\u{301}👩‍🚀\tend\r\nsecond\nlast".into())
.unwrap();
assert!(editor.show_hover("\u{1b}[31m".into()).is_err());
assert_eq!(editor.hover().unwrap().lines().nth(1), Some("second"));
for input in [
Input::Down,
Input::Ctrl('j'),
Input::Down,
Input::Right,
Input::Ctrl('l'),
] {
assert_eq!(editor.handle(input), None);
}
assert_eq!(
(editor.hover().unwrap().top, editor.hover().unwrap().left),
(2, 2)
);
editor.handle(Input::Escape);
assert!(editor.hover().is_none());
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.active.primary, primary);
assert_eq!(editor.context().text, text);
assert_eq!(editor.text_revision(), revision);
assert_eq!(
(editor.active.undo.len(), editor.active.redo.len()),
history
);
assert_eq!(editor.mode(), Mode::Normal);
editor.handle(Input::Escape);
assert_eq!(editor.selection_count(), 1);
editor.show_hover("documentation".into()).unwrap();
editor.handle(Input::Char('i'));
assert!(editor.hover().is_none());
assert_eq!(editor.mode(), Mode::Insert);
editor.handle(Input::Char('X'));
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "alpha\nbeXta");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, text);
editor.show_hover("documentation".into()).unwrap();
editor.apply(vec![Effect::Replace("new".into())]).unwrap();
assert!(editor.hover().is_none());
}
#[cfg(unix)]
mod system_clipboard {
use super::*;