Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions packages/diffs/src/editor/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,15 +326,60 @@ function moveBySoftLine(

const column = Math.max(0, character - current.start);
const targetCharacter = target.start + column;
const landedCharacter =
target.index === target.count - 1
? targetCharacter
: Math.min(targetCharacter, target.end);
const targetLineText = textDocument.getLineText(targetLine);
// Keep goal-column overshoots, but snap real offsets out of graphemes.
return {
line: targetLine,
character:
target.index === target.count - 1
? targetCharacter
: Math.min(targetCharacter, target.end),
landedCharacter > targetLineText.length
? landedCharacter
: snapCharacterToGraphemeBoundary(targetLineText, landedCharacter),
};
}

// Snaps a vertical landing to the end of its grapheme so edits cannot split a
// user-visible character. Stop once the containing cluster is resolved.
function snapCharacterToGraphemeBoundary(
lineText: string,
character: number
): number {
if (character <= 0 || character >= lineText.length) {
return character;
}

const segmenter = getGraphemeSegmenter();
if (segmenter !== undefined) {
for (const segment of segmenter.segment(lineText)) {
if (character <= segment.index) {
return character;
}
const segmentEnd = segment.index + segment.segment.length;
if (character < segmentEnd) {
return segmentEnd;
}
}
return character;
}

// Degraded path for engines lacking Intl.Segmenter: preserve code points.
let segmentStart = 0;
for (const codePoint of lineText) {
if (character <= segmentStart) {
return character;
}
const segmentEnd = segmentStart + codePoint.length;
if (character < segmentEnd) {
return segmentEnd;
}
segmentStart = segmentEnd;
}
return character;
}

function getSoftLineInfo(
textDocument: TextDocument<unknown>,
line: number,
Expand Down
6 changes: 0 additions & 6 deletions packages/diffs/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,3 @@ order. If you touch one of these areas, consider adding the missing coverage:
before the slice, a lookahead whose context sits after the slice, and a
lookahead that re-matches shorter on the slice all fall back to inserting the
literal (unexpanded) replacement text.
- **Soft-wrap vertical motion splits surrogate pairs** (2) — vertical motion
into a wrapped continuation row computes the landing spot as raw UTF-16 units
with no grapheme/surrogate snapping, so moving down into a continuation row
(or up across a logical-line boundary) can park the caret between the halves
of an astral character; a subsequent insert there splits the pair into lone
surrogates.
100 changes: 50 additions & 50 deletions packages/diffs/test/editorWrapCaretPosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,59 +572,59 @@ describe('caret affinity at a wrap boundary', () => {
});

describe('vertical motion between wrapped rows and grapheme integrity', () => {
// KNOWN BUG: moveBySoftLine computes the landing spot as target-segment
// start + visual column in raw UTF-16 units with no grapheme/surrogate
// snapping, so ArrowDown here parks the caret at character 7 — between the
// halves of the second astral character. A subsequent insert at that caret
// splits the pair into lone surrogates (verified against TextDocument:
// inserting "x" at (0,7) leaves \ud83d x \ude00 in the buffer).
test.failing(
'ArrowDown into a continuation row never lands inside a surrogate pair',
async () => {
const astralRow = '\u{1F680}\u{1F98A}'; // 2 astral chars = 4 UTF-16 units
const lineText = `wxyz${astralRow}`;
// Visual rows: [0,4) 'wxyz' and [4,8) with both astral characters.
const { cleanup, content, editor, window } = await createWrapEditor(
lineText,
4
);
try {
setCaret(editor, 0, 3);
dispatchMovementKey(window, content, { key: 'ArrowDown' });

const { line, character } = caretState(editor);
expect(line).toBe(0);
expect(sitsInsideSurrogatePair(lineText, character)).toBe(false);
} finally {
cleanup();
}
test('ArrowDown snaps across a Hangul jamo grapheme', async () => {
const lineText = 'wxyz\u1112\u1161\u11ab';
const { cleanup, content, editor, window } = await createWrapEditor(
lineText,
4
);
try {
setCaret(editor, 0, 1);
dispatchMovementKey(window, content, { key: 'ArrowDown' });

expectCaret(editor, 0, 7);
} finally {
cleanup();
}
);
});

// KNOWN BUG: same raw-column arithmetic on the upward path. Crossing the
// logical-line boundary lands on the wrapped line's last visual row at
// segment start + 1 = character 5, the low-surrogate half of the trailing
// astral character.
test.failing(
'ArrowUp across a logical-line boundary never lands inside a surrogate pair',
async () => {
const lineText = '\u{1F680}mn\u{1F98A}'; // rows: [0,4) '🚀mn', [4,6) '🦊'
const { cleanup, content, editor, window } = await createWrapEditor(
`${lineText}\nabc`,
4
);
try {
setCaret(editor, 1, 1);
dispatchMovementKey(window, content, { key: 'ArrowUp' });

const { line, character } = caretState(editor);
expect(line).toBe(0);
expect(sitsInsideSurrogatePair(lineText, character)).toBe(false);
} finally {
cleanup();
}
test('ArrowDown into a continuation row never lands inside a surrogate pair', async () => {
const astralRow = '\u{1F680}\u{1F98A}'; // 2 astral chars = 4 UTF-16 units
const lineText = `wxyz${astralRow}`;
// Visual rows: [0,4) 'wxyz' and [4,8) with both astral characters.
const { cleanup, content, editor, window } = await createWrapEditor(
lineText,
4
);
try {
setCaret(editor, 0, 3);
dispatchMovementKey(window, content, { key: 'ArrowDown' });

const { line, character } = caretState(editor);
expect(line).toBe(0);
expect(sitsInsideSurrogatePair(lineText, character)).toBe(false);
} finally {
cleanup();
}
);
});

test('ArrowUp across a logical-line boundary never lands inside a surrogate pair', async () => {
const lineText = '\u{1F680}mn\u{1F98A}'; // rows: [0,4) '🚀mn', [4,6) '🦊'
const { cleanup, content, editor, window } = await createWrapEditor(
`${lineText}\nabc`,
4
);
try {
setCaret(editor, 1, 1);
dispatchMovementKey(window, content, { key: 'ArrowUp' });

const { line, character } = caretState(editor);
expect(line).toBe(0);
expect(sitsInsideSurrogatePair(lineText, character)).toBe(false);
} finally {
cleanup();
}
});

test('ArrowUp from the line below lands on the last visual row of the wrapped line', async () => {
// Line 0 wraps into 'the_quick_' / 'brown_fox_' / 'jumps'.
Expand Down