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
+2
View File
@@ -170,6 +170,8 @@ Vertical wheel input over a hover popup or floating/docked file explorer scrolls
Use `:word_wrap.toggle`, the **Toggle word wrap** palette action, or `config.core.word_wrap.toggle` in a custom binding. Wrapping defaults to **off** for new buffers and has **no default keybinding**. Each ordinary buffer retains its own setting; toggling does not edit text or enter undo history.
Toggling preserves the source caret and anchors a visible caret to its previous screen row in ordinary and diff buffers. File boundaries clamp the anchor when there are not enough preceding rows. Horizontal position follows the reflowed text.
Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling.
## Buffers
+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