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
+2
View File
@@ -182,6 +182,7 @@ enum RequestKind {
pub struct DiffState {
pub catalog: Option<Catalog>,
pub phase: Phase,
pub word_wrap: bool,
pub before: Option<Revision>,
pub after: Option<Revision>,
pub category: usize,
@@ -222,6 +223,7 @@ impl DiffState {
Self {
catalog: None,
phase: Phase::Before,
word_wrap: false,
before: None,
after: None,
category: 0,
+105
View File
@@ -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<Target>,
primary: usize,
mode: Mode,
word_wrap: bool,
linewise: bool,
search_query: String,
search_match: Option<SearchMatch>,
@@ -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<Invocation> {
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();
+1
View File
@@ -12,3 +12,4 @@ pub mod session;
mod snippet;
pub mod syntax;
pub mod tui;
mod wrap;
+8
View File
@@ -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());
+870 -94
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 }]);
}
}