fix: keep short composer pastes as text
This commit is contained in:
+220
-12
@@ -15,6 +15,64 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
pub const MAX_PLAIN_TEXT_PASTE_CHARS: usize = 50;
|
||||
pub const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PasteMeasurement {
|
||||
pub chars: usize,
|
||||
pub logical_lines: usize,
|
||||
}
|
||||
|
||||
impl PasteMeasurement {
|
||||
pub fn presentation(self) -> PastePresentation {
|
||||
if self.chars <= MAX_PLAIN_TEXT_PASTE_CHARS
|
||||
&& self.logical_lines <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||
{
|
||||
PastePresentation::Text
|
||||
} else {
|
||||
PastePresentation::Chip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PastePresentation {
|
||||
Text,
|
||||
Chip,
|
||||
}
|
||||
|
||||
pub fn measure_paste(content: &str) -> PasteMeasurement {
|
||||
PasteMeasurement {
|
||||
chars: content.chars().count(),
|
||||
logical_lines: logical_line_count(content),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty content has zero logical lines. Otherwise LF, lone CR, and CRLF each
|
||||
/// advance one line; a CRLF pair is one break rather than two.
|
||||
pub fn logical_line_count(content: &str) -> usize {
|
||||
if content.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut lines = 1;
|
||||
let mut chars = content.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\r' => {
|
||||
if chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
lines += 1;
|
||||
}
|
||||
'\n' => lines += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PasteRef {
|
||||
pub id: u32,
|
||||
@@ -262,16 +320,21 @@ 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);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = self.next_paste_id;
|
||||
self.next_paste_id = self.next_paste_id.wrapping_add(1);
|
||||
let chars = content.chars().count();
|
||||
let lines = content.lines().count().max(1);
|
||||
self.atoms.insert(
|
||||
self.cursor,
|
||||
Atom::Paste(PasteRef {
|
||||
id,
|
||||
chars,
|
||||
lines,
|
||||
chars: measurement.chars,
|
||||
lines: measurement.logical_lines,
|
||||
content,
|
||||
}),
|
||||
);
|
||||
@@ -879,6 +942,136 @@ mod render_viewport_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paste_policy_tests {
|
||||
use super::*;
|
||||
use protocol::Segment;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Fixture {
|
||||
max_plain_text_chars: usize,
|
||||
max_plain_text_logical_lines: usize,
|
||||
cases: Vec<FixtureCase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FixtureCase {
|
||||
name: String,
|
||||
parts: Vec<FixturePart>,
|
||||
char_count: usize,
|
||||
logical_line_count: usize,
|
||||
presentation: FixturePresentation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FixturePart {
|
||||
value: String,
|
||||
repeat: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum FixturePresentation {
|
||||
Text,
|
||||
Chip,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
serde_json::from_str(include_str!(
|
||||
"../../../tests/fixtures/composer-paste-policy.json"
|
||||
))
|
||||
.expect("shared composer paste policy fixture must be valid")
|
||||
}
|
||||
|
||||
fn fixture_content(case: &FixtureCase) -> String {
|
||||
case.parts
|
||||
.iter()
|
||||
.map(|part| part.value.repeat(part.repeat))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_follows_shared_paste_presentation_contract() {
|
||||
let fixture = fixture();
|
||||
assert_eq!(fixture.max_plain_text_chars, MAX_PLAIN_TEXT_PASTE_CHARS);
|
||||
assert_eq!(
|
||||
fixture.max_plain_text_logical_lines,
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||
);
|
||||
|
||||
for case in fixture.cases {
|
||||
let content = fixture_content(&case);
|
||||
let measurement = measure_paste(&content);
|
||||
let expected_presentation = match case.presentation {
|
||||
FixturePresentation::Text => PastePresentation::Text,
|
||||
FixturePresentation::Chip => PastePresentation::Chip,
|
||||
};
|
||||
assert_eq!(measurement.chars, case.char_count, "{} chars", case.name);
|
||||
assert_eq!(
|
||||
measurement.logical_lines, case.logical_line_count,
|
||||
"{} logical lines",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
measurement.presentation(),
|
||||
expected_presentation,
|
||||
"{} presentation",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_paste_is_editable_text_at_the_cursor() {
|
||||
let mut buffer = InputBuffer::new();
|
||||
buffer.insert_str("ac");
|
||||
buffer.move_left();
|
||||
buffer.insert_paste("b".to_owned());
|
||||
|
||||
assert_eq!(buffer.plain_text(), "abc");
|
||||
assert!(
|
||||
buffer
|
||||
.atoms
|
||||
.iter()
|
||||
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||
);
|
||||
assert_eq!(
|
||||
buffer.submit_segments(),
|
||||
vec![Segment::text("abc".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_multiline_paste_normalizes_line_endings_as_text() {
|
||||
let mut buffer = InputBuffer::new();
|
||||
buffer.insert_paste("a\r\nb\rc".to_owned());
|
||||
|
||||
assert_eq!(buffer.plain_text(), "a\nb\nc");
|
||||
assert!(
|
||||
buffer
|
||||
.atoms
|
||||
.iter()
|
||||
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||
);
|
||||
assert_eq!(
|
||||
buffer.submit_segments(),
|
||||
vec![Segment::text("a\nb\nc".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_paste_is_a_noop() {
|
||||
let mut buffer = InputBuffer::new();
|
||||
buffer.insert_str("unchanged");
|
||||
let paste_id = buffer.next_paste_id;
|
||||
buffer.insert_paste(String::new());
|
||||
|
||||
assert_eq!(buffer.plain_text(), "unchanged");
|
||||
assert_eq!(buffer.next_paste_id, paste_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod submit_segments_tests {
|
||||
use super::*;
|
||||
@@ -904,7 +1097,8 @@ mod submit_segments_tests {
|
||||
for c in "see ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("line1\nline2".into());
|
||||
let pasted = "line1\nline2\nline3\nline4";
|
||||
buf.insert_paste(pasted.into());
|
||||
for c in " end".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
@@ -921,9 +1115,9 @@ mod submit_segments_tests {
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(content, "line1\nline2");
|
||||
assert_eq!(*chars, "line1\nline2".chars().count() as u32);
|
||||
assert_eq!(*lines, 2);
|
||||
assert_eq!(content, pasted);
|
||||
assert_eq!(*chars, pasted.chars().count() as u32);
|
||||
assert_eq!(*lines, 4);
|
||||
}
|
||||
other => panic!("expected Paste, got {other:?}"),
|
||||
}
|
||||
@@ -933,6 +1127,20 @@ mod submit_segments_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_direct_paste_remains_a_typed_segment_without_reclassification() {
|
||||
let original = Segment::Paste {
|
||||
id: 7,
|
||||
chars: 1,
|
||||
lines: 1,
|
||||
content: "x".to_owned(),
|
||||
};
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.replace_with_segments(std::slice::from_ref(&original));
|
||||
|
||||
assert_eq!(buf.submit_segments(), vec![original]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_paste_artifact_remains_a_typed_segment() {
|
||||
let artifact = protocol::PasteArtifactRef {
|
||||
@@ -967,7 +1175,7 @@ mod submit_segments_tests {
|
||||
#[test]
|
||||
fn leading_paste_does_not_emit_empty_text() {
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.insert_paste("X".into());
|
||||
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
||||
@@ -1067,7 +1275,7 @@ mod completion_prefix_tests {
|
||||
#[test]
|
||||
fn trigger_after_chip_atom() {
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.insert_paste("X".into());
|
||||
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
for c in "@sr".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
@@ -1176,7 +1384,7 @@ mod word_motion_tests {
|
||||
for c in "foo ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("anything".into());
|
||||
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
for c in " bar".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
@@ -1335,7 +1543,7 @@ mod word_motion_tests {
|
||||
for c in "foo ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("anything".into());
|
||||
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
for c in " bar".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"max_plain_text_chars": 50,
|
||||
"max_plain_text_logical_lines": 3,
|
||||
"cases": [
|
||||
{
|
||||
"name": "empty",
|
||||
"parts": [],
|
||||
"char_count": 0,
|
||||
"logical_line_count": 0,
|
||||
"presentation": "text"
|
||||
},
|
||||
{
|
||||
"name": "ascii_50",
|
||||
"parts": [{ "value": "a", "repeat": 50 }],
|
||||
"char_count": 50,
|
||||
"logical_line_count": 1,
|
||||
"presentation": "text"
|
||||
},
|
||||
{
|
||||
"name": "ascii_51",
|
||||
"parts": [{ "value": "a", "repeat": 51 }],
|
||||
"char_count": 51,
|
||||
"logical_line_count": 1,
|
||||
"presentation": "chip"
|
||||
},
|
||||
{
|
||||
"name": "unicode_scalar_50",
|
||||
"parts": [{ "value": "🦀", "repeat": 50 }],
|
||||
"char_count": 50,
|
||||
"logical_line_count": 1,
|
||||
"presentation": "text"
|
||||
},
|
||||
{
|
||||
"name": "unicode_scalar_51",
|
||||
"parts": [{ "value": "🦀", "repeat": 51 }],
|
||||
"char_count": 51,
|
||||
"logical_line_count": 1,
|
||||
"presentation": "chip"
|
||||
},
|
||||
{
|
||||
"name": "three_lf_lines",
|
||||
"parts": [{ "value": "alpha\nbeta\ngamma", "repeat": 1 }],
|
||||
"char_count": 16,
|
||||
"logical_line_count": 3,
|
||||
"presentation": "text"
|
||||
},
|
||||
{
|
||||
"name": "four_lf_lines",
|
||||
"parts": [{ "value": "a\nb\nc\nd", "repeat": 1 }],
|
||||
"char_count": 7,
|
||||
"logical_line_count": 4,
|
||||
"presentation": "chip"
|
||||
},
|
||||
{
|
||||
"name": "three_crlf_lines",
|
||||
"parts": [{ "value": "a\r\nb\r\nc", "repeat": 1 }],
|
||||
"char_count": 7,
|
||||
"logical_line_count": 3,
|
||||
"presentation": "text"
|
||||
},
|
||||
{
|
||||
"name": "mixed_line_endings",
|
||||
"parts": [{ "value": "a\r\nb\rc\nd", "repeat": 1 }],
|
||||
"char_count": 8,
|
||||
"logical_line_count": 4,
|
||||
"presentation": "chip"
|
||||
},
|
||||
{
|
||||
"name": "trailing_newline",
|
||||
"parts": [{ "value": "a\n", "repeat": 1 }],
|
||||
"char_count": 2,
|
||||
"logical_line_count": 2,
|
||||
"presentation": "text"
|
||||
},
|
||||
{
|
||||
"name": "whitespace_rich_50",
|
||||
"parts": [{ "value": " \t", "repeat": 25 }],
|
||||
"char_count": 50,
|
||||
"logical_line_count": 1,
|
||||
"presentation": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export const MAX_PLAIN_TEXT_PASTE_CHARS = 50;
|
||||
export const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES = 3;
|
||||
|
||||
export type ComposerPastePresentation = "text" | "chip";
|
||||
|
||||
export interface ComposerPasteMeasurement {
|
||||
charCount: number;
|
||||
logicalLineCount: number;
|
||||
presentation: ComposerPastePresentation;
|
||||
}
|
||||
|
||||
export interface ComposerPasteEvent {
|
||||
clipboardData: { getData(format: string): string } | null;
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count user-visible Unicode scalar values rather than UTF-16 code units.
|
||||
* JavaScript string iteration combines a valid surrogate pair into one value.
|
||||
*/
|
||||
export function unicodeScalarCount(content: string): number {
|
||||
let count = 0;
|
||||
for (const _value of content) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty content has zero logical lines. Otherwise each LF, lone CR, or CRLF
|
||||
* advances one line; CRLF is one break rather than two.
|
||||
*/
|
||||
export function logicalLineCount(content: string): number {
|
||||
if (content.length === 0) return 0;
|
||||
|
||||
let count = 1;
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const codeUnit = content.charCodeAt(index);
|
||||
if (codeUnit === 0x0d) {
|
||||
if (content.charCodeAt(index + 1) === 0x0a) index += 1;
|
||||
count += 1;
|
||||
} else if (codeUnit === 0x0a) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function measureComposerPaste(
|
||||
content: string,
|
||||
): ComposerPasteMeasurement {
|
||||
const charCount = unicodeScalarCount(content);
|
||||
const logicalLineCountValue = logicalLineCount(content);
|
||||
const presentation = charCount <= MAX_PLAIN_TEXT_PASTE_CHARS &&
|
||||
logicalLineCountValue <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||
? "text"
|
||||
: "chip";
|
||||
|
||||
return {
|
||||
charCount,
|
||||
logicalLineCount: logicalLineCountValue,
|
||||
presentation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Browser paste without disrupting native short-text editing.
|
||||
*
|
||||
* Returning false means the caller must leave the event untouched, preserving
|
||||
* the Browser's cursor, selection replacement, and undo behavior. A chip paste
|
||||
* is consumed exactly once and handed to the compact-paste implementation.
|
||||
*/
|
||||
export function handleComposerPaste(
|
||||
event: ComposerPasteEvent,
|
||||
insertCompactPaste: (
|
||||
content: string,
|
||||
measurement: ComposerPasteMeasurement,
|
||||
) => void,
|
||||
): boolean {
|
||||
if (!event.clipboardData) return false;
|
||||
|
||||
const content = event.clipboardData.getData("text/plain");
|
||||
const measurement = measureComposerPaste(content);
|
||||
if (measurement.presentation === "text") return false;
|
||||
|
||||
event.preventDefault();
|
||||
insertCompactPaste(content, measurement);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import fixtureJson from "../../../tests/fixtures/composer-paste-policy.json" with {
|
||||
type: "json",
|
||||
};
|
||||
|
||||
import {
|
||||
type ComposerPasteMeasurement,
|
||||
handleComposerPaste,
|
||||
MAX_PLAIN_TEXT_PASTE_CHARS,
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||
measureComposerPaste,
|
||||
} from "../src/lib/workspace/console/composer-paste.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertEquals(
|
||||
actual: unknown,
|
||||
expected: unknown,
|
||||
message?: string,
|
||||
): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(
|
||||
`${
|
||||
message ? `${message}: ` : ""
|
||||
}expected ${expectedJson}, got ${actualJson}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FixturePart {
|
||||
value: string;
|
||||
repeat: number;
|
||||
}
|
||||
|
||||
interface FixtureCase {
|
||||
name: string;
|
||||
parts: FixturePart[];
|
||||
char_count: number;
|
||||
logical_line_count: number;
|
||||
presentation: "text" | "chip";
|
||||
}
|
||||
|
||||
interface PastePolicyFixture {
|
||||
max_plain_text_chars: number;
|
||||
max_plain_text_logical_lines: number;
|
||||
cases: FixtureCase[];
|
||||
}
|
||||
|
||||
const fixture = fixtureJson as PastePolicyFixture;
|
||||
|
||||
function fixtureContent(testCase: FixtureCase): string {
|
||||
return testCase.parts.map(({ value, repeat }) => value.repeat(repeat)).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("Browser composer follows the shared paste presentation contract", () => {
|
||||
assertEquals(MAX_PLAIN_TEXT_PASTE_CHARS, fixture.max_plain_text_chars);
|
||||
assertEquals(
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||
fixture.max_plain_text_logical_lines,
|
||||
);
|
||||
|
||||
for (const testCase of fixture.cases) {
|
||||
assertEquals(
|
||||
measureComposerPaste(fixtureContent(testCase)),
|
||||
{
|
||||
charCount: testCase.char_count,
|
||||
logicalLineCount: testCase.logical_line_count,
|
||||
presentation: testCase.presentation,
|
||||
},
|
||||
testCase.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("short paste remains a native Browser edit", () => {
|
||||
let prevented = false;
|
||||
let inserted = 0;
|
||||
const handled = handleComposerPaste(
|
||||
{
|
||||
clipboardData: { getData: () => "replace the selection" },
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
},
|
||||
() => {
|
||||
inserted += 1;
|
||||
},
|
||||
);
|
||||
|
||||
assertEquals(handled, false);
|
||||
assertEquals(prevented, false);
|
||||
assertEquals(inserted, 0);
|
||||
});
|
||||
|
||||
Deno.test("chip paste is prevented and routed exactly once", () => {
|
||||
const content = "🦀".repeat(51);
|
||||
let prevented = 0;
|
||||
const inserted: Array<[string, ComposerPasteMeasurement]> = [];
|
||||
const handled = handleComposerPaste(
|
||||
{
|
||||
clipboardData: { getData: () => content },
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
},
|
||||
(paste, measurement) => inserted.push([paste, measurement]),
|
||||
);
|
||||
|
||||
assertEquals(handled, true);
|
||||
assertEquals(prevented, 1);
|
||||
assertEquals(inserted, [[content, {
|
||||
charCount: 51,
|
||||
logicalLineCount: 1,
|
||||
presentation: "chip",
|
||||
}]]);
|
||||
});
|
||||
Reference in New Issue
Block a user