feat: add toggleable buffer word wrap

This commit is contained in:
2026-10-11 15:55:52 +02:00
parent 2e05725a38
commit 1f625a69c4
9 changed files with 1199 additions and 97 deletions
+1 -1
View File
@@ -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] 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). - [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. - [ ] 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: 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 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.
+1
View File
@@ -83,6 +83,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
| Normal | `follow_link` | | Normal | `follow_link` |
| Normal | `diff` | | Normal | `diff` |
| Normal, Insert, Visual, Command, Search | `diff.close` | | Normal, Insert, Visual, Command, Search | `diff.close` |
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
| Normal | `jump_back`, `jump_forward` | | Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` | | Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
+10 -2
View File
@@ -160,12 +160,18 @@ Paste is backend input: it is ignored in Normal, Command, Search, and Visual mod
## Mouse wheel scrolling ## 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. 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 ## 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. 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). 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. 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. 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.
+2
View File
@@ -182,6 +182,7 @@ enum RequestKind {
pub struct DiffState { pub struct DiffState {
pub catalog: Option<Catalog>, pub catalog: Option<Catalog>,
pub phase: Phase, pub phase: Phase,
pub word_wrap: bool,
pub before: Option<Revision>, pub before: Option<Revision>,
pub after: Option<Revision>, pub after: Option<Revision>,
pub category: usize, pub category: usize,
@@ -222,6 +223,7 @@ impl DiffState {
Self { Self {
catalog: None, catalog: None,
phase: Phase::Before, phase: Phase::Before,
word_wrap: false,
before: None, before: None,
after: None, after: None,
category: 0, category: 0,
+105
View File
@@ -574,6 +574,18 @@ core_actions![
), ),
("core.save", Action::Save, &[Mode::Normal], true), ("core.save", Action::Save, &[Mode::Normal], true),
("core.diff", Action::Diff, &[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", "core.diff.close",
Action::DiffClose, Action::DiffClose,
@@ -1023,6 +1035,7 @@ struct Buffer {
targets: Vec<Target>, targets: Vec<Target>,
primary: usize, primary: usize,
mode: Mode, mode: Mode,
word_wrap: bool,
linewise: bool, linewise: bool,
search_query: String, search_query: String,
search_match: Option<SearchMatch>, search_match: Option<SearchMatch>,
@@ -1068,6 +1081,7 @@ impl Buffer {
targets: vec![Target::caret(Cursor::default())], targets: vec![Target::caret(Cursor::default())],
primary: 0, primary: 0,
mode: Mode::Normal, mode: Mode::Normal,
word_wrap: false,
linewise: false, linewise: false,
search_query: String::new(), search_query: String::new(),
search_match: None, search_match: None,
@@ -1363,6 +1377,13 @@ impl Editor {
self.diff.as_ref() 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) { pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
self.diff_context_options = (context_lines, expand_lines); self.diff_context_options = (context_lines, expand_lines);
if let Some(diff) = &mut self.diff { if let Some(diff) = &mut self.diff {
@@ -1427,6 +1448,7 @@ impl Editor {
} }
self.outstanding_diff_request = None; self.outstanding_diff_request = None;
let mut diff = DiffState::new(); 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); diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
self.diff = Some(diff); self.diff = Some(diff);
} }
@@ -1868,6 +1890,7 @@ impl Editor {
| Action::FilePicker | Action::FilePicker
| Action::FileExplorer | Action::FileExplorer
| Action::DiffClose | Action::DiffClose
| Action::WordWrapToggle
| Action::Quit | Action::Quit
| Action::ForceQuit | Action::ForceQuit
| Action::Plugins | Action::Plugins
@@ -3956,6 +3979,7 @@ impl Editor {
| Action::BufferForceDelete | Action::BufferForceDelete
| Action::Diff | Action::Diff
| Action::DiffClose | Action::DiffClose
| Action::WordWrapToggle
| Action::LspDefinition | Action::LspDefinition
| Action::LspHover | Action::LspHover
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
@@ -4535,6 +4559,28 @@ impl Editor {
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> { fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
self.finish_diff_prompt(); 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 entry.action == Action::DiffClose {
if args.is_empty() { if args.is_empty() {
self.release_diff_buffers(); self.release_diff_buffers();
@@ -7989,6 +8035,65 @@ mod tests {
assert_eq!(editor.lines(), ["beta"]); 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] #[test]
fn diff_readonly_panes_dispatch_prompts_palette_and_picker_remaps_without_edits() { fn diff_readonly_panes_dispatch_prompts_palette_and_picker_remaps_without_edits() {
let directory = Directory::new(); let directory = Directory::new();
+1
View File
@@ -12,3 +12,4 @@ pub mod session;
mod snippet; mod snippet;
pub mod syntax; pub mod syntax;
pub mod tui; pub mod tui;
mod wrap;
+8
View File
@@ -937,12 +937,20 @@ end
config.bind "Z", config.core.buffer_force_delete config.bind "Z", config.core.buffer_force_delete
config.bind "R", config.core.diff config.bind "R", config.core.diff
config.bind "T", config.core.diff.close 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? && 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.frozen? && config.core.diff.name.frozen? &&
config.core.diff.close.name.frozen? config.core.diff.close.name.frozen?
end"#, end"#,
); );
let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap(); 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::Char('R'));
review.handle(Input::Escape); review.handle(Input::Escape);
assert!(review.editor().diff().is_some()); assert!(review.editor().diff().is_some());
+852 -76
View File
File diff suppressed because it is too large Load Diff
+201
View File
@@ -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<usize, Vec<Segment>>,
}
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<Segment> {
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 }]);
}
}