feat: add native LSP hover action and popup
This commit is contained in:
+208
-2
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user