diff --git a/docs/editing.md b/docs/editing.md index ca6010f..d489852 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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 diff --git a/src/tui.rs b/src/tui.rs index 2065e86..93b0424 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -142,6 +142,7 @@ struct DocumentViewport { continuations: BTreeMap, 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 { + 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, ) { 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::>() + .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::>() + .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