Improvements to interactive hard wrap behavior (#26953)

Release Notes:

- Fixed involuntary joining of lines when typing in the commit message
editor
- Fixed being unable to type whitespace after a comment character at the
start of a line in the commit message editor
This commit is contained in:
Cole Miller 2025-03-18 13:05:08 -04:00 committed by GitHub
parent 41a60ffecf
commit e7bba1c252
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 254 additions and 86 deletions

View file

@ -1101,6 +1101,12 @@ pub enum MultibufferSelectionMode {
All, All,
} }
#[derive(Clone, Copy, Debug, Default)]
pub struct RewrapOptions {
pub override_language_settings: bool,
pub preserve_existing_whitespace: bool,
}
impl Editor { impl Editor {
pub fn single_line(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn single_line(window: &mut Window, cx: &mut Context<Self>) -> Self {
let buffer = cx.new(|cx| Buffer::local("", cx)); let buffer = cx.new(|cx| Buffer::local("", cx));
@ -3246,7 +3252,13 @@ impl Editor {
.line_len(MultiBufferRow(latest.start.row)) .line_len(MultiBufferRow(latest.start.row))
== latest.start.column == latest.start.column
{ {
this.rewrap_impl(true, cx) this.rewrap_impl(
RewrapOptions {
override_language_settings: true,
preserve_existing_whitespace: true,
},
cx,
)
} }
} }
this.trigger_completion_on_input(&text, trigger_in_words, window, cx); this.trigger_completion_on_input(&text, trigger_in_words, window, cx);
@ -9172,10 +9184,10 @@ impl Editor {
} }
pub fn rewrap(&mut self, _: &Rewrap, _: &mut Window, cx: &mut Context<Self>) { pub fn rewrap(&mut self, _: &Rewrap, _: &mut Window, cx: &mut Context<Self>) {
self.rewrap_impl(false, cx) self.rewrap_impl(RewrapOptions::default(), cx)
} }
pub fn rewrap_impl(&mut self, override_language_settings: bool, cx: &mut Context<Self>) { pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context<Self>) {
let buffer = self.buffer.read(cx).snapshot(cx); let buffer = self.buffer.read(cx).snapshot(cx);
let selections = self.selections.all::<Point>(cx); let selections = self.selections.all::<Point>(cx);
let mut selections = selections.iter().peekable(); let mut selections = selections.iter().peekable();
@ -9249,7 +9261,7 @@ impl Editor {
RewrapBehavior::Anywhere => true, RewrapBehavior::Anywhere => true,
}; };
let should_rewrap = override_language_settings let should_rewrap = options.override_language_settings
|| allow_rewrap_based_on_language || allow_rewrap_based_on_language
|| self.hard_wrap.is_some(); || self.hard_wrap.is_some();
if !should_rewrap { if !should_rewrap {
@ -9306,15 +9318,16 @@ impl Editor {
}); });
let wrapped_text = wrap_with_prefix( let wrapped_text = wrap_with_prefix(
line_prefix, line_prefix,
lines_without_prefixes.join(" "), lines_without_prefixes.join("\n"),
wrap_column, wrap_column,
tab_size, tab_size,
options.preserve_existing_whitespace,
); );
// TODO: should always use char-based diff while still supporting cursor behavior that // TODO: should always use char-based diff while still supporting cursor behavior that
// matches vim. // matches vim.
let mut diff_options = DiffOptions::default(); let mut diff_options = DiffOptions::default();
if override_language_settings { if options.override_language_settings {
diff_options.max_word_diff_len = 0; diff_options.max_word_diff_len = 0;
diff_options.max_word_diff_line_count = 0; diff_options.max_word_diff_line_count = 0;
} else { } else {
@ -17280,10 +17293,10 @@ fn should_stay_with_preceding_ideograph(text: &str) -> bool {
} }
#[derive(PartialEq, Eq, Debug, Clone, Copy)] #[derive(PartialEq, Eq, Debug, Clone, Copy)]
struct WordBreakToken<'a> { enum WordBreakToken<'a> {
token: &'a str, Word { token: &'a str, grapheme_len: usize },
grapheme_len: usize, InlineWhitespace { token: &'a str, grapheme_len: usize },
is_whitespace: bool, Newline,
} }
impl<'a> Iterator for WordBreakingTokenizer<'a> { impl<'a> Iterator for WordBreakingTokenizer<'a> {
@ -17299,16 +17312,17 @@ impl<'a> Iterator for WordBreakingTokenizer<'a> {
let mut iter = self.input.graphemes(true).peekable(); let mut iter = self.input.graphemes(true).peekable();
let mut offset = 0; let mut offset = 0;
let mut graphemes = 0; let mut grapheme_len = 0;
if let Some(first_grapheme) = iter.next() { if let Some(first_grapheme) = iter.next() {
let is_newline = first_grapheme == "\n";
let is_whitespace = is_grapheme_whitespace(first_grapheme); let is_whitespace = is_grapheme_whitespace(first_grapheme);
offset += first_grapheme.len(); offset += first_grapheme.len();
graphemes += 1; grapheme_len += 1;
if is_grapheme_ideographic(first_grapheme) && !is_whitespace { if is_grapheme_ideographic(first_grapheme) && !is_whitespace {
if let Some(grapheme) = iter.peek().copied() { if let Some(grapheme) = iter.peek().copied() {
if should_stay_with_preceding_ideograph(grapheme) { if should_stay_with_preceding_ideograph(grapheme) {
offset += grapheme.len(); offset += grapheme.len();
graphemes += 1; grapheme_len += 1;
} }
} }
} else { } else {
@ -17321,27 +17335,29 @@ impl<'a> Iterator for WordBreakingTokenizer<'a> {
if next_word_bound.map_or(false, |(i, _)| i == offset) { if next_word_bound.map_or(false, |(i, _)| i == offset) {
break; break;
}; };
if is_grapheme_whitespace(grapheme) != is_whitespace { if is_grapheme_whitespace(grapheme) != is_whitespace
|| (grapheme == "\n") != is_newline
{
break; break;
}; };
offset += grapheme.len(); offset += grapheme.len();
graphemes += 1; grapheme_len += 1;
iter.next(); iter.next();
} }
} }
let token = &self.input[..offset]; let token = &self.input[..offset];
self.input = &self.input[offset..]; self.input = &self.input[offset..];
if is_whitespace { if token == "\n" {
Some(WordBreakToken { Some(WordBreakToken::Newline)
token: " ", } else if is_whitespace {
grapheme_len: 1, Some(WordBreakToken::InlineWhitespace {
is_whitespace: true, token,
grapheme_len,
}) })
} else { } else {
Some(WordBreakToken { Some(WordBreakToken::Word {
token, token,
grapheme_len: graphemes, grapheme_len,
is_whitespace: false,
}) })
} }
} else { } else {
@ -17352,66 +17368,75 @@ impl<'a> Iterator for WordBreakingTokenizer<'a> {
#[test] #[test]
fn test_word_breaking_tokenizer() { fn test_word_breaking_tokenizer() {
let tests: &[(&str, &[(&str, usize, bool)])] = &[ let tests: &[(&str, &[WordBreakToken<'static>])] = &[
("", &[]), ("", &[]),
(" ", &[(" ", 1, true)]), (" ", &[whitespace(" ", 2)]),
("Ʒ", &[("Ʒ", 1, false)]), ("Ʒ", &[word("Ʒ", 1)]),
("Ǽ", &[("Ǽ", 1, false)]), ("Ǽ", &[word("Ǽ", 1)]),
("", &[("", 1, false)]), ("", &[word("", 1)]),
("⋑⋑", &[("⋑⋑", 2, false)]), ("⋑⋑", &[word("⋑⋑", 2)]),
( (
"原理,进而", "原理,进而",
&[ &[word("", 1), word("理,", 2), word("", 1), word("", 1)],
("", 1, false),
("理,", 2, false),
("", 1, false),
("", 1, false),
],
), ),
( (
"hello world", "hello world",
&[("hello", 5, false), (" ", 1, true), ("world", 5, false)], &[word("hello", 5), whitespace(" ", 1), word("world", 5)],
), ),
( (
"hello, world", "hello, world",
&[("hello,", 6, false), (" ", 1, true), ("world", 5, false)], &[word("hello,", 6), whitespace(" ", 1), word("world", 5)],
), ),
( (
" hello world", " hello world",
&[ &[
(" ", 1, true), whitespace(" ", 2),
("hello", 5, false), word("hello", 5),
(" ", 1, true), whitespace(" ", 1),
("world", 5, false), word("world", 5),
], ],
), ),
( (
"这是什么 \n 钢笔", "这是什么 \n 钢笔",
&[ &[
("", 1, false), word("", 1),
("", 1, false), word("", 1),
("", 1, false), word("", 1),
("", 1, false), word("", 1),
(" ", 1, true), whitespace(" ", 1),
("", 1, false), newline(),
("", 1, false), whitespace(" ", 1),
word("", 1),
word("", 1),
], ],
), ),
("mutton", &[(" ", 1, true), ("mutton", 6, false)]), ("mutton", &[whitespace("", 1), word("mutton", 6)]),
]; ];
fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> {
WordBreakToken::Word {
token,
grapheme_len,
}
}
fn whitespace(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> {
WordBreakToken::InlineWhitespace {
token,
grapheme_len,
}
}
fn newline() -> WordBreakToken<'static> {
WordBreakToken::Newline
}
for (input, result) in tests { for (input, result) in tests {
assert_eq!( assert_eq!(
WordBreakingTokenizer::new(input).collect::<Vec<_>>(), WordBreakingTokenizer::new(input)
result
.iter()
.copied()
.map(|(token, grapheme_len, is_whitespace)| WordBreakToken {
token,
grapheme_len,
is_whitespace,
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
.as_slice(),
*result,
); );
} }
} }
@ -17421,6 +17446,7 @@ fn wrap_with_prefix(
unwrapped_text: String, unwrapped_text: String,
wrap_column: usize, wrap_column: usize,
tab_size: NonZeroU32, tab_size: NonZeroU32,
preserve_existing_whitespace: bool,
) -> String { ) -> String {
let line_prefix_len = char_len_with_expanded_tabs(0, &line_prefix, tab_size); let line_prefix_len = char_len_with_expanded_tabs(0, &line_prefix, tab_size);
let mut wrapped_text = String::new(); let mut wrapped_text = String::new();
@ -17428,27 +17454,68 @@ fn wrap_with_prefix(
let tokenizer = WordBreakingTokenizer::new(&unwrapped_text); let tokenizer = WordBreakingTokenizer::new(&unwrapped_text);
let mut current_line_len = line_prefix_len; let mut current_line_len = line_prefix_len;
for WordBreakToken { let mut in_whitespace = false;
token, for token in tokenizer {
grapheme_len, let have_preceding_whitespace = in_whitespace;
is_whitespace, match token {
} in tokenizer WordBreakToken::Word {
{ token,
if current_line_len + grapheme_len > wrap_column && current_line_len != line_prefix_len { grapheme_len,
wrapped_text.push_str(current_line.trim_end()); } => {
wrapped_text.push('\n'); in_whitespace = false;
current_line.truncate(line_prefix.len()); if current_line_len + grapheme_len > wrap_column
current_line_len = line_prefix_len; && current_line_len != line_prefix_len
if !is_whitespace { {
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
current_line.truncate(line_prefix.len());
current_line_len = line_prefix_len;
}
current_line.push_str(token); current_line.push_str(token);
current_line_len += grapheme_len; current_line_len += grapheme_len;
} }
} else if !is_whitespace { WordBreakToken::InlineWhitespace {
current_line.push_str(token); mut token,
current_line_len += grapheme_len; mut grapheme_len,
} else if current_line_len != line_prefix_len { } => {
current_line.push(' '); in_whitespace = true;
current_line_len += 1; if have_preceding_whitespace && !preserve_existing_whitespace {
continue;
}
if !preserve_existing_whitespace {
token = " ";
grapheme_len = 1;
}
if current_line_len + grapheme_len > wrap_column {
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
current_line.truncate(line_prefix.len());
current_line_len = line_prefix_len;
} else if current_line_len != line_prefix_len || preserve_existing_whitespace {
current_line.push_str(token);
current_line_len += grapheme_len;
}
}
WordBreakToken::Newline => {
in_whitespace = true;
if preserve_existing_whitespace {
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
current_line.truncate(line_prefix.len());
current_line_len = line_prefix_len;
} else if have_preceding_whitespace {
continue;
} else if current_line_len + 1 > wrap_column && current_line_len != line_prefix_len
{
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
current_line.truncate(line_prefix.len());
current_line_len = line_prefix_len;
} else if current_line_len != line_prefix_len {
current_line.push(' ');
current_line_len += 1;
}
}
} }
} }
@ -17465,7 +17532,8 @@ fn test_wrap_with_prefix() {
"# ".to_string(), "# ".to_string(),
"abcdefg".to_string(), "abcdefg".to_string(),
4, 4,
NonZeroU32::new(4).unwrap() NonZeroU32::new(4).unwrap(),
false,
), ),
"# abcdefg" "# abcdefg"
); );
@ -17474,7 +17542,8 @@ fn test_wrap_with_prefix() {
"".to_string(), "".to_string(),
"\thello world".to_string(), "\thello world".to_string(),
8, 8,
NonZeroU32::new(4).unwrap() NonZeroU32::new(4).unwrap(),
false,
), ),
"hello\nworld" "hello\nworld"
); );
@ -17483,7 +17552,8 @@ fn test_wrap_with_prefix() {
"// ".to_string(), "// ".to_string(),
"xx \nyy zz aa bb cc".to_string(), "xx \nyy zz aa bb cc".to_string(),
12, 12,
NonZeroU32::new(4).unwrap() NonZeroU32::new(4).unwrap(),
false,
), ),
"// xx yy zz\n// aa bb cc" "// xx yy zz\n// aa bb cc"
); );
@ -17492,7 +17562,8 @@ fn test_wrap_with_prefix() {
String::new(), String::new(),
"这是什么 \n 钢笔".to_string(), "这是什么 \n 钢笔".to_string(),
3, 3,
NonZeroU32::new(4).unwrap() NonZeroU32::new(4).unwrap(),
false,
), ),
"这是什\n么 钢\n" "这是什\n么 钢\n"
); );

View file

@ -2,8 +2,10 @@ use super::*;
use crate::{ use crate::{
scroll::scroll_amount::ScrollAmount, scroll::scroll_amount::ScrollAmount,
test::{ test::{
assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext, assert_text_with_selections, build_editor,
editor_test_context::EditorTestContext, select_ranges, editor_lsp_test_context::{git_commit_lang, EditorLspTestContext},
editor_test_context::EditorTestContext,
select_ranges,
}, },
JoinLines, JoinLines,
}; };
@ -4746,6 +4748,7 @@ async fn test_hard_wrap(cx: &mut TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await; let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(git_commit_lang()), cx));
cx.update_editor(|editor, _, cx| { cx.update_editor(|editor, _, cx| {
editor.set_hard_wrap(Some(14), cx); editor.set_hard_wrap(Some(14), cx);
}); });
@ -4764,6 +4767,69 @@ async fn test_hard_wrap(cx: &mut TestAppContext) {
fourˇ fourˇ
" "
)); ));
cx.update_editor(|editor, window, cx| {
editor.newline(&Default::default(), window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc!(
"
one two three
four
ˇ
"
));
cx.simulate_input("five");
cx.run_until_parked();
cx.assert_editor_state(indoc!(
"
one two three
four
fiveˇ
"
));
cx.update_editor(|editor, window, cx| {
editor.newline(&Default::default(), window, cx);
});
cx.run_until_parked();
cx.simulate_input("# ");
cx.run_until_parked();
cx.assert_editor_state(indoc!(
"
one two three
four
five
# ˇ
"
));
cx.update_editor(|editor, window, cx| {
editor.newline(&Default::default(), window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc!(
"
one two three
four
five
#\x20
#ˇ
"
));
cx.simulate_input(" 6");
cx.run_until_parked();
cx.assert_editor_state(indoc!(
"
one two three
four
five
#
# 6ˇ
"
));
} }
#[gpui::test] #[gpui::test]

View file

@ -79,6 +79,19 @@ pub(crate) fn rust_lang() -> Arc<Language> {
.expect("Could not parse queries"); .expect("Could not parse queries");
Arc::new(language) Arc::new(language)
} }
#[cfg(test)]
pub(crate) fn git_commit_lang() -> Arc<Language> {
Arc::new(Language::new(
LanguageConfig {
name: "Git Commit".into(),
line_comments: vec!["#".into()],
..Default::default()
},
None,
))
}
impl EditorLspTestContext { impl EditorLspTestContext {
pub async fn new( pub async fn new(
language: Language, language: Language,

View file

@ -1,6 +1,6 @@
use crate::{motion::Motion, object::Object, state::Mode, Vim}; use crate::{motion::Motion, object::Object, state::Mode, Vim};
use collections::HashMap; use collections::HashMap;
use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Bias, Editor}; use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Bias, Editor, RewrapOptions};
use gpui::{actions, Context, Window}; use gpui::{actions, Context, Window};
use language::SelectionGoal; use language::SelectionGoal;
@ -14,7 +14,13 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
vim.update_editor(window, cx, |vim, editor, window, cx| { vim.update_editor(window, cx, |vim, editor, window, cx| {
editor.transact(window, cx, |editor, window, cx| { editor.transact(window, cx, |editor, window, cx| {
let mut positions = vim.save_selection_starts(editor, cx); let mut positions = vim.save_selection_starts(editor, cx);
editor.rewrap_impl(true, cx); editor.rewrap_impl(
RewrapOptions {
override_language_settings: true,
..Default::default()
},
cx,
);
editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.move_with(|map, selection| { s.move_with(|map, selection| {
if let Some(anchor) = positions.remove(&selection.id) { if let Some(anchor) = positions.remove(&selection.id) {
@ -52,7 +58,13 @@ impl Vim {
motion.expand_selection(map, selection, times, false, &text_layout_details); motion.expand_selection(map, selection, times, false, &text_layout_details);
}); });
}); });
editor.rewrap_impl(true, cx); editor.rewrap_impl(
RewrapOptions {
override_language_settings: true,
..Default::default()
},
cx,
);
editor.change_selections(None, window, cx, |s| { editor.change_selections(None, window, cx, |s| {
s.move_with(|map, selection| { s.move_with(|map, selection| {
let anchor = selection_starts.remove(&selection.id).unwrap(); let anchor = selection_starts.remove(&selection.id).unwrap();
@ -83,7 +95,13 @@ impl Vim {
object.expand_selection(map, selection, around); object.expand_selection(map, selection, around);
}); });
}); });
editor.rewrap_impl(true, cx); editor.rewrap_impl(
RewrapOptions {
override_language_settings: true,
..Default::default()
},
cx,
);
editor.change_selections(None, window, cx, |s| { editor.change_selections(None, window, cx, |s| {
s.move_with(|map, selection| { s.move_with(|map, selection| {
let anchor = original_positions.remove(&selection.id).unwrap(); let anchor = original_positions.remove(&selection.id).unwrap();