diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts index 3339f166106..db8f632c6ff 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts @@ -229,6 +229,18 @@ describe('registerArabicShapingJoiner', () => { webglLive = true expect(handler('مرحبا')).toEqual([[0, 5]]) }) + + it('returns RTL runs and Indic clusters from one handler in start order', () => { + const host = createJoinerHost() + registerArabicShapingJoiner(host.terminal, () => true) + + expect(host.getRegistered()!('বাংলা مرحبا কি')).toEqual([ + [0, 3], + [3, 5], + [6, 11], + [12, 14] + ]) + }) }) describe('configureLazyArabicShapingJoiner', () => { @@ -260,6 +272,60 @@ describe('configureLazyArabicShapingJoiner', () => { expect(host.events).toEqual([]) }) + it('registers before the first Bengali write and shapes its clusters', () => { + const host = createLazyHost() + configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'আমি দেখি') + + expect(host.events).toEqual(['register']) + expect(host.getHandler()!('আমি দেখি')).toEqual([ + [1, 3], + [4, 6], + [6, 8] + ]) + }) + + it('does not register for Indic text that needs no joining', () => { + const host = createLazyHost() + configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, '১২৩ কু ।') + + expect(host.events).toEqual([]) + }) + + it('registers when a Bengali spacing vowel sign arrives in the next write', () => { + const host = createLazyHost() + configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'ক') + expect(host.events).toEqual([]) + ensureArabicShapingJoinerForText(host.terminal, 'ি') + + expect(host.events).toEqual(['register']) + }) + + it('registers when a conjunct is split after the virama', () => { + const host = createLazyHost() + configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'ক্') + ensureArabicShapingJoinerForText(host.terminal, 'ষ') + + expect(host.events).toEqual(['register']) + }) + + it('does not carry an Indic tail across an ASCII write', () => { + const host = createLazyHost() + configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'ক') + ensureArabicShapingJoinerForText(host.terminal, ' ি') + + expect(host.events).toEqual([]) + }) + it('registers once before the first RTL write and cleans it up', () => { const host = createLazyHost() const cleanup = configureLazyArabicShapingJoiner(host.terminal, () => true) diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts index 64c996d88fd..81c1d1b7e60 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts @@ -1,4 +1,5 @@ import type { Terminal } from '@xterm/xterm' +import { findIndicClusterRanges } from './terminal-indic-cluster-ranges' type ArabicShapingTerminal = Pick @@ -6,7 +7,7 @@ type LazyArabicShapingJoinerState = { cleanup: (() => void) | null isShapingActive: () => boolean registrationAttempted: boolean - trailingHighSurrogate: string + trailingChunkTail: string } const lazyArabicShapingJoinerByTerminal = new WeakMap< @@ -161,14 +162,21 @@ export function findRtlJoinRanges(text: string): [number, number][] { return ranges } -/** Register the RTL shaping joiner; returns a cleanup deregistering it — Terminal.dispose() leaks joiners (xtermjs/xterm.js#3289). */ +// Why: Indic clusters share the lazy joiner so one registration, WebGL gate and wiring cover both scripts; the RTL and Indic ranges can never overlap. +function findShapingJoinRanges(text: string): [number, number][] { + const ranges = findRtlJoinRanges(text) + const indicRanges = findIndicClusterRanges(text) + return indicRanges.length === 0 ? ranges : [...ranges, ...indicRanges].sort((a, b) => a[0] - b[0]) +} + +/** Register the shaping joiner (RTL runs and Indic clusters); returns a cleanup deregistering it — Terminal.dispose() leaks joiners (xtermjs/xterm.js#3289). */ export function registerArabicShapingJoiner( terminal: ArabicShapingTerminal, isShapingActive: () => boolean ): () => void { // Why: DOM renderer letter-spacing breaks grid alignment for joined runs, so join only while WebGL is the live renderer. const joinerId = terminal.registerCharacterJoiner((text) => - isShapingActive() ? findRtlJoinRanges(text) : [] + isShapingActive() ? findShapingJoinRanges(text) : [] ) return () => { terminal.deregisterCharacterJoiner(joinerId) @@ -191,7 +199,7 @@ export function configureLazyArabicShapingJoiner( cleanup: null, isShapingActive, registrationAttempted: false, - trailingHighSurrogate: '' + trailingChunkTail: '' } lazyArabicShapingJoinerByTerminal.set(terminal, state) @@ -209,6 +217,17 @@ export function configureLazyArabicShapingJoiner( } } +const INDIC_TAIL = /[\u0900-\u0DFF\u200C\u200D]{1,8}$/ + +// A trailing high surrogate, or the trailing Indic run that a following chunk may complete into a cluster. +function getChunkTail(text: string): string { + const finalCodeUnit = text.charCodeAt(text.length - 1) + if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) { + return text.at(-1) ?? '' + } + return INDIC_TAIL.exec(text)?.[0] ?? '' +} + /** Register the configured joiner immediately before the first RTL write. */ export function ensureArabicShapingJoinerForText( terminal: ArabicShapingTerminal, @@ -219,17 +238,14 @@ export function ensureArabicShapingJoinerForText( return } - // Why: PTY/replay chunks can split a supplementary-plane RTL code point across surrogate halves; keep the boundary unit. - const scanText = state.trailingHighSurrogate + text - const finalCharacter = scanText.at(-1) ?? '' - const finalCodeUnit = finalCharacter.charCodeAt(0) - state.trailingHighSurrogate = - finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff ? finalCharacter : '' - if (!containsStrongRtlText(scanText)) { + // Why: PTY/replay chunks can split a supplementary-plane RTL code point or an Indic cluster; keep the boundary tail. + const scanText = state.trailingChunkTail + text + state.trailingChunkTail = getChunkTail(scanText) + if (!containsStrongRtlText(scanText) && findIndicClusterRanges(scanText).length === 0) { return } - state.trailingHighSurrogate = '' + state.trailingChunkTail = '' state.registrationAttempted = true try { state.cleanup = registerArabicShapingJoiner(terminal, state.isShapingActive) diff --git a/src/renderer/src/lib/pane-manager/terminal-indic-cluster-ranges.test.ts b/src/renderer/src/lib/pane-manager/terminal-indic-cluster-ranges.test.ts new file mode 100644 index 00000000000..8e67b5f9522 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-indic-cluster-ranges.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' + +import { findIndicClusterRanges } from './terminal-indic-cluster-ranges' + +describe('findIndicClusterRanges', () => { + it('returns no ranges for ASCII, Latin accents, and Arabic', () => { + expect(findIndicClusterRanges('ls -la | grep foo && echo done')).toEqual([]) + expect(findIndicClusterRanges('café é مرحبا')).toEqual([]) + }) + + it('returns a fresh array on every call so xterm can merge into it safely', () => { + findIndicClusterRanges('কি').push([9, 9]) + expect(findIndicClusterRanges('কি')).toEqual([[0, 2]]) + }) + + it('joins a base letter with its spacing vowel sign', () => { + expect(findIndicClusterRanges('কি')).toEqual([[0, 2]]) + expect(findIndicClusterRanges('কৌ')).toEqual([[0, 2]]) + }) + + it('joins each cluster of a word separately', () => { + // আ + মি + দে + খি: lone letters stay per-cell, only multi-cell clusters join. + expect(findIndicClusterRanges('আমি দেখি')).toEqual([ + [1, 3], + [4, 6], + [6, 8] + ]) + }) + + it('joins a conjunct through virama chains', () => { + expect(findIndicClusterRanges('ক্ষ')).toEqual([[0, 3]]) + expect(findIndicClusterRanges('ন্ত্র')).toEqual([[0, 5]]) + }) + + it('joins a conjunct together with a trailing vowel sign', () => { + // অ + ন্যে + র + expect(findIndicClusterRanges('অন্যের')).toEqual([[1, 5]]) + }) + + it('joins Devanagari clusters', () => { + expect(findIndicClusterRanges('हिन्दी')).toEqual([ + [0, 2], + [2, 6] + ]) + }) + + it('leaves nonspacing-only clusters in their single base cell', () => { + expect(findIndicClusterRanges('কু')).toEqual([]) + expect(findIndicClusterRanges('চাঁদ')).toEqual([[0, 3]]) + }) + + it('does not join a virama with nothing after it', () => { + expect(findIndicClusterRanges('ক্')).toEqual([]) + }) + + it('keeps ASCII and box-drawing neighbours out of the ranges', () => { + expect(findIndicClusterRanges('│ কি abc │')).toEqual([[2, 4]]) + }) + + it('joins a Sinhala conjunct written with virama + ZWJ', () => { + expect(findIndicClusterRanges('ක්\u200Dර')).toEqual([[0, 4]]) + }) + + it('does not join across a ZWNJ-blocked conjunct', () => { + expect(findIndicClusterRanges('ক্\u200Cষ')).toEqual([]) + }) + + it('does not let a bare ZWJ open a conjunct', () => { + expect(findIndicClusterRanges('ক\u200Dখ')).toEqual([]) + }) + + it('ends a Malayalam chillu cluster instead of joining the next letter', () => { + expect(findIndicClusterRanges('ന്\u200Dക')).toEqual([]) + }) + + it('leaves Tamil pulli chains per-cell', () => { + expect(findIndicClusterRanges('க்க்க்')).toEqual([]) + }) + + it('still joins a Tamil spacing vowel sign', () => { + expect(findIndicClusterRanges('கொ')).toEqual([[0, 2]]) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-indic-cluster-ranges.ts b/src/renderer/src/lib/pane-manager/terminal-indic-cluster-ranges.ts new file mode 100644 index 00000000000..f12adf7a970 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-indic-cluster-ranges.ts @@ -0,0 +1,44 @@ +// Why: xterm gives each Brahmic spacing vowel sign (U+09BF, U+09CB, …) its own cell, matching +// wcwidth/string-width, so TUIs align on that count. WebGL then draws each cell in isolation and the +// font never reorders or composes the cluster. Joining the cluster's cells lets the browser shape it +// without changing the cell count. + +const INDIC_SCAN_FLOOR = 0x0900 +const INDIC_SCAN_CEILING = 0x0dff + +// Why: Tamil pulli (U+0BCD) is omitted because it renders as a visible dot, so Tamil never forms conjuncts. +const VIRAMAS = '\\u094D\\u09CD\\u0A4D\\u0ACD\\u0B4D\\u0C4D\\u0CCD\\u0D4D\\u0DCA' + +// Base letter, then marks/joiners, or a letter that follows a virama (+ optional ZWJ) as a conjunct. +// Malayalam virama + ZWJ is a legacy chillu, which ends its cluster instead of joining the next letter. +const INDIC_CLUSTER = new RegExp( + `(?=\\p{L})[\\u0900-\\u0DFF](?:[\\p{M}\\u200C\\u200D]|(?<=[${VIRAMAS}]\\u200D?)(?= INDIC_SCAN_FLOOR && unit <= INDIC_SCAN_CEILING) { + return true + } + } + return false +} + +/** Character-joiner handler: [start, end) ranges of Indic clusters that occupy more than one cell. */ +export function findIndicClusterRanges(text: string): [number, number][] { + // Why: xterm merges other joiners' results into this array in place, so it must be freshly allocated each call. + const ranges: [number, number][] = [] + if (!containsIndicScriptText(text)) { + return ranges + } + for (const match of text.matchAll(INDIC_CLUSTER)) { + if (match[0].length > 1 && SPANS_MULTIPLE_CELLS.test(match[0])) { + ranges.push([match.index, match.index + match[0].length]) + } + } + return ranges +}