fix: center LSP definition destinations in the viewport

This commit is contained in:
Valdemar Lorenzen
2026-10-13 12:50:57 +02:00
parent e36c28c981
commit c4f72a45ba
5 changed files with 457 additions and 1 deletions
+26
View File
@@ -8,6 +8,7 @@ use crate::plugin::{
};
use serde::Serialize;
use std::borrow::Cow;
use std::cell::Cell;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::fmt;
use std::fs::{self, OpenOptions};
@@ -1337,6 +1338,7 @@ struct ViewState {
search_match: Option<SearchMatch>,
command_line: String,
caret_reveal_revision: u64,
lsp_center: Cell<Option<(u64, u64, Cursor)>>,
insert_start: Option<Snapshot>,
pending_edit: Option<EditRecipe>,
visual_motions: Option<Vec<Motion>>,
@@ -1354,6 +1356,7 @@ impl ViewState {
search_match: None,
command_line: String::new(),
caret_reveal_revision: 0,
lsp_center: Cell::new(None),
insert_start: None,
pending_edit: None,
visual_motions: None,
@@ -3527,6 +3530,7 @@ impl Editor {
target_range: Option<Utf16Range>,
) -> Result<(), String> {
self.pending_lsp_navigation = None;
self.active.view.lsp_center.set(None);
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());
}
@@ -3808,6 +3812,7 @@ impl Editor {
diff.normalize_focus();
diff.reveal_source_row(focus, row);
self.place_navigation(position);
self.request_lsp_center();
self.record_jump(source);
Ok(())
}
@@ -3829,6 +3834,7 @@ impl Editor {
self.switch_buffer(id);
}
self.place_navigation(position);
self.request_lsp_center();
self.record_jump(source);
}
@@ -3933,7 +3939,26 @@ impl Editor {
self.active.view.caret_reveal_revision
}
fn request_lsp_center(&self) {
self.active.view.lsp_center.set(Some((
self.text_revision(),
self.caret_reveal_revision(),
self.cursor(),
)));
}
/// Consume only when the destination has a usable text viewport.
pub(crate) fn take_lsp_center(&self) -> bool {
self.active.view.lsp_center.take()
== Some((
self.text_revision(),
self.caret_reveal_revision(),
self.cursor(),
))
}
fn reveal_caret(&mut self) {
self.active.view.lsp_center.set(None);
self.active.view.caret_reveal_revision =
self.active.view.caret_reveal_revision.wrapping_add(1);
}
@@ -5613,6 +5638,7 @@ impl Editor {
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
self.pending_lsp_navigation = None;
self.active.view.lsp_center.set(None);
// Normalize genuine terminal aliases before list and editing dispatch.
let input = match input {
Input::Ctrl(ch) => control_key(ch),
+426
View File
@@ -1561,6 +1561,11 @@ fn draw_document(
})
})
.map(|row| row.min(height.saturating_sub(1)));
let center_row = (height > 0 && width > 0 && editor.take_lsp_center()).then_some(height / 2);
let toggle_row = center_row.or(toggle_row);
if center_row.is_some() {
document.manual = None;
}
if toggle_row.is_some() {
document.anchor = Some(identity);
}
@@ -2665,6 +2670,26 @@ fn draw_diff(
(row < usize::from(diff_inner(previous.regions[side].0).height)).then_some(row)
}
});
let center_row = editor.and_then(|editor| {
let focus = editor.diff_buffer_focus()?;
let pane = if focus == Focus::Before {
before_area
} else {
after_area
};
let height = usize::from(diff_inner(pane).height);
let file = selected.filter(|file| file.loaded)?;
(editor.diff_interacting()
&& diff.follow_caret
&& height > 0
&& diff_pane_text_width(diff, file, pane, focus) > 0
&& diff
.source_display_row(focus, editor.cursor().row)
.is_some()
&& editor.take_lsp_center())
.then_some(height / 2)
});
let toggle_row = center_row.or(toggle_row);
if wrapping && let Some(file) = selected.filter(|file| file.loaded) {
let caret = editor
.filter(|editor| editor.diff_interacting() && diff.follow_caret)
@@ -4502,6 +4527,7 @@ fn semantic_style(token: &SemanticToken) -> Style {
#[allow(clippy::single_range_in_vec_init)]
mod tests {
use super::*;
use crate::plugin::TextPosition;
use std::{
fs,
path::PathBuf,
@@ -8309,6 +8335,406 @@ end
assert!(area.contains(terminal.get_cursor_position().unwrap()));
}
fn definition_point(row: usize, column: usize) -> crate::plugin::Utf16Range {
crate::plugin::Utf16Range {
start: (row, column),
end: (row, column),
}
}
#[test]
fn lsp_definitions_center_in_actual_document_panes_once_and_preserve_dirty_targets() {
for (layout, target_kind, row) in [
("single", "same", 0),
("single", "same", 2),
("single", "new", 50),
("split", "same", 50),
("dock", "dirty", 50),
("single", "same", 99),
] {
let directory = Directory::new();
let text = (0..100)
.map(|row| format!("definition_{row:03}"))
.collect::<Vec<_>>()
.join("\n");
let source = directory.file("source.txt", &text);
let target = if target_kind == "same" {
source.clone()
} else {
directory.file("target.txt", &text)
};
let mut editor = Editor::open(Some(source.clone()), vec![]).unwrap();
if target_kind == "dirty" {
editor
.navigate_to(target.clone(), TextPosition { row: 0, column: 0 })
.unwrap();
editor.handle(Input::Char('i'));
editor.handle(Input::Paste("UNSAVED ".into()));
editor.handle(Input::Escape);
editor
.navigate_to(source, TextPosition { row: 0, column: 0 })
.unwrap();
}
if layout == "split" {
editor.execute("pane.split_horizontal");
} else if layout == "dock" {
editor.set_project_root(directory.0.clone());
editor.set_file_explorer_side("bottom").unwrap();
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
editor.handle(Input::Escape);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
editor
.navigate_lsp_to(target.clone(), definition_point(row, 0), None)
.unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.document.geometry.unwrap().1;
if layout != "single" {
assert!(viewport.height < 22, "{layout}");
}
let centered = viewport.y + (row as u16).min(viewport.height / 2);
assert_eq!(
terminal.get_cursor_position().unwrap().y,
centered,
"{layout}/{target_kind}/{row}"
);
assert_eq!(editor.cursor().row, row);
assert_eq!(editor.path(), Some(target.as_path()));
if target_kind == "dirty" {
assert!(editor.lines()[0].starts_with("UNSAVED "));
assert_eq!(fs::read_to_string(&target).unwrap(), text);
}
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, centered);
if row == 50 {
editor.handle(Input::Down);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1);
let before = (
views.offsets.clone(),
screen_text(&terminal),
editor.cursor(),
);
for (path, range) in [
(target.clone(), definition_point(1000, 0)),
(directory.0.join("missing.txt"), definition_point(0, 0)),
] {
assert!(editor.navigate_lsp_to(path, range, None).is_err());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.offsets, before.0);
assert_eq!(screen_text(&terminal), before.1);
assert_eq!(editor.cursor(), before.2);
assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1);
}
}
}
}
#[test]
fn lsp_definition_centers_wrapped_utf16_grapheme_destination_visually() {
let directory = Directory::new();
let mut lines = (0..80)
.map(|row| format!("row_{row:03}"))
.collect::<Vec<_>>();
lines[40] = format!("{}😀e\u{301}ZTAIL", "a".repeat(80));
let path = directory.file("wrapped.txt", &lines.join("\n"));
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor.execute("word_wrap.toggle");
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
editor
.navigate_lsp_to(path, definition_point(40, 84), None)
.unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.document.geometry.unwrap().1;
let caret = terminal.get_cursor_position().unwrap();
assert_eq!(
editor.cursor(),
Cursor {
row: 40,
column: 82
}
);
assert_eq!(caret.y, viewport.y + viewport.height / 2);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].symbol(),
"Z"
);
assert_eq!(editor.lines(), lines);
}
#[test]
fn markdown_search_and_plugin_navigation_keep_ordinary_caret_following() {
for navigation in ["markdown", "search", "plugin"] {
let directory = Directory::new();
let mut lines = (0..80)
.map(|row| format!("row_{row:03}"))
.collect::<Vec<_>>();
lines[0] = "[destination](#destination)".into();
lines[50] = "# Destination".into();
let path = directory.file("links.md", &lines.join("\n"));
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
match navigation {
"markdown" => {
editor.execute("follow_link");
}
"search" => {
editor.handle(Input::Char('/'));
for ch in "Destination".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Enter);
}
"plugin" => editor
.navigate_to(path, TextPosition { row: 50, column: 0 })
.unwrap(),
_ => unreachable!(),
}
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.document.geometry.unwrap().1;
assert_eq!(editor.cursor().row, 50, "{navigation}");
assert_eq!(
terminal.get_cursor_position().unwrap().y,
viewport.bottom() - 1,
"{navigation}"
);
}
}
#[test]
fn lsp_definition_centers_revealed_diff_symbol_in_each_actual_focused_pane() {
for (mode, axis, focus) in [
(
ViewMode::Split,
crate::panes::SplitAxis::Vertical,
Focus::Before,
),
(
ViewMode::Split,
crate::panes::SplitAxis::Horizontal,
Focus::After,
),
(
ViewMode::Unified,
crate::panes::SplitAxis::Vertical,
Focus::After,
),
] {
let before = (0..90)
.map(|row| format!("line_{row:03}"))
.collect::<Vec<_>>();
let mut after = before.clone();
for row in 10..31 {
after[row] = format!("changed_{row:03}");
}
after[70] = "changed_body".into();
let mut file = syntax_diff_file("symbol.txt", &before.join("\n"), &after.join("\n"));
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_diff_display_options(mode, axis);
editor.set_diff_context_options(1, 3);
install_readonly_diff(&mut editor, file);
editor.handle(Input::Char('\t'));
if focus == Focus::After {
editor.handle(Input::Char('\t'));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let diff = editor.diff().unwrap();
let comparison = diff.comparison_id;
let side = usize::from(focus == Focus::After);
let path = diff.snapshot_roots[side]
.as_ref()
.unwrap()
.join("symbol.txt");
assert!(diff.source_display_row(focus, 40).is_none());
editor
.navigate_lsp_to(
path.clone(),
definition_point(40, 2),
Some(crate::plugin::Utf16Range {
start: (40, 0),
end: (71, 0),
}),
)
.unwrap();
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
let pane = diff_inner(geometry.regions[if focus == Focus::Before { 1 } else { 2 }].0);
assert_eq!(
caret.y,
pane.y + pane.height / 2,
"{mode:?}/{axis:?}/{focus:?}"
);
assert!(pane.contains(caret));
assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 });
assert_eq!(editor.diff().unwrap().comparison_id, comparison);
assert!(
editor
.diff()
.unwrap()
.source_display_row(focus, 40)
.is_some()
);
assert!(editor.mutation_blocked());
editor.handle(Input::Down);
assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y);
editor
.navigate_lsp_to(path, definition_point(80, 1), None)
.unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.document.geometry.unwrap().1;
assert!(editor.diff().is_none());
assert_eq!(editor.tab_count(), 2);
assert_eq!(
terminal.get_cursor_position().unwrap().y,
viewport.y + viewport.height / 2
);
assert!(editor.mutation_blocked());
}
}
#[test]
fn deferred_lsp_definition_centers_only_successful_current_diff_results() {
for outcome in ["success", "stale", "failed"] {
let directory = Directory::new();
let before = (0..90)
.map(|row| format!("line_{row:03}"))
.collect::<Vec<_>>();
let mut after = before.clone();
for row in 10..31 {
after[row] = format!("changed_{row:03}");
}
after[70] = "changed_body".into();
let mut source = syntax_diff_file("source.txt", &before.join("\n"), &after.join("\n"));
source.hunks = crate::diff::line_hunks(&source.before, false, &source.after, false);
let mut target = syntax_diff_file("target.txt", &before.join("\n"), &after.join("\n"));
target.hunks = crate::diff::line_hunks(&target.before, false, &target.after, false);
directory.file("target.txt", &after.join("\n"));
let mut unloaded = syntax_diff_file("target.txt", "", "");
unloaded.loaded = false;
unloaded.before.clear();
unloaded.after.clear();
unloaded.hunks.clear();
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_diff_context_options(1, 3);
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: directory.0.clone(),
editable_paths: Default::default(),
before: "baseline".into(),
after: "historic".into(),
files: vec![source, unloaded],
})),
);
editor.handle(Input::Char('\t'));
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
render_native_diff(&mut terminal, &mut views, &mut editor);
let comparison_id = editor.diff().unwrap().comparison_id.unwrap();
editor
.navigate_lsp_to(
directory.0.join("target.txt"),
definition_point(40, 2),
Some(crate::plugin::Utf16Range {
start: (40, 0),
end: (71, 0),
}),
)
.unwrap();
let (id, _) = editor.take_diff_request().unwrap();
if outcome == "stale" {
editor.handle(Input::Right);
editor.handle(Input::Left);
}
let before_caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let before_pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0);
let before_viewport = views.panels.diff.as_ref().unwrap().viewport;
let before_buffer = editor.buffer_id();
let before_source = editor.cursor();
editor.apply_diff_result(
id,
if outcome == "failed" {
Err("definition file unavailable".into())
} else {
Ok(crate::diff::Outcome::File {
comparison_id,
file_index: 1,
file: target,
})
},
);
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
if outcome == "success" {
let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0);
assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 });
assert_eq!(editor.diff().unwrap().file, 1);
assert_eq!(caret.y, pane.y + pane.height / 2);
editor.handle(Input::Down);
assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y);
} else {
assert_eq!(editor.buffer_id(), before_buffer, "{outcome}");
assert_eq!(editor.cursor(), before_source, "{outcome}");
assert_eq!(editor.diff().unwrap().file, 0, "{outcome}");
assert_eq!(
views.panels.diff.as_ref().unwrap().viewport,
before_viewport,
"{outcome}"
);
let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0);
assert_eq!(
(caret.x - pane.x, caret.y - pane.y),
(
before_caret.x - before_pane.x,
before_caret.y - before_pane.y
),
"{outcome}"
);
assert_eq!(
render_native_diff(&mut terminal, &mut views, &mut editor),
caret
);
}
}
}
fn readonly_diff_editor(file: ChangedFile) -> Editor {
let mut editor = Editor::open(None, vec![]).unwrap();
install_readonly_diff(&mut editor, file);