feat: add toggleable buffer word wrap
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -12,3 +12,4 @@ pub mod session;
|
||||
mod snippet;
|
||||
pub mod syntax;
|
||||
pub mod tui;
|
||||
mod wrap;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+201
@@ -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 }]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user