fix: preserve caret screen row when toggling word wrap
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user