diff --git a/src/utils/Cursor.nfc.test.ts b/src/utils/Cursor.nfc.test.ts new file mode 100644 index 0000000000..776c882952 --- /dev/null +++ b/src/utils/Cursor.nfc.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { Cursor } from './Cursor.js' + +describe('Cursor.insert NFC boundary offset', () => { + test('places the cursor correctly when the insert composes with the prefix', () => { + // Cursor sits between "e" and "X". Inserting a combining acute accent + // composes with the "e" into a single "é", so the normalized text is "éX" + // (length 2) and the cursor belongs at offset 1 (before "X"). The old code + // measured the insert in isolation and landed at offset 2 (after "X"). + const c = Cursor.fromText('eX', 80, 1) + expect(c.text).toBe('eX') + + const after = c.insert('́') + expect(after.text).toBe('éX') + expect(after.offset).toBe(1) + }) + + test('non-composing inserts still advance by the inserted length', () => { + const c = Cursor.fromText('abcX', 80, 3) // between "abc" and "X" + const after = c.insert('de') + expect(after.text).toBe('abcdeX') + expect(after.offset).toBe(5) + }) + + test('astral-plane insert advances by its UTF-16 unit count', () => { + const c = Cursor.fromText('X', 80, 0) + const after = c.insert('😀') // 2 UTF-16 code units + expect(after.text).toBe('😀X') + expect(after.offset).toBe(2) + }) +}) diff --git a/src/utils/Cursor.ts b/src/utils/Cursor.ts index 4622ea0acc..660bf1a359 100644 --- a/src/utils/Cursor.ts +++ b/src/utils/Cursor.ts @@ -875,11 +875,17 @@ export class Cursor { insertString + this.text.slice(endOffset) - return Cursor.fromText( - newText, - this.columns, - startOffset + insertString.normalize('NFC').length, - ) + // Cursor.fromText NFC-normalizes the whole newText, so compute the new + // offset from the normalized prefix-plus-insert rather than normalizing + // insertString in isolation. Otherwise a combining mark that composes with + // the last character of the prefix (e.g. "e" + U+0301 -> "é") shortens the + // normalized text by one unit that this offset doesn't account for, landing + // the cursor one position too far (past following text). + const newOffset = ( + this.text.slice(0, startOffset) + insertString + ).normalize('NFC').length + + return Cursor.fromText(newText, this.columns, newOffset) } insert(insertString: string): Cursor {