diff --git a/TODO.md b/TODO.md index 4e7448a..a168620 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ - [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first). - [x] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view). -- [ ] buffer ux improvement: add toggleable word wrap (no keybinding by default). +- [x] buffer ux improvement: add toggleable word wrap (no keybinding by default). - [ ] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action. - [ ] diff view: add character-wise change highlight (enabled by default). - [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view. diff --git a/docs/configuration.md b/docs/configuration.md index bed4ed9..acc33b0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,6 +83,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con | Normal | `follow_link` | | Normal | `diff` | | Normal, Insert, Visual, Command, Search | `diff.close` | +| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` | | Normal | `jump_back`, `jump_forward` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Insert | `lsp.completion`, `snippet.next`, `snippet.previous` | diff --git a/docs/editing.md b/docs/editing.md index 79aacc8..ca6010f 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -160,12 +160,18 @@ Paste is backend input: it is ignored in Normal, Command, Search, and Visual mod ## Mouse wheel scrolling -Mouse-wheel up/down scrolls the document view three rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates and resizing preserve a wheel-scrolled view, clamped to the document. +Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers. -Mouse-wheel left/right scrolls three display columns per event. **Shift+wheel up/down** provides the same left/right scrolling when the terminal does not send horizontal wheel events. Tabs and wide Unicode characters use terminal-cell widths; scrolling stops at the longest line and remains detached from the caret until navigation or editing resumes. +Mouse-wheel left/right scrolls three display columns per event when wrapping is off. **Shift+wheel up/down** provides the same left/right scrolling when the terminal does not send horizontal wheel events. Tabs and wide Unicode characters use terminal-cell widths; scrolling stops at the longest line and remains detached from the caret until navigation or editing resumes. Wrapped text consumes horizontal wheel input without panning. Vertical wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Horizontal wheel input over these panels is consumed without panning the document. Completion lists, command palettes, and startup notices consume wheel input without scrolling the background or editing text. Mouse clicks and dragging are not editing actions. +## Word wrap + +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. + +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 One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID. Caret-following adjusts them as needed; mouse-wheel scrolling can temporarily detach the view from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer. @@ -255,6 +261,8 @@ Text-pane navigation stays within the shown source lines. At an excerpt boundary Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive). +Diff views inherit the originating buffer's word-wrap setting. `word_wrap.toggle` switches both panes together, including read-only snapshots, without changing ordinary-buffer settings. Each aligned source row uses the taller side's wrapped height; shorter-side continuation padding stays blank so subsequent source lines remain aligned. Excerpts and explicit expansion still use source-line counts. + In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens. Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. diff --git a/src/diff.rs b/src/diff.rs index 39bf20f..15b379e 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -182,6 +182,7 @@ enum RequestKind { pub struct DiffState { pub catalog: Option, pub phase: Phase, + pub word_wrap: bool, pub before: Option, pub after: Option, pub category: usize, @@ -222,6 +223,7 @@ impl DiffState { Self { catalog: None, phase: Phase::Before, + word_wrap: false, before: None, after: None, category: 0, diff --git a/src/editor.rs b/src/editor.rs index 7ec2809..a07c36a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -574,6 +574,18 @@ core_actions![ ), ("core.save", Action::Save, &[Mode::Normal], true), ("core.diff", Action::Diff, &[Mode::Normal], true), + ( + "core.word_wrap.toggle", + Action::WordWrapToggle, + &[ + Mode::Normal, + Mode::Insert, + Mode::Visual, + Mode::Command, + Mode::Search + ], + true, + ), ( "core.diff.close", Action::DiffClose, @@ -1023,6 +1035,7 @@ struct Buffer { targets: Vec, primary: usize, mode: Mode, + word_wrap: bool, linewise: bool, search_query: String, search_match: Option, @@ -1068,6 +1081,7 @@ impl Buffer { targets: vec![Target::caret(Cursor::default())], primary: 0, mode: Mode::Normal, + word_wrap: false, linewise: false, search_query: String::new(), search_match: None, @@ -1363,6 +1377,13 @@ impl Editor { self.diff.as_ref() } + pub fn word_wrap(&self) -> bool { + self.diff + .as_ref() + .filter(|diff| diff.phase == Phase::View) + .map_or(self.active.word_wrap, |diff| diff.word_wrap) + } + pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) { self.diff_context_options = (context_lines, expand_lines); if let Some(diff) = &mut self.diff { @@ -1427,6 +1448,7 @@ impl Editor { } self.outstanding_diff_request = None; let mut diff = DiffState::new(); + diff.word_wrap = self.active.word_wrap; diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1); self.diff = Some(diff); } @@ -1868,6 +1890,7 @@ impl Editor { | Action::FilePicker | Action::FileExplorer | Action::DiffClose + | Action::WordWrapToggle | Action::Quit | Action::ForceQuit | Action::Plugins @@ -3956,6 +3979,7 @@ impl Editor { | Action::BufferForceDelete | Action::Diff | Action::DiffClose + | Action::WordWrapToggle | Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), @@ -4535,6 +4559,28 @@ impl Editor { fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { self.finish_diff_prompt(); + if entry.action == Action::WordWrapToggle { + if !args.is_empty() { + self.set_message("The word_wrap.toggle command takes no arguments"); + return None; + } + if let Some(diff) = &mut self.diff + && diff.phase == Phase::View + { + diff.word_wrap = !diff.word_wrap; + diff.follow_caret = true; + } else { + self.active.word_wrap = !self.active.word_wrap; + } + self.count = None; + self.clear_leader(); + self.palette = None; + if self.active.mode == Mode::Command { + self.command_line.clear(); + self.active.mode = Mode::Normal; + } + return None; + } if entry.action == Action::DiffClose { if args.is_empty() { self.release_diff_buffers(); @@ -7989,6 +8035,65 @@ mod tests { assert_eq!(editor.lines(), ["beta"]); } + #[test] + fn word_wrap_toggle_retains_buffers_and_readonly_diff_state() { + let directory = Directory::new(); + let mut editor = editor(); + assert!(!editor.word_wrap()); + editor + .bind("normal", "W", Some("core.word_wrap.toggle".into())) + .unwrap(); + editor.handle(Input::Char('W')); + assert!(editor.word_wrap()); + let original = editor.buffer_id(); + editor.execute(&format!("edit {}", directory.path("other").display())); + assert!(!editor.word_wrap()); + editor.execute(&format!("buffer {original}")); + assert!(editor.word_wrap()); + editor.execute("word_wrap.toggle unexpected"); + assert!(editor.word_wrap()); + editor.execute("word_wrap.toggle"); + assert!(!editor.word_wrap()); + editor.execute("word_wrap.toggle"); + historical_diff( + &mut editor, + &directory.0, + vec!["alpha beta".into()], + vec!["alpha changed".into()], + ); + assert!(editor.word_wrap()); + editor + .bind("visual", "W", Some("core.word_wrap.toggle".into())) + .unwrap(); + type_text(&mut editor, "v2l"); + let text = editor.lines().to_vec(); + let selections = editor.selections(); + let cursor = editor.cursor(); + let revision = editor.active.text_revision; + let history = (editor.active.undo.len(), editor.active.redo.len()); + let comparison = editor.diff().unwrap().comparison_id; + editor.execute("word_wrap.toggle unexpected"); + assert!(editor.word_wrap()); + editor.handle(Input::Char('W')); + assert!(!editor.word_wrap()); + assert_eq!(editor.lines(), text); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.active.text_revision, revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert_eq!(editor.diff().unwrap().phase, Phase::View); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "word_wrap.toggle"); + editor.handle(Input::Enter); + assert!(editor.word_wrap()); + editor.execute("diff.close"); + assert!(editor.word_wrap()); + } + #[test] fn diff_readonly_panes_dispatch_prompts_palette_and_picker_remaps_without_edits() { let directory = Directory::new(); diff --git a/src/lib.rs b/src/lib.rs index d0d4c5e..dc73420 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,3 +12,4 @@ pub mod session; mod snippet; pub mod syntax; pub mod tui; +mod wrap; diff --git a/src/ruby.rs b/src/ruby.rs index a16094e..82de3c8 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -937,12 +937,20 @@ end config.bind "Z", config.core.buffer_force_delete config.bind "R", config.core.diff config.bind "T", config.core.diff.close + config.bind "H", config.core.word_wrap.toggle raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? && config.core.diff.close.frozen? && config.core.diff.name.frozen? && config.core.diff.close.name.frozen? end"#, ); let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap(); + assert!(!review.editor().word_wrap()); + review.handle(Input::Char('H')); + assert!(review.editor().word_wrap()); + review.execute("word_wrap.toggle unexpected"); + assert!(review.editor().word_wrap()); + review.execute("word_wrap.toggle"); + assert!(!review.editor().word_wrap()); review.handle(Input::Char('R')); review.handle(Input::Escape); assert!(review.editor().diff().is_some()); diff --git a/src/tui.rs b/src/tui.rs index 7320ad9..2065e86 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -42,10 +42,11 @@ use crate::{ }, session::EditorSession, syntax::{SyntaxCache, SyntaxKind, SyntaxSpan}, + wrap::{Segment, WrapCache, caret_segment}, }; pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"]; -const TAB_WIDTH: usize = 4; +pub(crate) const TAB_WIDTH: usize = 4; type PanicHook = Box) + Send + Sync + 'static>; @@ -137,6 +138,10 @@ struct DocumentViewport { manual: Option, text_width: usize, horizontal_extent: Option<(usize, u64, usize)>, + wrap: WrapCache, + continuations: BTreeMap, + presentation: Option<(usize, bool, usize, usize)>, + visible: Vec<(usize, usize, Segment)>, } struct ManualViewport { @@ -149,6 +154,87 @@ struct ManualViewport { selections: Vec, } +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct VisualRow { + source: usize, + continuation: usize, +} + +fn next_visual(cache: &mut WrapCache, lines: &[String], position: VisualRow) -> Option { + if position.continuation + 1 + < cache + .segments(position.source, &lines[position.source]) + .len() + { + Some(VisualRow { + continuation: position.continuation + 1, + ..position + }) + } else if position.source + 1 < lines.len() { + Some(VisualRow { + source: position.source + 1, + continuation: 0, + }) + } else { + None + } +} + +fn previous_visual( + cache: &mut WrapCache, + lines: &[String], + position: VisualRow, +) -> Option { + if position.continuation > 0 { + Some(VisualRow { + continuation: position.continuation - 1, + ..position + }) + } else if position.source > 0 { + let source = position.source - 1; + Some(VisualRow { + source, + continuation: cache.segments(source, &lines[source]).len() - 1, + }) + } else { + None + } +} + +fn visual_page( + cache: &mut WrapCache, + lines: &[String], + start: VisualRow, + height: usize, +) -> (VisualRow, usize) { + let mut end = start; + let mut count = usize::from(height > 0); + while count < height { + let Some(next) = next_visual(cache, lines, end) else { + break; + }; + end = next; + count += 1; + } + (end, count) +} + +fn clamp_visual_bottom( + cache: &mut WrapCache, + lines: &[String], + mut start: VisualRow, + height: usize, +) -> VisualRow { + let (_, count) = visual_page(cache, lines, start, height); + for _ in count..height { + let Some(previous) = previous_visual(cache, lines, start) else { + break; + }; + start = previous; + } + start +} + #[derive(Default)] struct BufferViewports { offsets: BTreeMap, @@ -161,6 +247,9 @@ impl BufferViewports { fn draw(&mut self, frame: &mut Frame, editor: &Editor) { self.offsets.retain(|id, _| editor.has_buffer(*id)); self.syntax.retain(|id, _| editor.has_buffer(*id)); + self.document + .continuations + .retain(|id, _| editor.has_buffer(*id)); let (top, left) = self.offsets.entry(editor.buffer_id()).or_default(); let syntax = self.syntax.entry(editor.buffer_id()).or_default(); draw( @@ -210,7 +299,36 @@ impl BufferViewports { return false; } let (top, left) = self.offsets.entry(buffer_id).or_default(); - if matches!( + if editor.word_wrap() { + if matches!( + kind, + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight + ) { + return true; + } + let continuation = self.document.continuations.entry(buffer_id).or_default(); + let mut position = VisualRow { + source: *top, + continuation: *continuation, + }; + for _ in 0..3 { + let next = if kind == MouseEventKind::ScrollDown { + next_visual(&mut self.document.wrap, editor.lines(), position) + } else { + previous_visual(&mut self.document.wrap, editor.lines(), position) + }; + let Some(next) = next else { break }; + position = next; + } + position = clamp_visual_bottom( + &mut self.document.wrap, + editor.lines(), + position, + usize::from(viewport.height), + ); + *top = position.source; + *continuation = position.continuation; + } else if matches!( kind, MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight ) { @@ -565,6 +683,17 @@ fn draw( let mut document_caret = None; let carets = editor.carets(); let selections = editor.selections(); + let wrapped = editor.word_wrap(); + let presentation = (editor.buffer_id(), wrapped, width, height); + if document.presentation.is_some_and(|previous| { + previous.0 != presentation.0 + || previous.1 != wrapped + || (wrapped && previous != presentation) + }) { + document.manual = None; + } + document.presentation = Some(presentation); + document.visible.clear(); if document.manual.as_ref().is_some_and(|manual| { manual.buffer_id != editor.buffer_id() || manual.revision != editor.text_revision() @@ -585,37 +714,116 @@ fn draw( viewport.height, ), )); - *top = (*top).min(editor.lines().len().saturating_sub(height)); - if document.manual.is_some() - && let Some((id, revision, columns)) = document.horizontal_extent - && id == editor.buffer_id() - && revision == editor.text_revision() - { - *left = (*left).min(columns.saturating_add(1).saturating_sub(width.max(1))); + if !wrapped { + *top = (*top).min(editor.lines().len().saturating_sub(height)); + if document.manual.is_some() + && let Some((id, revision, columns)) = document.horizontal_extent + && id == editor.buffer_id() + && revision == editor.text_revision() + { + *left = (*left).min(columns.saturating_add(1).saturating_sub(width.max(1))); + } } if height > 0 && width > 0 { - if document.manual.is_none() { - if cursor.row < *top { - *top = cursor.row; - } else if cursor.row >= top.saturating_add(height) { - *top = cursor.row + 1 - height; + if wrapped { + document + .wrap + .prepare((editor.buffer_id(), editor.text_revision()), width); + let continuation = document + .continuations + .entry(editor.buffer_id()) + .or_default(); + *top = (*top).min(editor.lines().len() - 1); + *continuation = + (*continuation).min(document.wrap.segments(*top, &editor.lines()[*top]).len() - 1); + let mut start = VisualRow { + source: *top, + continuation: *continuation, + }; + if document.manual.is_none() { + let column = display_column(&editor.lines()[cursor.row], cursor.column); + let caret = VisualRow { + source: cursor.row, + continuation: caret_segment( + document + .wrap + .segments(cursor.row, &editor.lines()[cursor.row]), + column, + ), + }; + if caret < start { + start = caret; + } else if caret > visual_page(&mut document.wrap, editor.lines(), start, height).0 { + start = caret; + for _ in 1..height { + let Some(previous) = + previous_visual(&mut document.wrap, editor.lines(), start) + else { + break; + }; + start = previous; + } + } } - let column = display_column(&editor.lines()[cursor.row], cursor.column); - if column < *left { - *left = column; - } else if column >= left.saturating_add(width) { - *left = column + 1 - width; + start = clamp_visual_bottom(&mut document.wrap, editor.lines(), start, height); + *top = start.source; + *continuation = start.continuation; + let mut position = start; + for _ in 0..height { + let segment = document + .wrap + .segments(position.source, &editor.lines()[position.source]) + [position.continuation]; + document + .visible + .push((position.source, position.continuation, segment)); + let Some(next) = next_visual(&mut document.wrap, editor.lines(), position) else { + break; + }; + position = next; } + } else { + if document.manual.is_none() { + if cursor.row < *top { + *top = cursor.row; + } else if cursor.row >= top.saturating_add(height) { + *top = cursor.row + 1 - height; + } + let column = display_column(&editor.lines()[cursor.row], cursor.column); + if column < *left { + *left = column; + } else if column >= left.saturating_add(width) { + *left = column + 1 - width; + } + } + document.visible.extend( + (*top..top.saturating_add(height).min(editor.lines().len())).map(|row| { + ( + row, + 0, + Segment { + start: *left, + end: left.saturating_add(width), + }, + ) + }), + ); } + let last = document.visible.last().map_or(*top, |(row, _, _)| *row); syntax.update_visible( editor.lines(), editor.path(), editor.text_revision(), - std::slice::from_ref(&(*top..top.saturating_add(height).min(editor.lines().len()))), + std::slice::from_ref(&(*top..last + 1)), ); if gutter.width > 0 { - let numbers: Vec = (*top..top.saturating_add(height).min(editor.lines().len())) - .map(|row| { + let numbers: Vec = document + .visible + .iter() + .map(|&(row, continuation, _)| { + if continuation > 0 { + return Line::default(); + } let style = if row == cursor.row { Style::default() .fg(Color::Cyan) @@ -642,24 +850,50 @@ fn draw( .collect(); frame.render_widget(Paragraph::new(numbers), gutter); } - let lines: Vec = editor - .lines() + let lines: Vec = document + .visible .iter() - .skip(*top) - .take(height) - .map(|line| Line::from(visible_line(line, *left, width))) + .map(|&(row, _, segment)| { + Line::from(visible_line( + &editor.lines()[row], + segment.start, + segment.end.saturating_sub(segment.start).min(width), + )) + }) .collect(); frame.render_widget(Paragraph::new(lines), viewport); - for (row, line) in editor.lines().iter().enumerate().skip(*top).take(height) { - let area = Rect::new( - viewport.x, - viewport.y + (row - *top) as u16, - viewport.width, - 1, + for (screen_row, &(row, continuation, segment)) in document.visible.iter().enumerate() { + let line = &editor.lines()[row]; + let cells: usize = if wrapped { + segment + .end + .saturating_sub(segment.start) + .saturating_add(usize::from( + continuation + 1 == document.wrap.segments(row, line).len(), + )) + .min(width) + } else { + width + }; + let area = Rect::new(viewport.x, viewport.y + screen_row as u16, cells as u16, 1); + paint_syntax_line(frame, line, syntax.line(row), area, segment.start); + paint_semantic_line( + frame, + line, + editor.semantic_tokens(), + row, + area, + segment.start, + ); + paint_editor_line( + frame, + editor, + row, + area, + segment.start, + &carets, + &selections, ); - paint_syntax_line(frame, line, syntax.line(row), area, *left); - paint_semantic_line(frame, line, editor.semantic_tokens(), row, area, *left); - paint_editor_line(frame, editor, row, area, *left, &carets, &selections); } } let path = editor @@ -764,14 +998,31 @@ fn draw( frame.render_widget(Paragraph::new(message), prompt_area); if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { let column = display_column(&editor.lines()[cursor.row], cursor.column); - if cursor.row >= *top - && cursor.row < top.saturating_add(height) - && column >= *left - && column < left.saturating_add(width) + let continuation = if wrapped { + caret_segment( + document + .wrap + .segments(cursor.row, &editor.lines()[cursor.row]), + column, + ) + } else { + 0 + }; + if let Some((screen_row, (_, _, segment))) = + document + .visible + .iter() + .enumerate() + .find(|(_, (row, part, segment))| { + *row == cursor.row + && *part == continuation + && column >= segment.start + && column < segment.start.saturating_add(width) + }) { let position = Position::new( - viewport.x + (column - *left) as u16, - viewport.y + (cursor.row - *top) as u16, + viewport.x + (column - segment.start) as u16, + viewport.y + screen_row as u16, ); frame.set_cursor_position(position); document_caret = Some(position); @@ -1031,19 +1282,16 @@ fn paint_diff_editor( area: Rect, focus: Focus, viewport: (usize, usize), + wrapped: Option<&[WrappedDiffRow]>, ) -> Option { let (top, left) = viewport; let carets = editor.carets(); let selections = editor.selections(); + let source_cursor = editor.cursor(); + let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column); let mut cursor = None; - for (display, row) in diff - .display_rows - .iter() - .enumerate() - .skip(top) - .take(usize::from(area.height)) - { - let DisplayRow::Line(aligned) = *row else { + for (screen, display, segments) in diff_screen_rows(diff, top, area.height, wrapped) { + let DisplayRow::Line(aligned) = diff.display_rows[display] else { continue; }; let Some(source) = (match focus { @@ -1053,11 +1301,23 @@ fn paint_diff_editor( }) else { continue; }; - let line_area = Rect::new(area.x, area.y + (display - top) as u16, area.width, 1); + let mut line_area = Rect::new(area.x, area.y + screen as u16, area.width, 1); + let segment = segments.and_then(|segments| segments[usize::from(focus == Focus::After)]); + if segments.is_some() && segment.is_none() { + continue; + } + let left = segment.map_or(left, |segment| segment.start); + if let Some(segment) = segment { + let final_segment = wrapped.is_some_and(|rows| { + rows[screen].final_segments[usize::from(focus == Focus::After)] + }); + line_area.width = + segment_paint_width(segment, final_segment, usize::from(area.width)) as u16; + } paint_editor_line(frame, editor, source, line_area, left, &carets, &selections); - if source == editor.cursor().row && editor.diff_interacting() { - let column = display_column(&editor.lines()[source], editor.cursor().column); - if column >= left && column < left.saturating_add(usize::from(area.width)) { + if source == source_cursor.row && editor.diff_interacting() { + let column = cursor_column; + if column >= left && column < left.saturating_add(usize::from(line_area.width)) { cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y)); } } @@ -1414,7 +1674,25 @@ fn draw_diff( let mut top = diff.top; let mut before_left = diff.left_before; let mut after_left = diff.left_after; - if let Some(editor) = editor.filter(|editor| editor.diff_interacting() && diff.follow_caret) + let wrapping = diff.word_wrap; + if wrapping && let Some(file) = selected.filter(|file| file.loaded) { + let caret = editor + .filter(|editor| editor.diff_interacting() && diff.follow_caret) + .and_then(|editor| { + editor + .diff_buffer_focus() + .map(|focus| (focus, editor.cursor())) + }); + let viewport = panels + .diff_wrap + .get_or_insert_with(DiffWrapViewport::default); + viewport.update(diff, file, [before_area, after_area], caret); + top = viewport.top.display; + } else { + panels.diff_wrap = None; + } + if let Some(editor) = + editor.filter(|editor| !wrapping && editor.diff_interacting() && diff.follow_caret) && let Some(focus) = editor.diff_buffer_focus() { let (pane, left) = match focus { @@ -1453,6 +1731,10 @@ fn draw_diff( if let Some(id) = diff.comparison_id && let Some(file) = selected.filter(|file| file.loaded) { + let wrapped = panels + .diff_wrap + .as_ref() + .map(|viewport| viewport.rows.as_slice()); panels .diff_syntax .get_or_insert_with(DiffSyntaxCache::default) @@ -1461,7 +1743,12 @@ fn draw_diff( diff.file, file, diff.live_revision, - Some((diff, top, [before_area, after_area])), + Some(DiffSyntaxViewport { + diff, + top, + areas: [before_area, after_area], + wrapped, + }), ); } if let Some(picker) = &diff.files @@ -1486,6 +1773,10 @@ fn draw_diff( }) }) .map(|geometry| geometry.text_widths); + let wrapped = panels + .diff_wrap + .as_ref() + .map(|viewport| viewport.rows.as_slice()); if let Some(file) = selected.filter(|file| file.loaded) && let Some(syntax) = &panels.diff_syntax { @@ -1506,6 +1797,7 @@ fn draw_diff( &mut before_left, previous_text_widths.map(|widths| widths[0]), ), + wrapped, ); text_widths[0] = text_area.width; if let Some(editor) = @@ -1518,6 +1810,7 @@ fn draw_diff( text_area, Focus::Before, (top, before_left), + wrapped, ) .map(|position| (text_area, position)); } @@ -1536,6 +1829,7 @@ fn draw_diff( &mut after_left, previous_text_widths.map(|widths| widths[1]), ), + wrapped, ); text_widths[1] = text_area.width; if let Some(editor) = @@ -1548,6 +1842,7 @@ fn draw_diff( text_area, Focus::After, (top, after_left), + wrapped, ) .map(|position| (text_area, position)); } @@ -1622,6 +1917,7 @@ fn draw_diff_pane( syntax: &SyntaxCache, semantic: &[SemanticToken], viewport: (usize, &mut usize, Option), + wrapped: Option<&[WrappedDiffRow]>, ) -> Rect { if area.width == 0 || area.height == 0 { return area; @@ -1662,23 +1958,13 @@ fn draw_diff_pane( 0 }; let width = usize::from(content.width).saturating_sub(gutter); - if previous_width.is_some_and(|previous| usize::from(previous) != width) { + if wrapped.is_none() && previous_width.is_some_and(|previous| usize::from(previous) != width) { *left = diff.bound_horizontal(focus, *left, width); } let left = *left; - for (display_row, row) in diff - .display_rows - .iter() - .enumerate() - .skip(top) - .take(usize::from(content.height)) - { - let row_area = Rect::new( - content.x, - content.y + (display_row - top) as u16, - content.width, - 1, - ); + for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) { + let row = &diff.display_rows[display_row]; + let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1); let aligned_row = match row { DisplayRow::Line(index) => *index, DisplayRow::Fold { start, end } => { @@ -1708,6 +1994,18 @@ fn draw_diff_pane( let Some(source_text) = source.get(index) else { continue; }; + let segment = segments.and_then(|segments| segments[usize::from(!before)]); + if segments.is_some() && segment.is_none() { + continue; + } + let left = segment.map_or(left, |segment| segment.start); + let text_width = segment.map_or(width, |segment| segment.end - segment.start); + let paint_width = segment.map_or(width, |segment| { + let final_segment = + wrapped.is_some_and(|rows| rows[screen].final_segments[usize::from(!before)]); + segment_paint_width(segment, final_segment, width) + }); + let text_area = Rect::new(content.x + gutter as u16, row_area.y, paint_width as u16, 1); let changed = matches!(row.kind, RowKind::Changed) || (before && row.kind == RowKind::Removed) || (!before && row.kind == RowKind::Added); @@ -1738,11 +2036,11 @@ fn draw_diff_pane( Cow::Borrowed(source_text.as_str()) }; frame.render_widget( - Paragraph::new(visible_line(&text, left, width)) + Paragraph::new(visible_line(&text, left, text_width.min(width))) .style(Style::default().fg(Color::Reset).bg(background)), Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), ); - if gutter > 0 { + if gutter > 0 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 0) { let marker = if changed { if before { '-' } else { '+' } } else { @@ -1759,21 +2057,8 @@ fn draw_diff_pane( Rect::new(content.x, row_area.y, gutter as u16, 1), ); } - paint_syntax_line( - frame, - source_text, - syntax.line(index), - Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), - left, - ); - paint_semantic_line( - frame, - source_text, - semantic, - index, - Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), - left, - ); + paint_syntax_line(frame, source_text, syntax.line(index), text_area, left); + paint_semantic_line(frame, source_text, semantic, index, text_area, left); } Rect::new( content.x + gutter as u16, @@ -1845,6 +2130,238 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar } } +fn segment_paint_width(segment: crate::wrap::Segment, final_segment: bool, width: usize) -> usize { + let cells = segment.end - segment.start + usize::from(final_segment); + width.min(cells.max(1)) +} + +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +struct DiffWrapPosition { + display: usize, + continuation: usize, +} + +#[derive(Clone, Copy)] +struct WrappedDiffRow { + position: DiffWrapPosition, + segments: [Option; 2], + final_segments: [bool; 2], +} + +#[derive(Default)] +struct DiffWrapViewport { + key: Option<(u64, usize, Option)>, + revision: u64, + before: crate::wrap::WrapCache, + after: crate::wrap::WrapCache, + widths: [usize; 2], + top: DiffWrapPosition, + rows: Vec, +} + +fn diff_inner(area: Rect) -> Rect { + if area.width >= 4 && area.height >= 4 { + Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2) + } else { + area + } +} + +fn diff_text_width(area: Rect, source_len: usize) -> usize { + let width = usize::from(diff_inner(area).width); + let digits = source_len.max(1).ilog10() as usize + 1; + width.saturating_sub(if width > digits + 2 { digits + 2 } else { 0 }) +} + +impl DiffWrapViewport { + fn row( + &mut self, + diff: &DiffState, + file: &ChangedFile, + position: DiffWrapPosition, + ) -> WrappedDiffRow { + let mut segments = [None; 2]; + let mut final_segments = [false; 2]; + if let Some(DisplayRow::Line(index)) = diff.display_rows.get(position.display) { + let aligned = &diff.rows[*index]; + for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() { + if let Some(source) = source.filter(|_| self.widths[side] > 0) { + let (cache, lines) = if side == 0 { + (&mut self.before, &file.before) + } else { + (&mut self.after, &file.after) + }; + let text = lines.get(source).map_or("", String::as_str); + let parts = cache.segments(source, text); + segments[side] = parts.get(position.continuation).copied(); + final_segments[side] = position.continuation + 1 == parts.len(); + } + } + } + WrappedDiffRow { + position, + segments, + final_segments, + } + } + + fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize { + let Some(DisplayRow::Line(index)) = diff.display_rows.get(display) else { + return 1; + }; + let aligned = &diff.rows[*index]; + let mut height = 1; + for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() { + if let Some(source) = source.filter(|_| self.widths[side] > 0) { + let (cache, lines) = if side == 0 { + (&mut self.before, &file.before) + } else { + (&mut self.after, &file.after) + }; + height = height.max( + cache + .segments(source, lines.get(source).map_or("", String::as_str)) + .len(), + ); + } + } + height + } + + fn step( + &mut self, + diff: &DiffState, + file: &ChangedFile, + mut position: DiffWrapPosition, + down: bool, + ) -> DiffWrapPosition { + if down { + if position.continuation + 1 < self.height(diff, file, position.display) { + position.continuation += 1; + } else if position.display + 1 < diff.display_rows.len() { + position.display += 1; + position.continuation = 0; + } + } else if position.continuation > 0 { + position.continuation -= 1; + } else if position.display > 0 { + position.display -= 1; + position.continuation = self.height(diff, file, position.display) - 1; + } + position + } + + fn fill(&mut self, diff: &DiffState, file: &ChangedFile, height: usize) { + self.rows.clear(); + if diff.display_rows.is_empty() { + return; + } + let mut position = self.top; + for _ in 0..height { + let row = self.row(diff, file, position); + self.rows.push(row); + let next = self.step(diff, file, position, true); + if next == position { + break; + } + position = next; + } + } + + fn update( + &mut self, + diff: &DiffState, + file: &ChangedFile, + areas: [Rect; 2], + caret: Option<(Focus, Cursor)>, + ) { + let Some(id) = diff.comparison_id else { return }; + let key = Some((id, diff.file, diff.live_revision)); + if self.key.map(|(id, file, _)| (id, file)) != Some((id, diff.file)) + || self.top.display != diff.top + { + self.top = DiffWrapPosition { + display: diff.top, + continuation: 0, + }; + } + if self.key != key { + self.key = key; + self.revision = self + .revision + .checked_add(1) + .expect("wrap revisions exhausted"); + } + self.widths = [ + diff_text_width(areas[0], file.before.len()), + diff_text_width(areas[1], file.after.len()), + ]; + self.before.prepare((diff.file, id), self.widths[0]); + self.after + .prepare((diff.file, self.revision), self.widths[1]); + self.top.display = self + .top + .display + .min(diff.display_rows.len().saturating_sub(1)); + self.top.continuation = self + .top + .continuation + .min(self.height(diff, file, self.top.display) - 1); + 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 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 { + self.top = position; + self.fill(diff, file, height); + } else if !self.rows.iter().any(|row| row.position == position) { + self.top = position; + for _ in 1..height { + self.top = self.step(diff, file, self.top, false); + } + self.fill(diff, file, height); + } + } + } +} + +fn diff_screen_rows<'a>( + diff: &'a DiffState, + top: usize, + height: u16, + wrapped: Option<&'a [WrappedDiffRow]>, +) -> impl Iterator; 2]>)> + 'a { + (0..usize::from(height)).filter_map(move |screen| { + if let Some(rows) = wrapped { + rows.get(screen) + .map(|row| (screen, row.position.display, Some(row.segments))) + } else { + (top + screen < diff.display_rows.len()).then_some((screen, top + screen, None)) + } + }) +} + +struct DiffSyntaxViewport<'a> { + diff: &'a DiffState, + top: usize, + areas: [Rect; 2], + wrapped: Option<&'a [WrappedDiffRow]>, +} + #[derive(Default)] struct DiffSyntaxCache { key: Option<(u64, usize, Option)>, @@ -1863,7 +2380,7 @@ impl DiffSyntaxCache { file_index: usize, file: &ChangedFile, live_revision: Option, - visible: Option<(&DiffState, usize, [Rect; 2])>, + visible: Option>, ) { let key = Some((comparison_id, file_index, live_revision)); if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) { @@ -1879,9 +2396,15 @@ impl DiffSyntaxCache { .checked_add(1) .expect("diff syntax revisions exhausted"); } - if let Some((diff, top, areas)) = visible { - diff_visible_rows(diff, top, areas[0], true, &mut self.before_rows); - diff_visible_rows(diff, top, areas[1], false, &mut self.after_rows); + if let Some(DiffSyntaxViewport { + diff, + top, + areas, + wrapped, + }) = visible + { + diff_visible_rows(diff, top, areas[0], true, &mut self.before_rows, wrapped); + diff_visible_rows(diff, top, areas[1], false, &mut self.after_rows, wrapped); self.before.update_visible( &file.before, Some(file.old_path.as_deref().unwrap_or(&file.path)), @@ -1912,6 +2435,7 @@ fn diff_visible_rows( pane: Rect, before: bool, ranges: &mut Vec>, + wrapped: Option<&[WrappedDiffRow]>, ) { ranges.clear(); let height = if pane.width >= 4 && pane.height >= 4 { @@ -1919,7 +2443,10 @@ fn diff_visible_rows( } else { pane.height }; - for row in diff.display_rows.iter().skip(top).take(usize::from(height)) { + let count = wrapped.map_or(usize::from(height), |rows| { + rows.last().map_or(0, |row| row.position.display - top + 1) + }); + for row in diff.display_rows.iter().skip(top).take(count) { let DisplayRow::Line(index) = *row else { continue; }; @@ -1950,6 +2477,7 @@ struct WheelPanels { diff: Option, diff_syntax: Option, previous_diff: Option, + diff_wrap: Option, } struct FileWheelOffset { @@ -2048,13 +2576,32 @@ impl WheelPanels { .find_map(|(area, focus)| area.contains(position).then_some(*focus)) }); if let Some(focus) = focus { - if horizontal { + if horizontal && !editor.word_wrap() { let width = self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)]; if width > 0 { session.scroll_diff_horizontal(focus, down, usize::from(width)); } - } else { + } else if !horizontal && focus != Focus::Files && editor.word_wrap() { + if let Some(wrap) = &mut self.diff_wrap + && let Some(diff) = editor.diff() + && let Some(file) = diff + .comparison + .as_ref() + .and_then(|comparison| comparison.files.get(diff.file)) + .filter(|file| file.loaded) + && let Some(id) = diff.comparison_id + { + for _ in 0..3 { + wrap.top = wrap.step(diff, file, wrap.top, down); + } + let top = wrap.top.display; + let (left_before, left_after, selected) = + (diff.left_before, diff.left_after, diff.file); + session.scroll_diff(focus, down); + session.set_diff_viewport(id, selected, top, left_before, left_after); + } + } else if !horizontal { for _ in 0..3 { session.scroll_diff(focus, down); } @@ -3875,6 +4422,67 @@ mod tests { terminal.get_cursor_position().unwrap() } + #[test] + fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() { + let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70)); + let after = format!("// {}AFTER_END\nsentinel", "new ".repeat(20)); + let mut editor = readonly_diff_editor(syntax_diff_file("history.rs", &before, &after)); + editor.execute("word_wrap.toggle"); + editor.handle(Input::Char('\t')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(140, 20)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let geometry = views.panels.diff.as_ref().unwrap(); + let before_area = diff_inner(geometry.regions[1].0); + let after_area = diff_inner(geometry.regions[2].0); + let buffer = terminal.backend().buffer(); + let find_sentinel = |area: Rect| { + (area.y..area.bottom()) + .find(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, *y)].symbol()) + .collect::() + .contains("sentinel") + }) + .unwrap() + }; + assert_eq!(find_sentinel(before_area), find_sentinel(after_area)); + editor.handle(Input::End); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(before_area.contains(caret)); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "D" + ); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + Color::DarkGray + ); + editor.handle(Input::Char('v')); + editor.handle(Input::Home); + render_native_diff(&mut terminal, &mut views, &mut editor); + let wrap = views.panels.diff_wrap.as_ref().unwrap(); + let continuation = wrap + .rows + .iter() + .position(|row| row.position.display == 0 && row.position.continuation == 1) + .unwrap(); + let x = before_area.x + + (before_area.width - views.panels.diff.as_ref().unwrap().text_widths[0]); + assert_eq!( + terminal.backend().buffer()[(x, before_area.y + continuation as u16)].bg, + Color::Blue + ); + let selection = editor.selections(); + let text = editor.lines().to_vec(); + editor.execute("word_wrap.toggle"); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(views.panels.diff_wrap.is_none()); + assert_eq!(editor.selections(), selection); + assert_eq!(editor.lines(), text); + assert_eq!(editor.mode(), Mode::Visual); + } + #[test] fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() { let mut editor = readonly_diff_editor(syntax_diff_file( @@ -7978,6 +8586,174 @@ end } } + #[test] + fn wrapped_document_renders_continuations_selection_and_insertion_caret() { + let directory = Directory::new(); + let source = "abcdefghijklmnop\ntail"; + let path = directory.file("wrapped.txt", source); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.execute("word_wrap.toggle"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let screen = screen_text(&terminal); + let rows: Vec<_> = screen.lines().collect(); + assert_eq!(rows[0], "1 abcdefgh"); + assert_eq!(rows[1], " ijklmnop"); + assert_eq!(rows[2], " "); + assert_eq!(rows[3], "1 tail "); + editor.handle(Input::Char('v')); + for _ in 0..10 { + editor.handle(Input::Right); + } + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(4, 1)); + assert_eq!(terminal.backend().buffer()[(2, 1)].bg, Color::Blue); + assert_eq!(terminal.backend().buffer()[(3, 1)].bg, Color::Blue); + editor.handle(Input::Escape); + editor.handle(Input::End); + editor.handle(Input::Char('a')); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(2, 2)); + assert_eq!(editor.lines().join("\n"), source); + assert!(!editor.dirty()); + } + + #[test] + fn wrapped_wheel_moves_visual_rows_and_resize_and_toggle_restore_following() { + let directory = Directory::new(); + let source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let path = directory.file("wrapped.txt", source); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let id = session.editor().buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..12 { + session.handle(Input::Right); + } + terminal + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + let original_left = viewports.offsets[&id].1; + assert!(original_left > 0); + session.execute("word_wrap.toggle"); + 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) + )); + assert_eq!(viewports.offsets[&id].1, original_left); + assert!(viewports.document.manual.is_none()); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + 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) + )); + 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 + .draw(|frame| viewports.draw(frame, session.editor())) + .unwrap(); + assert_eq!(viewports.offsets[&id].1, original_left); + assert_eq!(session.editor().lines().join("\n"), source); + } + + #[test] + fn wrapped_unicode_uses_original_tab_stops_and_queries_visible_source_rows() { + let directory = Directory::new(); + let source = format!("#a\t界e\u{301} z\n{}", vec!["# unseen"; 100].join("\n")); + let path = directory.file("wrapped.py", &source); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([semantic_token( + 0, + 3, + 5, + SemanticTokenKind::Property, + 0, + )])), + ) + .unwrap(); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![annotation( + (0, 2), + (0, 5), + DiagnosticSeverity::Warning, + "wrapped range", + )], + ) + .unwrap(); + editor.execute("word_wrap.toggle"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.document.text_width, 4); + assert_eq!( + viewports + .document + .visible + .iter() + .map(|&(row, part, _)| (row, part)) + .collect::>(), + [(0, 0), (0, 1), (0, 2), (1, 0)] + ); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(4, 0)].symbol(), "#"); + assert_eq!(buffer[(6, 0)].symbol(), " "); + assert_eq!(buffer[(4, 1)].symbol(), "界"); + assert_eq!(buffer[(6, 1)].symbol(), "e\u{301}"); + for x in [4, 6] { + assert_eq!(buffer[(x, 1)].fg, Color::Blue); + assert!(buffer[(x, 1)].modifier.contains(Modifier::UNDERLINED)); + assert_eq!(buffer[(x, 1)].underline_color, Color::Yellow); + } + assert_eq!(buffer[(4, 2)].fg, Color::DarkGray); + assert!(viewports.syntax[&editor.buffer_id()].line(2).is_empty()); + assert_eq!(buffer[(4, 2)].symbol(), "z"); + assert_eq!(editor.lines().join("\n"), source); + editor.handle(Input::Down); + assert_eq!(editor.cursor().row, 1); + } + #[test] fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() { let directory = Directory::new(); diff --git a/src/wrap.rs b/src/wrap.rs new file mode 100644 index 0000000..7201e26 --- /dev/null +++ b/src/wrap.rs @@ -0,0 +1,201 @@ +use std::collections::BTreeMap; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::tui::TAB_WIDTH; + +/// Terminal-cell coordinates in the original source line, including its tab stops. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Segment { + pub start: usize, + pub end: usize, +} + +#[derive(Default)] +pub(crate) struct WrapCache { + key: Option<(usize, u64)>, + width: usize, + rows: BTreeMap>, +} + +impl WrapCache { + pub(crate) fn prepare(&mut self, key: (usize, u64), width: usize) { + let width = width.max(1); + if self.key != Some(key) || self.width != width { + self.rows.clear(); + self.key = Some(key); + self.width = width; + } + } + + pub(crate) fn segments(&mut self, row: usize, text: &str) -> &[Segment] { + let width = self.width.max(1); + self.rows.entry(row).or_insert_with(|| { + let mut segments = Vec::new(); + let mut start = 0; + let mut column = 0; + let mut word_boundary = 0; + for grapheme in text.graphemes(true) { + let cells = if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + UnicodeWidthStr::width(grapheme) + }; + if column + cells - start > width && column > start { + let end = if word_boundary > start { + word_boundary + } else { + column + }; + segments.push(Segment { start, end }); + start = end; + if column + cells - start > width && column > start { + segments.push(Segment { start, end: column }); + start = column; + } + } + if grapheme == "\t" { + // Tabs are expanded cells, so even a one-column pane preserves them. + while column + cells - start > width { + let end = start + width; + segments.push(Segment { start, end }); + start = end; + } + } + column += cells; + if grapheme.chars().all(char::is_whitespace) { + word_boundary = column; + } + } + segments.push(Segment { start, end: column }); + // A full final segment has no spare cell for the insertion caret. + if column - start >= width { + segments.push(Segment { + start: column, + end: column, + }); + } + segments + }) + } +} + +pub(crate) fn caret_segment(segments: &[Segment], column: usize) -> usize { + segments + .partition_point(|segment| segment.start <= column) + .saturating_sub(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(text: &str, width: usize) -> Vec { + let mut cache = WrapCache::default(); + cache.prepare((1, 0), width); + cache.segments(0, text).to_vec() + } + + #[test] + fn words_and_long_words_preserve_every_source_cell() { + assert_eq!( + layout("one two three", 8), + vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },] + ); + let segments = layout("abcdefgh", 3); + assert_eq!( + segments, + vec![ + Segment { start: 0, end: 3 }, + Segment { start: 3, end: 6 }, + Segment { start: 6, end: 8 }, + ] + ); + assert_eq!(caret_segment(&segments, 3), 1); + assert_eq!(caret_segment(&segments, 8), 2); + } + + #[test] + fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() { + assert_eq!( + layout("ab\t界e\u{301} z", 4), + vec![ + Segment { start: 0, end: 4 }, + Segment { start: 4, end: 8 }, + Segment { start: 8, end: 9 }, + ] + ); + assert_eq!( + layout("界界界", 3), + vec![ + Segment { start: 0, end: 2 }, + Segment { start: 2, end: 4 }, + Segment { start: 4, end: 6 }, + ] + ); + assert_eq!( + layout(" \t ", 3), + vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },] + ); + } + + #[test] + fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() { + assert_eq!( + layout("\tX", 2), + [ + Segment { start: 0, end: 2 }, + Segment { start: 2, end: 4 }, + Segment { start: 4, end: 5 }, + ] + ); + assert_eq!( + layout("a\tX", 2), + [ + Segment { start: 0, end: 1 }, + Segment { start: 1, end: 3 }, + Segment { start: 3, end: 5 }, + Segment { start: 5, end: 5 }, + ] + ); + for source in ["\t", "a\t", "ab\tX", "\t\tX"] { + let segments = layout(source, 1); + assert!( + segments + .iter() + .all(|segment| segment.end - segment.start <= 1) + ); + assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start)); + let end = source.graphemes(true).fold(0, |column, grapheme| { + column + + if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + 1 + } + }); + assert_eq!(segments.last().unwrap(), &Segment { start: end, end }); + } + } + + #[test] + fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() { + let mut cache = WrapCache::default(); + cache.prepare((7, 2), 4); + assert_eq!( + cache.segments(90, "abcd"), + [Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },] + ); + assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1); + assert_eq!(cache.rows.len(), 1); + cache.prepare((7, 2), 4); + assert_eq!(cache.rows.len(), 1); + cache.prepare((7, 2), 8); + assert!(cache.rows.is_empty()); + cache.segments(90, "abcd"); + cache.prepare((7, 3), 8); + assert!(cache.rows.is_empty()); + assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]); + } +}