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::*;
+252 -28
View File
@@ -65,10 +65,25 @@ enum Input {
event: DocumentEventKind,
served: bool,
},
Definition(Source),
Position(PositionKind, Source),
Diagnostics(usize),
}
#[derive(Clone, Copy)]
enum PositionKind {
Definition,
Hover,
}
impl PositionKind {
fn name(self) -> &'static str {
match self {
Self::Definition => "definition",
Self::Hover => "hover",
}
}
}
struct Source {
buffer_id: usize,
revision: u64,
@@ -172,22 +187,35 @@ impl Lsp {
}
pub fn definition(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
self.position(context, request_id, PositionKind::Definition)
}
pub fn hover(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
self.position(context, request_id, PositionKind::Hover)
}
fn position(
&mut self,
context: &Context,
request_id: u64,
kind: PositionKind,
) -> Result<(), String> {
let source = Source {
buffer_id: context.buffer_id,
revision: context.revision,
row: context
.row
.checked_sub(1)
.ok_or("LSP definition row is invalid")?,
.ok_or_else(|| format!("LSP {} row is invalid", kind.name()))?,
column: context
.column
.checked_sub(1)
.ok_or("LSP definition column is invalid")?,
.ok_or_else(|| format!("LSP {} column is invalid", kind.name()))?,
request_id,
};
self.command(
context.buffer_id,
Input::Definition(source),
Input::Position(kind, source),
Some(request_id),
)
}
@@ -379,6 +407,7 @@ struct SyncOptions {
enum Request {
Initialize,
Definition(Source),
Hover(Source),
Shutdown,
}
@@ -392,10 +421,11 @@ struct Runtime {
snapshots: HashMap<usize, Arc<DocumentSnapshot>>,
documents: HashMap<usize, Document>,
requests: HashMap<u64, Request>,
pending: VecDeque<Source>,
pending: VecDeque<(PositionKind, Source)>,
next_id: u64,
ready: bool,
definition: bool,
hover: bool,
sync: SyncOptions,
}
@@ -422,6 +452,7 @@ impl Runtime {
next_id: 1,
ready: false,
definition: false,
hover: false,
sync: SyncOptions::default(),
};
runtime.request(
@@ -439,6 +470,8 @@ impl Runtime {
"synchronization": {"dynamicRegistration": false, "willSave": false,
"willSaveWaitUntil": false, "didSave": true},
"definition": {"dynamicRegistration": false, "linkSupport": true},
"hover": {"dynamicRegistration": false,
"contentFormat": ["plaintext", "markdown"]},
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true}
}
}
@@ -457,7 +490,7 @@ impl Runtime {
event,
served,
}) => self.document(snapshot, event, served)?,
Ok(Input::Definition(source)) => self.definition(source)?,
Ok(Input::Position(kind, source)) => self.position(kind, source)?,
Ok(Input::Diagnostics(id)) => {
let text = if !self.documents.contains_key(&id) {
"no running server for this document".to_owned()
@@ -634,7 +667,7 @@ impl Runtime {
Ok(())
}
fn definition(&mut self, source: Source) -> Result<(), String> {
fn position(&mut self, kind: PositionKind, source: Source) -> Result<(), String> {
let Some(doc) = self.documents.get(&source.buffer_id) else {
return Ok(());
};
@@ -645,23 +678,33 @@ impl Runtime {
if self.pending.len() == QUEUE_SIZE {
return Err("LSP pending command queue is full".into());
}
self.pending.push_back(source);
self.pending.push_back((kind, source));
return Ok(());
}
if !self.definition {
return self.emit(message(
let (supported, method, unsupported) = match kind {
PositionKind::Definition => (
self.definition,
"textDocument/definition",
"server does not support go-to-definition",
Some(source.request_id),
));
),
PositionKind::Hover => (
self.hover,
"textDocument/hover",
"server does not support hover",
),
};
if !supported {
return self.emit(message(unsupported, Some(source.request_id)));
}
let position = outbound_position(&doc.snapshot.text, source.row, source.column)?;
self.request(
"textDocument/definition",
Some(json!({
"textDocument": {"uri": doc.uri}, "position": position
})),
Request::Definition(source),
)
let params = json!({
"textDocument": {"uri": doc.uri}, "position": position
});
let request = match kind {
PositionKind::Definition => Request::Definition(source),
PositionKind::Hover => Request::Hover(source),
};
self.request(method, Some(params), request)
}
fn request(
@@ -721,27 +764,41 @@ impl Runtime {
}
} else {
let id = frame["id"].as_u64().ok_or("unexpected LSP response")?;
if object.contains_key("result") == object.contains_key("error") {
return Err("unexpected LSP response".into());
}
let context = self.requests.remove(&id).ok_or("unexpected LSP response")?;
if object.contains_key("result") == object.contains_key("error") {
return match context {
Request::Hover(source) => {
self.hover_result(source, Err("unexpected LSP response".into()))
}
_ => Err("unexpected LSP response".into()),
};
}
if let Some(error) = object.get("error") {
let text = error["message"]
.as_str()
.filter(|_| error["code"].as_i64().is_some())
.ok_or("invalid LSP error response")?;
.filter(|_| error["code"].as_i64().is_some());
let Some(text) = text else {
return match context {
Request::Hover(source) => {
self.hover_result(source, Err("invalid LSP error response".into()))
}
_ => Err("invalid LSP error response".into()),
};
};
match context {
Request::Initialize => Err(format!("initialization failed: {text}")),
Request::Definition(source) => self.emit(message(
&format!("definition failed: {text}"),
Some(source.request_id),
)),
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
Request::Shutdown => Ok(()),
}
} else {
match context {
Request::Initialize => self.initialized(&frame["result"]),
Request::Definition(source) => self.definition_result(source, &frame["result"]),
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
Request::Shutdown => self.notify("exit", None),
}
}
@@ -762,6 +819,9 @@ impl Runtime {
self.definition = capabilities
.get("definitionProvider")
.is_some_and(|value| value == true || value.is_object());
self.hover = capabilities
.get("hoverProvider")
.is_some_and(|value| value == true || value.is_object());
self.notify("initialized", Some(json!({})))?;
self.notify(
"workspace/didChangeConfiguration",
@@ -776,8 +836,8 @@ impl Runtime {
}
self.documents.insert(id, doc);
}
while let Some(source) = self.pending.pop_front() {
self.definition(source)?;
while let Some((kind, source)) = self.pending.pop_front() {
self.position(kind, source)?;
}
Ok(())
}
@@ -908,6 +968,31 @@ impl Runtime {
}
}
fn hover_result(&self, source: Source, result: Result<&Value, String>) -> Result<(), String> {
let Some(doc) = self
.documents
.get(&source.buffer_id)
.filter(|doc| doc.snapshot.revision == source.revision)
else {
return Ok(());
};
match result.and_then(|result| hover_text(result, &doc.snapshot.text)) {
Ok(Some(text)) => self.emit(PluginUpdate {
request_id: Some(source.request_id),
effects: vec![UpdateEffect::Hover {
buffer_id: source.buffer_id,
revision: source.revision,
text,
}],
}),
Ok(None) => self.emit(message("no hover information", Some(source.request_id))),
Err(reason) => self.emit(message(
&format!("hover failed: {reason}"),
Some(source.request_id),
)),
}
}
fn definition_effects(
&self,
source: &Source,
@@ -1583,6 +1668,91 @@ fn message(text: &str, request_id: Option<u64>) -> PluginUpdate {
}
}
fn hover_text(result: &Value, source: &str) -> Result<Option<String>, String> {
if result.is_null() {
return Ok(None);
}
let object = result.as_object().ok_or("invalid LSP hover result")?;
if let Some(value) = object.get("range") {
range(&normalized(source), value)?;
}
let contents = object.get("contents").ok_or("missing LSP hover contents")?;
let mut text = String::new();
match contents {
Value::Array(items) => {
for item in items {
append_hover(&mut text, marked_string(item)?)?;
}
}
Value::Object(object) if object.contains_key("kind") => {
match object.get("kind").and_then(Value::as_str) {
Some("plaintext" | "markdown") => {}
_ => return Err("invalid LSP hover markup kind".into()),
}
append_hover(
&mut text,
object
.get("value")
.and_then(Value::as_str)
.ok_or("invalid LSP hover markup value")?,
)?;
}
value => append_hover(&mut text, marked_string(value)?)?,
}
Ok((!text.trim().is_empty()).then_some(text))
}
fn marked_string(value: &Value) -> Result<&str, String> {
match value {
Value::String(text) => Ok(text),
Value::Object(object) if object.get("language").and_then(Value::as_str).is_some() => object
.get("value")
.and_then(Value::as_str)
.ok_or_else(|| "invalid LSP marked string value".into()),
_ => Err("invalid LSP marked string".into()),
}
}
fn append_hover(text: &mut String, value: &str) -> Result<(), String> {
if value.is_empty() {
return Ok(());
}
if !text.is_empty() {
if text.len() + 2 > MAX_BODY {
return Err("LSP hover contents exceed 4 MiB".into());
}
text.push_str("\n\n");
}
let mut characters = value.chars().peekable();
while let Some(character) = characters.next() {
let character = match character {
'\r' => {
if characters.peek() == Some(&'\n') {
characters.next();
}
'\n'
}
'\n' | '\t' => character,
character if character.is_control() => ' ',
character => character,
};
let length = if character == '\t' {
4
} else {
character.len_utf8()
};
if text.len() + length > MAX_BODY {
return Err("LSP hover contents exceed 4 MiB".into());
}
if character == '\t' {
text.push_str(" ");
} else {
text.push(character);
}
}
Ok(())
}
fn clean(text: &str) -> String {
text.chars()
.map(|character| {
@@ -1655,13 +1825,13 @@ fn outbound_position(text: &str, row: usize, column: usize) -> Result<Value, Str
let line = text
.split('\n')
.nth(row)
.ok_or("definition source row is outside the document")?;
.ok_or("LSP source row is outside the document")?;
let mut units = 0;
let mut clusters = line.graphemes(true);
for _ in 0..column {
units += clusters
.next()
.ok_or("definition source column is outside the line")?
.ok_or("LSP source column is outside the line")?
.encode_utf16()
.count();
}
@@ -1797,4 +1967,58 @@ mod tests {
assert!(uri_path(uri).is_err(), "{uri}");
}
}
#[test]
fn hover_contents_keep_lines_and_reject_malformed_values() {
let source = "A😀e\u{301}Z\r\n";
for kind in ["plaintext", "markdown"] {
assert_eq!(
hover_text(
&json!({
"contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"},
"range": {"start": {"line": 0, "character": 2},
"end": {"line": 0, "character": 4}}
}),
source,
)
.unwrap(),
Some("😀e\u{301}\n code\nnext ".into()),
);
}
for contents in [
json!("line\ncode"),
json!({"language": "rust", "value": "line\ncode"}),
] {
assert_eq!(
hover_text(&json!({"contents": contents}), source).unwrap(),
Some("line\ncode".into()),
);
}
assert_eq!(
hover_text(
&json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}),
source,
)
.unwrap(),
Some("first\n\nfn main() {\n}".into()),
);
for value in [
Value::Null,
json!({"contents": ""}),
json!({"contents": []}),
] {
assert_eq!(hover_text(&value, source).unwrap(), None);
}
for value in [
json!({}),
json!({"contents": {"kind": "html", "value": "x"}}),
json!({"contents": {"language": "rust", "value": false}}),
json!({"contents": [{"kind": "markdown", "value": "x"}]}),
json!({"contents": "x", "range": {"start": {"line": 1, "character": 0},
"end": {"line": 0, "character": 0}}}),
json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}),
] {
assert!(hover_text(&value, source).is_err());
}
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ Set VAMP_RUBY to choose the Ruby executable.
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
LSP commands: :lsp_definition, :lsp_diagnostics";
LSP commands: :lsp_definition, :lsp_diagnostics, :lsp_hover
Hover binding: config.bind \"K\", config.core.lsp_hover (no default key)";
struct Options {
file: Option<PathBuf>,
+5
View File
@@ -67,6 +67,11 @@ pub enum UpdateEffect {
row: usize,
column: usize,
},
Hover {
buffer_id: usize,
revision: u64,
text: String,
},
Message {
text: String,
},
+1 -1
View File
@@ -64,7 +64,7 @@ module Vamp
copy_system paste_system_before paste_system_after
select_lines clone_above clone_below select_next skip_occurrence select_all
file_picker file_explorer
lsp_definition lsp_diagnostics
lsp_definition lsp_diagnostics lsp_hover
].each do |name|
action = Action.new("core.#{name}".freeze).freeze
define_singleton_method(name) { action }
+86 -5
View File
@@ -41,6 +41,8 @@ pub struct EditorSession {
lsp: Lsp,
lsp_request_id: u64,
lsp_source_cursor: Option<Cursor>,
pending_hover: bool,
hover_owner: Option<String>,
}
impl EditorSession {
@@ -124,6 +126,8 @@ impl EditorSession {
lsp: Lsp::new(config.lsp),
lsp_request_id: 0,
lsp_source_cursor: None,
pending_hover: false,
hover_owner: None,
};
session.synchronize_documents();
Ok(session)
@@ -134,6 +138,7 @@ impl EditorSession {
}
pub fn handle(&mut self, input: Input) {
self.cancel_pending_hover();
let invocation = self.editor.handle(input);
self.synchronize_documents();
if let Some(invocation) = invocation {
@@ -143,6 +148,7 @@ impl EditorSession {
}
pub fn execute(&mut self, command: &str) {
self.cancel_pending_hover();
let invocation = self.editor.execute(command);
self.synchronize_documents();
if let Some(invocation) = invocation {
@@ -154,7 +160,7 @@ impl EditorSession {
fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" {
let result = match invocation.command.as_str() {
"lsp_definition" => {
"lsp_definition" | "lsp_hover" => {
if let Some(id) = self.lsp_request_id.checked_add(1) {
self.lsp_request_id = id;
let context = self.editor.context();
@@ -164,7 +170,12 @@ impl EditorSession {
};
self.requests.entry("core".into()).or_default().latest = Some((id, source));
self.lsp_source_cursor = Some(self.editor.cursor());
self.lsp.definition(&context, id)
self.pending_hover = invocation.command == "lsp_hover";
if self.pending_hover {
self.lsp.hover(&context, id)
} else {
self.lsp.definition(&context, id)
}
} else {
Err("LSP request IDs exhausted".into())
}
@@ -202,11 +213,24 @@ impl EditorSession {
}
}
fn cancel_pending_hover(&mut self) {
if self.pending_hover {
if let Some(requests) = self.requests.get_mut("core") {
requests.latest = None;
}
self.pending_hover = false;
}
}
fn retire(&mut self, owner: &str, error: &str) {
// Drop also stops a live host whose protocol-valid effects were rejected.
drop(self.plugins.remove(owner));
self.requests.remove(owner);
self.editor.disable_plugin(owner, error);
if self.hover_owner.as_deref() == Some(owner) {
self.editor.clear_hover();
self.hover_owner = None;
}
}
fn synchronize_documents(&mut self) -> bool {
@@ -313,7 +337,7 @@ impl EditorSession {
.any(|document| document.id == source.buffer_id && document.revision == source.revision)
}
fn navigation_is_current(
fn request_is_current(
&self,
owner: &str,
request_id: Option<u64>,
@@ -335,7 +359,33 @@ impl EditorSession {
})
}
fn hover_is_current(
&self,
owner: &str,
request_id: Option<u64>,
source: InvocationSource,
) -> bool {
self.request_is_current(owner, request_id, source)
&& self.editor.mode() == Mode::Normal
&& self.editor.palette().is_none()
&& self
.editor
.file_picker()
.is_none_or(|picker| !picker.focused)
}
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
if owner == "core"
&& let Some(id) = update.request_id
&& self
.requests
.get(owner)
.and_then(|requests| requests.latest)
.map(|latest| latest.0)
!= Some(id)
{
return Ok(false);
}
// Validate the whole applicable annotation batch before changing any buffer.
for effect in &update.effects {
match effect {
@@ -357,7 +407,7 @@ impl EditorSession {
buffer_id: *buffer_id,
revision: *revision,
};
if self.navigation_is_current(owner, update.request_id, source)
if self.request_is_current(owner, update.request_id, source)
&& (path.as_os_str().is_empty()
|| path.to_string_lossy().chars().any(char::is_control))
{
@@ -366,6 +416,19 @@ impl EditorSession {
);
}
}
UpdateEffect::Hover {
buffer_id,
revision,
text,
} => {
let source = InvocationSource {
buffer_id: *buffer_id,
revision: *revision,
};
if self.hover_is_current(owner, update.request_id, source) {
crate::editor::validate_hover_text(text)?;
}
}
UpdateEffect::Message { .. } => {}
}
}
@@ -392,7 +455,7 @@ impl EditorSession {
buffer_id,
revision,
};
if self.navigation_is_current(owner, update.request_id, source) {
if self.request_is_current(owner, update.request_id, source) {
if let Err(error) =
self.editor.navigate_to(path, TextPosition { row, column })
{
@@ -402,6 +465,24 @@ impl EditorSession {
changed = true;
}
}
UpdateEffect::Hover {
buffer_id,
revision,
text,
} => {
let source = InvocationSource {
buffer_id,
revision,
};
if self.hover_is_current(owner, update.request_id, source) {
self.editor.show_hover(text)?;
self.hover_owner = Some(owner.into());
if owner == "core" {
self.pending_hover = false;
}
changed = true;
}
}
UpdateEffect::Message { text } => {
changed |= self.editor.message() != text;
self.editor.set_message(text);
+100 -1
View File
@@ -29,7 +29,7 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Editor, Input, Mode, Palette},
editor::{Editor, Hover, Input, Mode, Palette},
files::{DockSide, FilePicker, FilePresentation, FileView},
plugin::{Diagnostic, DiagnosticSeverity},
session::EditorSession,
@@ -631,6 +631,67 @@ fn draw(
editor.selection_navigation_hint(),
);
}
if let Some(hover) = editor.hover() {
draw_hover(frame, hover, viewport);
}
}
fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(84)),
Constraint::Fill(1),
])
.areas(area);
let [_, panel, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(
area.height
.min(hover.lines().len().saturating_add(3).min(18) as u16),
),
Constraint::Fill(1),
])
.areas(column);
frame.render_widget(Clear, panel);
let bordered = panel.height >= 4 && panel.width >= 4;
let block = Block::default()
.borders(if bordered {
Borders::ALL
} else {
Borders::NONE
})
.border_style(Style::default().fg(Color::Cyan))
.title(" Hover ");
let content = if bordered { block.inner(panel) } else { panel };
if bordered {
frame.render_widget(block, panel);
}
let [text_area, help_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 3)),
])
.areas(content);
let lines: Vec<Line> = hover
.lines()
.skip(hover.top)
.take(usize::from(text_area.height))
.map(|line| {
Line::from(visible_line(
line,
display_column(line, hover.left),
usize::from(text_area.width),
))
})
.collect();
frame.render_widget(Paragraph::new(lines), text_area);
frame.render_widget(
Paragraph::new("Arrows/Ctrl+h/j/k/l scroll · Esc close")
.style(Style::default().fg(Color::DarkGray)),
help_area,
);
}
fn dock_layout(area: Rect, side: DockSide, focused: bool) -> (Rect, Option<Rect>) {
@@ -977,6 +1038,44 @@ mod tests {
.join("\n")
}
#[test]
fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap();
let mut syntax = SyntaxCache::new();
let (mut top, mut left) = (0, 0);
for _ in 0..2 {
editor.handle(Input::Right);
}
let rendered = terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
.unwrap();
assert_eq!(rendered.buffer[(2, 0)].symbol(), "e\u{301}");
assert_eq!(rendered.buffer[(3, 0)].symbol(), "👩‍🚀");
assert_eq!(rendered.buffer[(5, 0)].symbol(), "z");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(2, 0)
);
assert_eq!((top, left), (0, 0));
editor.handle(Input::Down);
editor.handle(Input::Home);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
.unwrap();
assert!(screen_text(&terminal).ends_with("last"));
for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
.unwrap();
}
assert_eq!(editor.cursor(), crate::editor::Cursor::default());
assert_eq!(editor.lines(), &[""]);
}
fn annotation(
start: (usize, usize),
end: (usize, usize),