fix: center LSP definition destinations in the viewport
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user