fix: preserve caret screen row when toggling word wrap

This commit is contained in:
2026-10-11 16:23:14 +02:00
parent 1f625a69c4
commit 0b7d4d2c1f
2 changed files with 215 additions and 25 deletions
+213 -25
View File
@@ -142,6 +142,7 @@ struct DocumentViewport {
continuations: BTreeMap<usize, usize>,
presentation: Option<(usize, bool, usize, usize)>,
visible: Vec<(usize, usize, Segment)>,
anchor: Option<(usize, u64, Cursor)>,
}
struct ManualViewport {
@@ -685,6 +686,42 @@ fn draw(
let selections = editor.selections();
let wrapped = editor.word_wrap();
let presentation = (editor.buffer_id(), wrapped, width, height);
let identity = (editor.buffer_id(), editor.text_revision(), cursor);
if document.anchor != Some(identity)
|| document
.presentation
.is_some_and(|previous| previous.2 != width || previous.3 != height)
{
document.anchor = None;
}
let toggle_row = document
.presentation
.filter(|previous| previous.0 == editor.buffer_id() && previous.1 != wrapped)
.and_then(|previous| {
let column = display_column(&editor.lines()[cursor.row], cursor.column);
let part = if previous.1 {
caret_segment(
document
.wrap
.segments(cursor.row, &editor.lines()[cursor.row]),
column,
)
} else {
0
};
document
.visible
.iter()
.position(|&(row, continuation, segment)| {
row == cursor.row
&& continuation == part
&& (previous.1 || (column >= segment.start && column < segment.end))
})
})
.map(|row| row.min(height.saturating_sub(1)));
if toggle_row.is_some() {
document.anchor = Some(identity);
}
if document.presentation.is_some_and(|previous| {
previous.0 != presentation.0
|| previous.1 != wrapped
@@ -715,7 +752,15 @@ fn draw(
),
));
if !wrapped {
*top = (*top).min(editor.lines().len().saturating_sub(height));
let limit = if document.anchor.is_some() {
editor.lines().len() - 1
} else {
editor.lines().len().saturating_sub(height)
};
*top = (*top).min(limit);
if let Some(row) = toggle_row {
*top = cursor.row.saturating_sub(row);
}
if document.manual.is_some()
&& let Some((id, revision, columns)) = document.horizontal_extent
&& id == editor.buffer_id()
@@ -751,7 +796,17 @@ fn draw(
column,
),
};
if caret < start {
if let Some(row) = toggle_row {
start = caret;
for _ in 0..row {
let Some(previous) =
previous_visual(&mut document.wrap, editor.lines(), start)
else {
break;
};
start = previous;
}
} else if caret < start {
start = caret;
} else if caret > visual_page(&mut document.wrap, editor.lines(), start, height).0 {
start = caret;
@@ -765,7 +820,9 @@ fn draw(
}
}
}
start = clamp_visual_bottom(&mut document.wrap, editor.lines(), start, height);
if document.anchor.is_none() {
start = clamp_visual_bottom(&mut document.wrap, editor.lines(), start, height);
}
*top = start.source;
*continuation = start.continuation;
let mut position = start;
@@ -1077,6 +1134,7 @@ struct DiffGeometry {
regions: [(Rect, Focus); 3],
viewport: Option<(u64, usize, usize, usize, usize)>,
text_widths: [u16; 2],
wrapping: bool,
}
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
@@ -1581,6 +1639,7 @@ fn draw_diff(
],
viewport: None,
text_widths: [0; 2],
wrapping: diff.word_wrap,
});
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, None, panels);
@@ -1675,6 +1734,34 @@ fn draw_diff(
let mut before_left = diff.left_before;
let mut after_left = diff.left_after;
let wrapping = diff.word_wrap;
let toggle_row = panels
.previous_diff
.as_ref()
.filter(|previous| {
previous.wrapping != wrapping
&& previous.viewport.is_some_and(|(id, file, _, _, _)| {
Some(id) == diff.comparison_id && file == diff.file
})
})
.and_then(|previous| {
let editor = editor?;
let focus = editor.diff_buffer_focus()?;
let cursor = editor.cursor();
if previous.wrapping {
let file = selected?;
let viewport = panels.diff_wrap.as_mut()?;
let position = viewport.caret_position(diff, file, focus, cursor)?;
viewport
.rows
.iter()
.position(|row| row.position == position)
} else {
let display = diff.source_display_row(focus, cursor.row)?;
let row = display.checked_sub(previous.viewport?.2)?;
let side = usize::from(focus == Focus::After) + 1;
(row < usize::from(diff_inner(previous.regions[side].0).height)).then_some(row)
}
});
if wrapping && let Some(file) = selected.filter(|file| file.loaded) {
let caret = editor
.filter(|editor| editor.diff_interacting() && diff.follow_caret)
@@ -1686,7 +1773,7 @@ fn draw_diff(
let viewport = panels
.diff_wrap
.get_or_insert_with(DiffWrapViewport::default);
viewport.update(diff, file, [before_area, after_area], caret);
viewport.update(diff, file, [before_area, after_area], caret, toggle_row);
top = viewport.top.display;
} else {
panels.diff_wrap = None;
@@ -1702,6 +1789,9 @@ fn draw_diff(
};
let height = usize::from(pane.height.saturating_sub(2));
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
if let Some(anchor) = toggle_row {
top = row.saturating_sub(anchor.min(height.saturating_sub(1)));
}
if row < top {
top = row;
} else if height > 0 && row >= top.saturating_add(height) {
@@ -1877,6 +1967,7 @@ fn draw_diff(
.comparison_id
.map(|id| (id, diff.file, top, before_left, after_left)),
text_widths,
wrapping,
});
if let Some(picker) = &diff.files
&& diff.files_visible
@@ -2268,12 +2359,35 @@ impl DiffWrapViewport {
}
}
fn caret_position(
&mut self,
diff: &DiffState,
file: &ChangedFile,
focus: Focus,
cursor: Cursor,
) -> Option<DiffWrapPosition> {
let display = diff.source_display_row(focus, cursor.row)?;
let (cache, source) = if focus == Focus::Before {
(&mut self.before, &file.before)
} else {
(&mut self.after, &file.after)
};
let text = source.get(cursor.row).map_or("", String::as_str);
let column = display_column(text, cursor.column);
let continuation = caret_segment(cache.segments(cursor.row, text), column);
Some(DiffWrapPosition {
display,
continuation,
})
}
fn update(
&mut self,
diff: &DiffState,
file: &ChangedFile,
areas: [Rect; 2],
caret: Option<(Focus, Cursor)>,
anchor_row: Option<usize>,
) {
let Some(id) = diff.comparison_id else { return };
let key = Some((id, diff.file, diff.live_revision));
@@ -2310,22 +2424,15 @@ impl DiffWrapViewport {
let height = usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
self.fill(diff, file, height);
if let Some((focus, cursor)) = caret
&& let Some(display) = diff.source_display_row(focus, cursor.row)
&& let Some(position) = self.caret_position(diff, file, focus, cursor)
{
let side = usize::from(focus == Focus::After);
let (cache, source) = if side == 0 {
(&mut self.before, &file.before)
} else {
(&mut self.after, &file.after)
};
let text = source.get(cursor.row).map_or("", String::as_str);
let column = display_column(text, cursor.column);
let continuation = crate::wrap::caret_segment(cache.segments(cursor.row, text), column);
let position = DiffWrapPosition {
display,
continuation,
};
if position < self.top {
if let Some(row) = anchor_row {
self.top = position;
for _ in 0..row.min(height.saturating_sub(1)) {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, height);
} else if position < self.top {
self.top = position;
self.fill(diff, file, height);
} else if !self.rows.iter().any(|row| row.position == position) {
@@ -4422,6 +4529,93 @@ mod tests {
terminal.get_cursor_position().unwrap()
}
#[test]
fn wrap_toggle_preserves_caret_screen_row_in_normal_buffers() {
let directory = Directory::new();
let text = (0..35)
.map(|row| format!("line{row} {}", "word ".repeat(15)))
.collect::<Vec<_>>()
.join("\n");
let mut editor = Editor::open(Some(directory.file("anchor.txt", &text)), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 10)).unwrap();
let mut views = BufferViewports::default();
for _ in 0..12 {
editor.handle(Input::Down);
}
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
for _ in 0..3 {
editor.handle(Input::Up);
}
for _ in 0..30 {
editor.handle(Input::Right);
}
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
let row = terminal.get_cursor_position().unwrap().y;
let source = editor.cursor();
for _ in 0..4 {
editor.execute("word_wrap.toggle");
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, row);
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, row);
assert_eq!(editor.cursor(), source);
assert_eq!(editor.lines().join("\n"), text);
}
for _ in 0..35 {
editor.handle(Input::Down);
}
editor.handle(Input::Home);
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
editor.execute("word_wrap.toggle");
editor.handle(Input::Right);
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
let near_eof_row = terminal.get_cursor_position().unwrap().y;
let near_eof_source = editor.cursor();
editor.execute("word_wrap.toggle");
for _ in 0..2 {
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, near_eof_row);
assert_eq!(editor.cursor(), near_eof_source);
}
}
#[test]
fn wrap_toggle_preserves_caret_screen_row_in_diff_buffers() {
let before = (0..35)
.map(|row| format!("old{row} {}", "word ".repeat(15)))
.collect::<Vec<_>>()
.join("\n");
let after = before.replace("old", "new");
let mut editor = readonly_diff_editor(syntax_diff_file("anchor.txt", &before, &after));
editor.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
for _ in 0..12 {
editor.handle(Input::Down);
}
render_native_diff(&mut terminal, &mut views, &mut editor);
for _ in 0..3 {
editor.handle(Input::Up);
}
for _ in 0..30 {
editor.handle(Input::Right);
}
let row = render_native_diff(&mut terminal, &mut views, &mut editor).y;
let source = editor.cursor();
for _ in 0..4 {
editor.execute("word_wrap.toggle");
assert_eq!(
render_native_diff(&mut terminal, &mut views, &mut editor).y,
row
);
assert_eq!(
render_native_diff(&mut terminal, &mut views, &mut editor).y,
row
);
assert_eq!(editor.cursor(), source);
}
}
#[test]
fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() {
let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70));
@@ -8646,7 +8840,6 @@ end
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(6, 1));
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollRight, 4, 0)
@@ -8660,10 +8853,7 @@ end
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert_eq!(viewports.offsets[&id].0, 0);
assert_eq!(viewports.document.continuations[&id], 3);
assert!(!terminal.backend().cursor_visible());
assert!(screen_text(&terminal).starts_with(" yzABCDEF"));
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
@@ -8671,14 +8861,12 @@ end
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert_eq!(viewports.document.continuations[&id], 4);
terminal.backend_mut().resize(14, 6);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(viewports.document.manual.is_none());
assert!(terminal.backend().cursor_visible());
assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(2, 0));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 12 });
session.execute("word_wrap.toggle");
terminal