From 12d96fb03d5ceb041fc3bab17125ef9d912ec6a6 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 18:49:44 +0900 Subject: [PATCH] fix: preserve short paste line endings --- crates/tui/src/input.rs | 173 +++++++++++++--------- tests/fixtures/composer-paste-policy.json | 64 ++++++++ 2 files changed, 171 insertions(+), 66 deletions(-) diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index f15d3673..afede024 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -322,8 +322,7 @@ impl InputBuffer { pub fn insert_paste(&mut self, content: String) { let measurement = measure_paste(&content); if measurement.presentation() == PastePresentation::Text { - let normalized = content.replace("\r\n", "\n").replace('\r', "\n"); - self.insert_str(&normalized); + self.insert_str(&content); return; } @@ -483,80 +482,78 @@ impl InputBuffer { self.cursor = 0; } - pub fn move_home(&mut self) { - while self.cursor > 0 { - if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) { - break; - } - self.cursor -= 1; + fn logical_line_ranges(&self) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut start = 0; + let mut index = 0; + while index < self.atoms.len() { + let break_len = match self.atoms[index] { + Atom::Char('\r') => { + if matches!(self.atoms.get(index + 1), Some(Atom::Char('\n'))) { + 2 + } else { + 1 + } + } + Atom::Char('\n') => 1, + _ => { + index += 1; + continue; + } + }; + ranges.push((start, index)); + index += break_len; + start = index; } + ranges.push((start, self.atoms.len())); + ranges + } + + fn logical_line_and_col(&self) -> (Vec<(usize, usize)>, usize, usize) { + let ranges = self.logical_line_ranges(); + for (line, &(start, end)) in ranges.iter().enumerate() { + if self.cursor <= end { + return (ranges, line, self.cursor.saturating_sub(start)); + } + if let Some(&(next_start, _)) = ranges.get(line + 1) + && self.cursor < next_start + { + return (ranges, line + 1, 0); + } + } + let line = ranges.len().saturating_sub(1); + let col = self.cursor.saturating_sub(ranges[line].0); + (ranges, line, col) + } + + pub fn move_home(&mut self) { + let (ranges, line, _) = self.logical_line_and_col(); + self.cursor = ranges[line].0; } pub fn move_end(&mut self) { - while self.cursor < self.atoms.len() { - if matches!(self.atoms[self.cursor], Atom::Char('\n')) { - break; - } - self.cursor += 1; - } + let (ranges, line, _) = self.logical_line_and_col(); + self.cursor = ranges[line].1; } /// Move one logical line up, preserving column (atom count from /// current line start). No-op if already on the first line. pub fn move_up(&mut self) { - let (line_start, col) = self.line_start_and_col(); - if line_start == 0 { + let (ranges, line, col) = self.logical_line_and_col(); + if line == 0 { return; } - // `atoms[line_start - 1]` is the '\n' that opens the current - // line; find the previous line's start. - let prev_end = line_start - 1; - let mut prev_start = 0; - for i in (0..prev_end).rev() { - if matches!(self.atoms[i], Atom::Char('\n')) { - prev_start = i + 1; - break; - } - } - let prev_len = prev_end - prev_start; - self.cursor = prev_start + col.min(prev_len); + let (start, end) = ranges[line - 1]; + self.cursor = start + col.min(end - start); } /// Move one logical line down, preserving column. pub fn move_down(&mut self) { - let (line_start, col) = self.line_start_and_col(); - // End of current line. - let mut cur_end = self.atoms.len(); - for i in line_start..self.atoms.len() { - if matches!(self.atoms[i], Atom::Char('\n')) { - cur_end = i; - break; - } - } - if cur_end == self.atoms.len() { - return; // no next line - } - let next_start = cur_end + 1; - let mut next_end = self.atoms.len(); - for i in next_start..self.atoms.len() { - if matches!(self.atoms[i], Atom::Char('\n')) { - next_end = i; - break; - } - } - let next_len = next_end - next_start; - self.cursor = next_start + col.min(next_len); - } - - fn line_start_and_col(&self) -> (usize, usize) { - let mut start = 0; - for i in (0..self.cursor).rev() { - if matches!(self.atoms[i], Atom::Char('\n')) { - start = i + 1; - break; - } - } - (start, self.cursor - start) + let (ranges, line, col) = self.logical_line_and_col(); + let Some(&(start, end)) = ranges.get(line + 1) else { + return; + }; + self.cursor = start + col.min(end - start); } /// Build the typed `Vec` sent over the protocol. Adjacent @@ -629,6 +626,7 @@ impl InputBuffer { let mut cursor_row: u16 = 0; let mut cursor_col: u16 = 0; let mut cursor_set = false; + let mut previous_was_cr = false; // Record cursor once, at the point right before `atom` would be // placed — accounting for a wrap that the atom itself will cause. @@ -652,7 +650,7 @@ impl InputBuffer { for (i, atom) in self.atoms.iter().enumerate() { if !cursor_set && i == self.cursor { let leading = match atom { - Atom::Char('\n') => 0, + Atom::Char('\n' | '\r') => 0, Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0), other => other .chip() @@ -667,6 +665,21 @@ impl InputBuffer { } match atom { + Atom::Char('\r') => { + flush_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut rows, + &mut row_width, + ); + rows.push(Vec::new()); + row_width = 0; + previous_was_cr = true; + } + Atom::Char('\n') if previous_was_cr => { + previous_was_cr = false; + } Atom::Char('\n') => { flush_pending( &mut pending, @@ -677,8 +690,10 @@ impl InputBuffer { ); rows.push(Vec::new()); row_width = 0; + previous_was_cr = false; } Atom::Char(c) => { + previous_was_cr = false; let cw = UnicodeWidthChar::width(*c).unwrap_or(0); if pending_style != text_style && !pending.is_empty() { flush_pending( @@ -702,6 +717,7 @@ impl InputBuffer { ); } other => { + previous_was_cr = false; let (chip_style, label) = other.chip().expect("non-char atom has a chip"); if pending_style != chip_style && !pending.is_empty() { flush_pending( @@ -1043,11 +1059,12 @@ mod paste_policy_tests { } #[test] - fn short_multiline_paste_normalizes_line_endings_as_text() { + fn short_multiline_paste_preserves_original_line_endings_as_text() { + let content = "ab\r\ncd\ref"; let mut buffer = InputBuffer::new(); - buffer.insert_paste("a\r\nb\rc".to_owned()); + buffer.insert_paste(content.to_owned()); - assert_eq!(buffer.plain_text(), "a\nb\nc"); + assert_eq!(buffer.plain_text(), content); assert!( buffer .atoms @@ -1056,8 +1073,32 @@ mod paste_policy_tests { ); assert_eq!( buffer.submit_segments(), - vec![Segment::text("a\nb\nc".to_owned())] + vec![Segment::text(content.to_owned())] ); + + let rendered: Vec = buffer + .render(80) + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + }) + .collect(); + assert_eq!(rendered, vec!["ab", "cd", "ef"]); + + buffer.move_up(); + assert_eq!(buffer.cursor, 6); + buffer.move_up(); + assert_eq!(buffer.cursor, 2); + buffer.move_down(); + assert_eq!(buffer.cursor, 6); + buffer.move_home(); + assert_eq!(buffer.cursor, 4); + buffer.move_end(); + assert_eq!(buffer.cursor, 6); } #[test] diff --git a/tests/fixtures/composer-paste-policy.json b/tests/fixtures/composer-paste-policy.json index 04eb55a0..e041efb7 100644 --- a/tests/fixtures/composer-paste-policy.json +++ b/tests/fixtures/composer-paste-policy.json @@ -23,6 +23,70 @@ "logical_line_count": 1, "presentation": "chip" }, + { + "name": "three_lines_50_scalars", + "parts": [ + { "value": "a\nb\n", "repeat": 1 }, + { "value": "x", "repeat": 46 } + ], + "char_count": 50, + "logical_line_count": 3, + "presentation": "text" + }, + { + "name": "four_lines_50_scalars", + "parts": [ + { "value": "a\nb\nc\n", "repeat": 1 }, + { "value": "x", "repeat": 44 } + ], + "char_count": 50, + "logical_line_count": 4, + "presentation": "chip" + }, + { + "name": "four_lines_51_scalars", + "parts": [ + { "value": "a\nb\nc\n", "repeat": 1 }, + { "value": "x", "repeat": 45 } + ], + "char_count": 51, + "logical_line_count": 4, + "presentation": "chip" + }, + { + "name": "two_lines_51_scalars", + "parts": [ + { "value": "a\n", "repeat": 1 }, + { "value": "x", "repeat": 49 } + ], + "char_count": 51, + "logical_line_count": 2, + "presentation": "chip" + }, + { + "name": "four_lines_10_scalars", + "parts": [ + { "value": "a\nb\nc\n", "repeat": 1 }, + { "value": "x", "repeat": 4 } + ], + "char_count": 10, + "logical_line_count": 4, + "presentation": "chip" + }, + { + "name": "japanese", + "parts": [{ "value": "日本語の貼り付け", "repeat": 1 }], + "char_count": 8, + "logical_line_count": 1, + "presentation": "text" + }, + { + "name": "combining_marks_50_scalars", + "parts": [{ "value": "é", "repeat": 25 }], + "char_count": 50, + "logical_line_count": 1, + "presentation": "text" + }, { "name": "unicode_scalar_50", "parts": [{ "value": "🦀", "repeat": 50 }],