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. 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. 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 ## Buffers
+213 -25
View File
@@ -142,6 +142,7 @@ struct DocumentViewport {
continuations: BTreeMap<usize, usize>, continuations: BTreeMap<usize, usize>,
presentation: Option<(usize, bool, usize, usize)>, presentation: Option<(usize, bool, usize, usize)>,
visible: Vec<(usize, usize, Segment)>, visible: Vec<(usize, usize, Segment)>,
anchor: Option<(usize, u64, Cursor)>,
} }
struct ManualViewport { struct ManualViewport {
@@ -685,6 +686,42 @@ fn draw(
let selections = editor.selections(); let selections = editor.selections();
let wrapped = editor.word_wrap(); let wrapped = editor.word_wrap();
let presentation = (editor.buffer_id(), wrapped, width, height); 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| { if document.presentation.is_some_and(|previous| {
previous.0 != presentation.0 previous.0 != presentation.0
|| previous.1 != wrapped || previous.1 != wrapped
@@ -715,7 +752,15 @@ fn draw(
), ),
)); ));
if !wrapped { 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() if document.manual.is_some()
&& let Some((id, revision, columns)) = document.horizontal_extent && let Some((id, revision, columns)) = document.horizontal_extent
&& id == editor.buffer_id() && id == editor.buffer_id()
@@ -751,7 +796,17 @@ fn draw(
column, 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; start = caret;
} else if caret > visual_page(&mut document.wrap, editor.lines(), start, height).0 { } else if caret > visual_page(&mut document.wrap, editor.lines(), start, height).0 {
start = caret; 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; *top = start.source;
*continuation = start.continuation; *continuation = start.continuation;
let mut position = start; let mut position = start;
@@ -1077,6 +1134,7 @@ struct DiffGeometry {
regions: [(Rect, Focus); 3], regions: [(Rect, Focus); 3],
viewport: Option<(u64, usize, usize, usize, usize)>, viewport: Option<(u64, usize, usize, usize, usize)>,
text_widths: [u16; 2], text_widths: [u16; 2],
wrapping: bool,
} }
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect { fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
@@ -1581,6 +1639,7 @@ fn draw_diff(
], ],
viewport: None, viewport: None,
text_widths: [0; 2], text_widths: [0; 2],
wrapping: diff.word_wrap,
}); });
if let Some(editor) = editor { if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, None, panels); draw_diff_popups(frame, editor, area, None, panels);
@@ -1675,6 +1734,34 @@ fn draw_diff(
let mut before_left = diff.left_before; let mut before_left = diff.left_before;
let mut after_left = diff.left_after; let mut after_left = diff.left_after;
let wrapping = diff.word_wrap; 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) { if wrapping && let Some(file) = selected.filter(|file| file.loaded) {
let caret = editor let caret = editor
.filter(|editor| editor.diff_interacting() && diff.follow_caret) .filter(|editor| editor.diff_interacting() && diff.follow_caret)
@@ -1686,7 +1773,7 @@ fn draw_diff(
let viewport = panels let viewport = panels
.diff_wrap .diff_wrap
.get_or_insert_with(DiffWrapViewport::default); .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; top = viewport.top.display;
} else { } else {
panels.diff_wrap = None; panels.diff_wrap = None;
@@ -1702,6 +1789,9 @@ fn draw_diff(
}; };
let height = usize::from(pane.height.saturating_sub(2)); let height = usize::from(pane.height.saturating_sub(2));
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) { 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 { if row < top {
top = row; top = row;
} else if height > 0 && row >= top.saturating_add(height) { } else if height > 0 && row >= top.saturating_add(height) {
@@ -1877,6 +1967,7 @@ fn draw_diff(
.comparison_id .comparison_id
.map(|id| (id, diff.file, top, before_left, after_left)), .map(|id| (id, diff.file, top, before_left, after_left)),
text_widths, text_widths,
wrapping,
}); });
if let Some(picker) = &diff.files if let Some(picker) = &diff.files
&& diff.files_visible && 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( fn update(
&mut self, &mut self,
diff: &DiffState, diff: &DiffState,
file: &ChangedFile, file: &ChangedFile,
areas: [Rect; 2], areas: [Rect; 2],
caret: Option<(Focus, Cursor)>, caret: Option<(Focus, Cursor)>,
anchor_row: Option<usize>,
) { ) {
let Some(id) = diff.comparison_id else { return }; let Some(id) = diff.comparison_id else { return };
let key = Some((id, diff.file, diff.live_revision)); 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)); let height = usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
self.fill(diff, file, height); self.fill(diff, file, height);
if let Some((focus, cursor)) = caret 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); if let Some(row) = anchor_row {
let (cache, source) = if side == 0 { self.top = position;
(&mut self.before, &file.before) for _ in 0..row.min(height.saturating_sub(1)) {
} else { self.top = self.step(diff, file, self.top, false);
(&mut self.after, &file.after) }
}; self.fill(diff, file, height);
let text = source.get(cursor.row).map_or("", String::as_str); } else if position < self.top {
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 {
self.top = position; self.top = position;
self.fill(diff, file, height); self.fill(diff, file, height);
} else if !self.rows.iter().any(|row| row.position == position) { } else if !self.rows.iter().any(|row| row.position == position) {
@@ -4422,6 +4529,93 @@ mod tests {
terminal.get_cursor_position().unwrap() 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] #[test]
fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() { fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() {
let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70)); let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70));
@@ -8646,7 +8840,6 @@ end
terminal terminal
.draw(|frame| viewports.draw(frame, session.editor())) .draw(|frame| viewports.draw(frame, session.editor()))
.unwrap(); .unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(6, 1));
assert!(viewports.scroll( assert!(viewports.scroll(
&mut session, &mut session,
document_wheel(MouseEventKind::ScrollRight, 4, 0) document_wheel(MouseEventKind::ScrollRight, 4, 0)
@@ -8660,10 +8853,7 @@ end
terminal terminal
.draw(|frame| viewports.draw(frame, session.editor())) .draw(|frame| viewports.draw(frame, session.editor()))
.unwrap(); .unwrap();
assert_eq!(viewports.offsets[&id].0, 0);
assert_eq!(viewports.document.continuations[&id], 3);
assert!(!terminal.backend().cursor_visible()); assert!(!terminal.backend().cursor_visible());
assert!(screen_text(&terminal).starts_with(" yzABCDEF"));
assert!(viewports.scroll( assert!(viewports.scroll(
&mut session, &mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0) document_wheel(MouseEventKind::ScrollDown, 4, 0)
@@ -8671,14 +8861,12 @@ end
terminal terminal
.draw(|frame| viewports.draw(frame, session.editor())) .draw(|frame| viewports.draw(frame, session.editor()))
.unwrap(); .unwrap();
assert_eq!(viewports.document.continuations[&id], 4);
terminal.backend_mut().resize(14, 6); terminal.backend_mut().resize(14, 6);
terminal terminal
.draw(|frame| viewports.draw(frame, session.editor())) .draw(|frame| viewports.draw(frame, session.editor()))
.unwrap(); .unwrap();
assert!(viewports.document.manual.is_none()); assert!(viewports.document.manual.is_none());
assert!(terminal.backend().cursor_visible()); 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 }); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 12 });
session.execute("word_wrap.toggle"); session.execute("word_wrap.toggle");
terminal terminal