From c2b074bd154aa200a5bb7274b0412b32fb192b49 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:29:36 -0700 Subject: [PATCH] fix(terminal): shape Arabic/RTL text on the cell grid via xterm character joiner (#7665) * fix(terminal): shape Arabic/RTL runs on the cell grid via xterm character joiner (#5262) Register a character joiner that groups contiguous RTL runs (tunneling through neutral spaces/digits/punctuation between RTL words) so both the WebGL and DOM renderers draw each run as one string, applying native contextual shaping and BiDi ordering inside the run's grid-aligned cell box. Buffer and PTY stream are untouched; xterm un-joins ranges holding the cursor or a partial selection, keeping cursor visibility and selection cell-accurate. Non-RTL lines early-return via a single charCodeAt sweep. Co-authored-by: Orca * Deregister Arabic shaping joiner when disposing pane Since xterm.js does not automatically remove registered character joiners upon terminal disposal, they must be explicitly deregistered to avoid memory and registration leaks. - Update `registerArabicShapingJoiner` to return a cleanup function. - Store and call the cleanup handler when disposing a managed pane. - Update tests to assert proper registration and deregistration. * fix(terminal): gate RTL joining on live WebGL, tunnel ZWNJ/ZWJ/RLM, lock joiner lifecycle in tests Review findings on the Arabic shaping branch: - The DOM renderer sizes a joined span with one letter-spacing value applied per character, so joined RTL runs blew out row grid alignment whenever the WebGL renderer was unavailable (GPU off, auto policy, context loss). The joiner now only returns ranges while the pane's WebGL addon is live. - ZWNJ/ZWJ/RLM (mandatory in Persian orthography) hard-broke RTL runs, rendering word halves in swapped visual order. They are now transparent inside a run without extending or counting toward it. - openTerminal's existing unicode-ordering test failed at HEAD because the fake terminal lacked registerCharacterJoiner; the harness now implements the joiner API and a new test locks register-on-open/deregister-on-dispose. Co-authored-by: Orca * fix(terminal): keep zero-width RTL marks from opening joined runs, treat ALM as transparent Round-2 review findings: - A combining mark (tashkeel/niqqud) orphaned after an LTR base could open an RTL run mid-cell; xterm rounds that string range to an empty joined cell range and the WebGL renderer draws an empty glyph over the next character. Marks now extend and count only in runs opened by a spacing RTL letter. - ALM (U+061C) sat inside the strong-RTL block scan, so it opened and counted toward runs unlike RLM. It now joins the transparent set. - New test locks openTerminal wiring the joiner predicate to pane.webglAddon (previously () => true would pass the suite). Co-authored-by: Orca * fix(terminal): treat zero-width Cf controls in RTL blocks as run-transparent Round-3 review finding: zero-width format controls that live inside the strong-RTL scan ranges (Arabic number signs U+0600-0605, end of ayah U+06DD, Syriac abbreviation mark U+070F, disputed end of ayah U+08E2) and the BOM/ ZWNBSP U+FEFF are width-0 in xterm. Like combining marks they could open a run mid-cell, producing an empty joined cell range that blanks the following glyph in the WebGL renderer. They are Cf not Mn, so canOpenRtlRun could not catch them. Fold them into the run-transparent set (never open, count, or break a run) and document the upstream standalone-width-0-cell join skew. Co-authored-by: Orca * Document WebGL decoration limitation for Arabic shaping joiner Add comments explaining a known upstream limitation in xterm.js WebGL renderer where search-match highlights inside a joined run render all-or-nothing, similar to ligatures. --------- Co-authored-by: Orca --- .../src/lib/pane-manager/pane-dom-creation.ts | 1 + .../lib/pane-manager/pane-lifecycle.test.ts | 107 ++++++-- .../src/lib/pane-manager/pane-lifecycle.ts | 18 ++ .../lib/pane-manager/pane-manager-types.ts | 3 + .../terminal-arabic-shaping-joiner.test.ts | 250 ++++++++++++++++++ .../terminal-arabic-shaping-joiner.ts | 187 +++++++++++++ 6 files changed, 540 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 43b628ffb..365bfd801 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -148,6 +148,7 @@ export function createPaneDOM( compositionHandler: null, focusClassSyncCleanup: null, terminalScrollIntentDisposable: null, + arabicShapingJoinerCleanup: null, pendingSplitScrollState: null, pendingSplitScrollRafIds: [], pendingSplitScrollTimerId: null, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 16791a485..e96a8efec 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -6,7 +6,7 @@ import { markComplexScriptOutput, resetTerminalWebglSuggestion } from './pane-webgl-renderer' -import { attachLigatures, openTerminal } from './pane-lifecycle' +import { attachLigatures, disposePane, openTerminal } from './pane-lifecycle' import { buildDefaultTerminalOptions, DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, @@ -410,24 +410,27 @@ describe('attachLigatures', () => { }) }) -describe('openTerminal — Unicode 11 ordering', () => { +describe('openTerminal — addon and provider wiring', () => { beforeEach(() => { vi.stubGlobal('requestAnimationFrame', () => 1) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) }) afterEach(() => { vi.unstubAllGlobals() }) - // Why: CJK / emoji / ZWJ widths get baked into the buffer at the active - // unicode version on write. If anything writes bytes through xterm before - // unicode v11 is activated (still on default v6 width tables), wide chars - // lay out as single cells. The bug surfaces as the broken `?`-style glyphs - // users saw on worktree switch. - it('activates unicode 11 before any caller-driven write would be possible', () => { + function createOpenTerminalHarness(): { + pane: ManagedPaneInternal + events: string[] + getRegisteredJoinHandler: () => ((text: string) => [number, number][]) | null + } { const events: string[] = [] + let registeredJoinHandler: ((text: string) => [number, number][]) | null = null - const fitAddon = { fit: vi.fn() } as unknown as ManagedPaneInternal['fitAddon'] + const fitAddon = { + fit: vi.fn() + } as unknown as ManagedPaneInternal['fitAddon'] const searchAddon = {} as unknown as ManagedPaneInternal['searchAddon'] const serializeAddon = {} as unknown as ManagedPaneInternal['serializeAddon'] const unicode11Addon = {} as unknown as ManagedPaneInternal['unicode11Addon'] @@ -493,6 +496,14 @@ describe('openTerminal — Unicode 11 ordering', () => { write: vi.fn(() => { events.push('write') }), + registerCharacterJoiner: vi.fn((handler: (text: string) => [number, number][]) => { + events.push('registerCharacterJoiner') + registeredJoinHandler = handler + return 3 + }), + deregisterCharacterJoiner: vi.fn((joinerId: number) => { + events.push(`deregisterCharacterJoiner:${joinerId}`) + }), unicode: unicodeProxy, buffer: { active: { cursorX: 0, cursorY: 0 } } } as unknown as ManagedPaneInternal['terminal'] @@ -525,26 +536,70 @@ describe('openTerminal — Unicode 11 ordering', () => { debugLabel: null } - try { - openTerminal(pane) + return { pane, events, getRegisteredJoinHandler: () => registeredJoinHandler } + } - expect(fakePaneContainer.appendChild).toHaveBeenCalledWith(fakeTooltip) - expect(fakeXtermContainer.appendChild).not.toHaveBeenCalled() - expect(fakeTerminalElement.appendChild).not.toHaveBeenCalled() - expect(events).toContain('loadAddon:unicode11') - expect(events).toContain('activeVersion=11') + // Why: CJK / emoji / ZWJ widths get baked into the buffer at the active + // unicode version on write. If anything writes bytes through xterm before + // unicode v11 is activated (still on default v6 width tables), wide chars + // lay out as single cells. The bug surfaces as the broken `?`-style glyphs + // users saw on worktree switch. + it('activates unicode 11 before any caller-driven write would be possible', () => { + const { pane, events } = createOpenTerminalHarness() - const unicodeIdx = events.indexOf('activeVersion=11') - const writeIdx = events.indexOf('write') - if (writeIdx !== -1) { - expect(unicodeIdx).toBeLessThan(writeIdx) - } + openTerminal(pane) - const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') - expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) - expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) - } finally { - vi.unstubAllGlobals() + expect(pane.container.appendChild).toHaveBeenCalledWith(pane.linkTooltip) + expect(pane.xtermContainer.appendChild).not.toHaveBeenCalled() + expect(pane.terminal.element!.appendChild).not.toHaveBeenCalled() + expect(events).toContain('loadAddon:unicode11') + expect(events).toContain('activeVersion=11') + + const unicodeIdx = events.indexOf('activeVersion=11') + const writeIdx = events.indexOf('write') + if (writeIdx !== -1) { + expect(unicodeIdx).toBeLessThan(writeIdx) } + + const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') + expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) + expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) + }) + + // Why: terminal.dispose() does not deregister character joiners, so the + // pane lifecycle must — this locks the register/deregister pairing that + // makes Arabic/RTL shaping (#5262) actually reach a real terminal. + it('registers the Arabic shaping joiner on open and deregisters it on dispose', () => { + const { pane, events } = createOpenTerminalHarness() + + openTerminal(pane) + + expect(events).toContain('registerCharacterJoiner') + expect(events.indexOf('open')).toBeLessThan(events.indexOf('registerCharacterJoiner')) + expect(pane.arabicShapingJoinerCleanup).toBeTypeOf('function') + + disposePane(pane, new Map([[pane.id, pane]])) + + expect(events).toContain('deregisterCharacterJoiner:3') + expect(pane.arabicShapingJoinerCleanup).toBeNull() + }) + + // Why: the DOM renderer misrenders joined spans (per-character + // letter-spacing blowout), so the joiner must only join while this pane's + // WebGL addon is live — locked here against the real openTerminal wiring. + it('joins RTL runs only while the pane has a live WebGL addon', () => { + const { pane, getRegisteredJoinHandler } = createOpenTerminalHarness() + + openTerminal(pane) + const handler = getRegisteredJoinHandler()! + + expect(pane.webglAddon).toBeNull() + expect(handler('مرحبا')).toEqual([]) + + pane.webglAddon = {} as never + expect(handler('مرحبا')).toEqual([[0, 5]]) + + pane.webglAddon = null + expect(handler('مرحبا')).toEqual([]) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index c6727c3c1..c984508d4 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -17,6 +17,7 @@ import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' +import { registerArabicShapingJoiner } from './terminal-arabic-shaping-joiner' import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' // --------------------------------------------------------------------------- @@ -72,6 +73,16 @@ export function openTerminal(pane: ManagedPaneInternal): void { // so the activation must stay at this position. activateOrcaTerminalUnicodeProvider(terminal) + // Why: without run-joining, Arabic/Hebrew output renders as disconnected + // letters in reversed order (#5262). Registered up front so restored + // scrollback and reattach replays shape correctly, not just live output. + // Joining tracks the live WebGL renderer: the DOM fallback misrenders + // joined spans (see registerArabicShapingJoiner), so it stays per-cell. + pane.arabicShapingJoinerCleanup = registerArabicShapingJoiner( + terminal, + () => pane.webglAddon != null + ) + // Why: the OS reads the focused textarea's screen rect at compositionstart to // decide where to display the IME candidate window. xterm positions that // textarea from its own cursor, which can be stale or intentionally hidden by @@ -224,6 +235,13 @@ export function disposePane( pane.focusClassSyncCleanup = null pane.terminalScrollIntentDisposable?.dispose() pane.terminalScrollIntentDisposable = null + // Deregister the RTL shaping joiner: terminal.dispose() below does not. + try { + pane.arabicShapingJoinerCleanup?.() + } catch { + /* ignore */ + } + pane.arabicShapingJoinerCleanup = null if (pane.compositionHandler) { pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler) pane.terminal.element?.removeEventListener('compositionupdate', pane.compositionHandler) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 1ab542bf7..8218de5e8 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -161,6 +161,9 @@ export type ManagedPaneInternal = { focusClassSyncCleanup?: (() => void) | null // Stored so disposePane() can remove user-scroll intent listeners. terminalScrollIntentDisposable?: IDisposable | null + // Stored so disposePane() can deregister the joiner; terminal.dispose() + // does not remove registered character joiners. + arabicShapingJoinerCleanup?: (() => void) | null // Why: splitPane reparents DOM; its delayed restore owns scroll until the // browser settles, so intermediate fits must not compete with it. pendingSplitScrollState: ScrollState | null 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 new file mode 100644 index 000000000..6bed3d7c0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest' + +import { + findRtlJoinRanges, + isStrongRtlCodePoint, + registerArabicShapingJoiner +} from './terminal-arabic-shaping-joiner' + +describe('isStrongRtlCodePoint', () => { + it('classifies Arabic and Hebrew letters as strong RTL', () => { + expect(isStrongRtlCodePoint('م'.codePointAt(0)!)).toBe(true) + expect(isStrongRtlCodePoint('ش'.codePointAt(0)!)).toBe(true) + expect(isStrongRtlCodePoint('א'.codePointAt(0)!)).toBe(true) + // Arabic presentation forms (legacy shaped codepoints). + expect(isStrongRtlCodePoint(0xfe8d)).toBe(true) + // Adlam (supplementary plane). + expect(isStrongRtlCodePoint(0x1e900)).toBe(true) + }) + + it('does not classify Latin, box drawing, CJK, or emoji as RTL', () => { + expect(isStrongRtlCodePoint('a'.codePointAt(0)!)).toBe(false) + expect(isStrongRtlCodePoint('│'.codePointAt(0)!)).toBe(false) + expect(isStrongRtlCodePoint('漢'.codePointAt(0)!)).toBe(false) + expect(isStrongRtlCodePoint(0x1f600)).toBe(false) + }) +}) + +describe('findRtlJoinRanges', () => { + it('returns no ranges for plain ASCII text', () => { + expect(findRtlJoinRanges('ls -la | grep foo && echo done')).toEqual([]) + }) + + it('returns no ranges for Latin-1/Cyrillic/Greek text below the RTL floor', () => { + expect(findRtlJoinRanges('café привет αβγ')).toEqual([]) + }) + + it('returns a fresh array on every call so xterm can merge into it safely', () => { + const first = findRtlJoinRanges('plain') + const second = findRtlJoinRanges('plain') + expect(first).not.toBe(second) + }) + + it('joins a single Arabic word as one range', () => { + const text = 'مرحبا' + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('joins a multi-word Arabic phrase across spaces as one range', () => { + const text = 'مرحباً هذه مشكلة في اللغة العربية' + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('excludes leading and trailing neutrals from the range', () => { + const text = ' مرحبا هذه ' + expect(findRtlJoinRanges(text)).toEqual([[2, 11]]) + }) + + it('stops the run at strong LTR words', () => { + const text = 'مرحبا hello' + expect(findRtlJoinRanges(text)).toEqual([[0, 5]]) + }) + + it('does not pull an adjacent filename into the run', () => { + const text = 'ملف test.txt' + expect(findRtlJoinRanges(text)).toEqual([[0, 3]]) + }) + + it('treats box-drawing characters as run breakers so TUI borders stay per-cell', () => { + const text = '│ مرحبا بكم │' + expect(findRtlJoinRanges(text)).toEqual([[2, 11]]) + }) + + it('produces separate ranges for RTL runs split by LTR text', () => { + const text = 'اهلا and שלום' + expect(findRtlJoinRanges(text)).toEqual([ + [0, 4], + [9, 13] + ]) + }) + + it('skips an isolated single RTL letter (already correct in isolated form)', () => { + expect(findRtlJoinRanges('a م b')).toEqual([]) + }) + + it('joins a letter with its combining tashkeel marks', () => { + const text = 'مَ' + expect(findRtlJoinRanges(text)).toEqual([[0, 2]]) + }) + + it('tunnels through ASCII digits between Arabic words', () => { + const text = 'صفحة 15 من 20 صفحة' + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('does not extend a run through trailing digits without a following RTL char', () => { + const text = 'صفحة 15' + expect(findRtlJoinRanges(text)).toEqual([[0, 4]]) + }) + + it('joins Arabic-Indic digits and Arabic punctuation as part of the run', () => { + const text = 'رقم ١٢٣، حسناً؟' + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('handles supplementary-plane RTL (Adlam) via surrogate pairs', () => { + const text = '𞤀𞤣𞤤𞤢𞤥' + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('breaks runs on CJK and emoji above the scan floor', () => { + const text = 'مرحبا漢بكم' + expect(findRtlJoinRanges(text)).toEqual([ + [0, 5], + [6, 9] + ]) + }) + + it('joins Hebrew words with niqqud points', () => { + const text = 'שָׁלוֹם עוֹלָם' + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + // Escapes, not literals: these controls are invisible in source. + const ZWNJ = '\u200c' + const ZWJ = '\u200d' + const RLM = '\u200f' + const LRM = '\u200e' + + it('tunnels through ZWNJ inside a Persian word without splitting the run', () => { + // می‌خواهم — splitting at the ZWNJ would render the word halves in + // swapped visual order. + const text = `می${ZWNJ}خواهم` + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('tunnels through ZWJ and RLM inside an RTL run', () => { + const zwjText = `مر${ZWJ}حب` + expect(findRtlJoinRanges(zwjText)).toEqual([[0, zwjText.length]]) + const rlmText = `سلام${RLM}عليكم` + expect(findRtlJoinRanges(rlmText)).toEqual([[0, rlmText.length]]) + }) + + it('excludes a trailing ZWNJ from the joined range', () => { + expect(findRtlJoinRanges(`مرحبا${ZWNJ}`)).toEqual([[0, 5]]) + }) + + it('does not let ZWNJ start a run or bridge into LTR text', () => { + expect(findRtlJoinRanges(`${ZWNJ}abc`)).toEqual([]) + expect(findRtlJoinRanges(`مرحبا${ZWNJ}abc`)).toEqual([[0, 5]]) + }) + + it('still breaks the run on LRM (strong LTR)', () => { + expect(findRtlJoinRanges(`مرحبا${LRM}بكم`)).toEqual([ + [0, 5], + [6, 9] + ]) + }) + + it('treats ALM as transparent like RLM', () => { + const ALM = '\u061c' + expect(findRtlJoinRanges(`${ALM}${ALM}`)).toEqual([]) + // An isolated letter stays isolated even with a leading direction mark. + expect(findRtlJoinRanges(`${ALM}م`)).toEqual([]) + expect(findRtlJoinRanges(`مرحبا${ALM}`)).toEqual([[0, 5]]) + const text = `مرحبا${ALM}بكم` + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('does not open a run at orphan combining marks after an LTR base', () => { + // Marks render inside their base's cell; a run opened mid-cell maps to an + // empty joined cell range that blanks the following glyph in WebGL. + expect(findRtlJoinRanges('a\u064b\u0651b')).toEqual([]) + expect(findRtlJoinRanges('x\u05b0\u05b1y')).toEqual([]) + }) + + it('lets combining marks extend a run opened by a spacing RTL letter', () => { + const text = 'منَّ هنا' + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) + + it('does not open a run at zero-width Cf controls after an LTR base', () => { + // BOM/ZWNBSP and Arabic number signs are width-0 in xterm, so like + // combining marks a run opened there maps to an empty joined cell range + // that blanks the following glyph in WebGL. + const BOM = '\ufeff' + expect(findRtlJoinRanges(`a${BOM}${BOM}b`)).toEqual([]) + expect(findRtlJoinRanges('x\u0600\u0602y')).toEqual([]) + expect(findRtlJoinRanges(`a${BOM}\u0651b`)).toEqual([]) + }) + + it('keeps zero-width Cf controls from opening or counting an RTL run', () => { + const BOM = '\ufeff' + // A single letter behind a BOM stays isolated (BOM neither opens nor counts). + expect(findRtlJoinRanges(`${BOM}م`)).toEqual([]) + // BOM inside a word does not break the run. + const text = `مرحبا${BOM}بكم` + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) +}) + +describe('registerArabicShapingJoiner', () => { + function createJoinerHost(): { + terminal: Parameters[0] + getRegistered: () => ((text: string) => [number, number][]) | null + getDeregistered: () => number | null + } { + let registered: ((text: string) => [number, number][]) | null = null + let deregistered: number | null = null + return { + terminal: { + registerCharacterJoiner(handler: (text: string) => [number, number][]): number { + registered = handler + return 7 + }, + deregisterCharacterJoiner(joinerId: number): void { + deregistered = joinerId + } + }, + getRegistered: () => registered, + getDeregistered: () => deregistered + } + } + + it('registers a joining handler and returns a cleanup that deregisters it', () => { + const host = createJoinerHost() + const cleanup = registerArabicShapingJoiner(host.terminal, () => true) + const text = 'مرحبا' + expect(host.getRegistered()!(text)).toEqual([[0, text.length]]) + expect(host.getDeregistered()).toBeNull() + + // terminal.dispose() does not deregister joiners, so cleanup must. + cleanup() + expect(host.getDeregistered()).toBe(7) + }) + + it('returns no ranges while shaping is inactive (DOM renderer misrenders joined spans)', () => { + const host = createJoinerHost() + let webglLive = false + registerArabicShapingJoiner(host.terminal, () => webglLive) + const handler = host.getRegistered()! + + const inactive = handler('مرحبا') + expect(inactive).toEqual([]) + // Fresh array each call — xterm mutates the handler's result in place. + expect(handler('مرحبا')).not.toBe(inactive) + + webglLive = true + expect(handler('مرحبا')).toEqual([[0, 5]]) + }) +}) 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 new file mode 100644 index 000000000..5b9b5f8dc --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts @@ -0,0 +1,187 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm draws every cell's glyph in isolation, so Arabic output shows +// disconnected letterforms in logical (reversed) order — upstream has no +// BiDi/shaping support (xtermjs/xterm.js#701, Orca #5262). Joining each RTL +// run into one cell range makes both renderers (WebGL atlas, DOM row factory) +// draw the run as a single string, letting the browser apply contextual +// shaping and BiDi ordering inside the run's grid-aligned cell box. The +// terminal buffer and PTY stream are untouched, and xterm itself un-joins any +// range that holds the cursor or a partially selected span, so cursor +// visibility and selection stay cell-accurate. + +// Every strong-RTL script block sits at or above U+0590, so plain ASCII/Latin +// segments bail out with a single charCodeAt sweep and no per-char decode. +const RTL_SCAN_FLOOR = 0x0590 + +export function isStrongRtlCodePoint(codePoint: number): boolean { + return ( + // Hebrew, Arabic, Syriac, Arabic Sup, Thaana, NKo, Samaritan, Mandaic, + // Syriac Sup, Arabic Extended-B/A — one contiguous strong-RTL span. + (codePoint >= 0x0590 && codePoint <= 0x08ff) || + // Hebrew + Arabic presentation forms (legacy shaped codepoints). + (codePoint >= 0xfb1d && codePoint <= 0xfdff) || + (codePoint >= 0xfe70 && codePoint <= 0xfeff) || + // Historic RTL scripts (Phoenician, Nabataean, …). + (codePoint >= 0x10800 && codePoint <= 0x10fff) || + // Mende Kikakui, Adlam, Arabic Mathematical symbols. + (codePoint >= 0x1e800 && codePoint <= 0x1eeff) + ) +} + +// Neutral characters may sit inside an RTL run (so a multi-word phrase joins +// as one unit and keeps right-to-left word order) but never start or end one: +// ASCII space/digits/punctuation and NBSP. ASCII letters are strong LTR and +// always break a run so paths like `test.txt` never get pulled into one. +function isRunNeutralCharCode(charCode: number): boolean { + if (charCode < 0x20) { + return false + } + if (charCode <= 0x7e) { + const isAsciiLetter = + (charCode >= 0x41 && charCode <= 0x5a) || (charCode >= 0x61 && charCode <= 0x7a) + return !isAsciiLetter + } + return charCode === 0xa0 +} + +// ZWNJ/ZWJ shape within a word (mandatory in Persian/Kurdish orthography) and +// RLM/ALM assert RTL context — breaking the run on them would split one word +// into two joined chunks laid out in swapped visual order. Transparent: never +// opens, closes, extends, or counts toward a run. LRM (U+200E) is strong LTR +// and intentionally still breaks the run. +// The zero-width Cf controls inside the RTL blocks (Arabic number signs +// U+0600–0605, end of ayah U+06DD, Syriac abbreviation mark U+070F, disputed +// end of ayah U+08E2) and BOM/ZWNBSP U+FEFF are width-0 in xterm, so like +// combining marks they must never open or count a run (an empty joined cell +// range blanks the following glyph in WebGL) — and they have no shape to join. +function isRtlRunTransparentCodePoint(codePoint: number): boolean { + return ( + codePoint === 0x200c || + codePoint === 0x200d || + codePoint === 0x200f || + codePoint === 0x061c || + (codePoint >= 0x0600 && codePoint <= 0x0605) || + codePoint === 0x06dd || + codePoint === 0x070f || + codePoint === 0x08e2 || + codePoint === 0xfeff + ) +} + +// Why: a combining mark renders inside its base's cell, so a run opened at a +// mark starts mid-cell — xterm rounds that to an empty joined cell range and +// the WebGL renderer then draws an empty glyph over the next character. Marks +// may extend and count only after a spacing RTL letter opened the run. +const COMBINING_MARK = /\p{Mn}/u +function canOpenRtlRun(codePoint: number): boolean { + return !COMBINING_MARK.test(String.fromCodePoint(codePoint)) +} + +/** + * Character-joiner handler for xterm's registerCharacterJoiner API. Receives + * one attribute-homogeneous segment of a row and returns [start, end) string + * ranges that should render as single joined units. + * + * A run spans from the first strong-RTL code point to the last one of a + * cluster, tunneling through neutral characters between RTL words. Runs with + * fewer than two RTL code points are skipped: an isolated Arabic letter + * already renders in its correct (isolated) form cell-by-cell. + * + * Known upstream limitation (affects every joiner, ligatures too): a + * standalone width-0 cell (e.g. RLM at line start) makes xterm's + * CharacterJoinerService skip the cell without advancing its string index, + * shifting the run's cell range by one — fix belongs upstream in xterm.js. + * + * Known upstream limitation #2: the WebGL renderer un-joins a range for the + * cursor or a partial selection but not for decorations, so a search-match + * highlight inside a joined run renders all-or-nothing — the whole run when + * the match covers the run's first cell, otherwise not at all. Same class as + * ligatures today, extended here to phrase-length runs. + */ +export function findRtlJoinRanges(text: string): [number, number][] { + const length = text.length + let i = 0 + for (; i < length; i++) { + if (text.charCodeAt(i) >= RTL_SCAN_FLOOR) { + break + } + } + // Why: xterm merges other joiners' results into the returned array in + // place, so this must be a fresh array on every call — never a shared + // constant. The non-RTL fast path above keeps the allocation the only cost. + const ranges: [number, number][] = [] + if (i === length) { + return ranges + } + + let runStart = -1 + let runEnd = -1 + let runRtlCount = 0 + const closeRun = (): void => { + if (runStart !== -1 && runRtlCount >= 2) { + ranges.push([runStart, runEnd]) + } + runStart = -1 + runRtlCount = 0 + } + + for (; i < length; i++) { + const unit = text.charCodeAt(i) + if (unit < RTL_SCAN_FLOOR) { + if (runStart !== -1 && !isRunNeutralCharCode(unit)) { + closeRun() + } + continue + } + let codePoint = unit + let codeUnitLength = 1 + if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < length) { + const low = text.charCodeAt(i + 1) + if (low >= 0xdc00 && low <= 0xdfff) { + codePoint = (unit - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000 + codeUnitLength = 2 + } + } + if (isRtlRunTransparentCodePoint(codePoint)) { + // Run-transparent format controls: skip without opening or closing. + } else if (isStrongRtlCodePoint(codePoint)) { + if (runStart !== -1 || canOpenRtlRun(codePoint)) { + if (runStart === -1) { + runStart = i + } + runEnd = i + codeUnitLength + runRtlCount++ + } + } else if (runStart !== -1) { + // Non-RTL above the floor (box drawing, CJK, emoji, …) breaks the run + // so TUI borders and East Asian text keep per-cell rendering. + closeRun() + } + i += codeUnitLength - 1 + } + closeRun() + return ranges +} + +/** Register the RTL shaping joiner on a terminal. Returns a cleanup that + * deregisters the joiner — `Terminal.dispose()` does not remove registered + * character joiners, so disposePane() must call this to avoid leaking the + * registration (xtermjs/xterm.js#3289). */ +export function registerArabicShapingJoiner( + terminal: Pick, + isShapingActive: () => boolean +): () => void { + // Why: the DOM renderer sizes a joined span with one letter-spacing value + // that the browser applies after every character, so a joined run whose + // shaped width differs from its cell budget blows out the whole row's grid + // alignment. Join only while the WebGL renderer is live (checked per render + // call, so context-loss/GPU-setting fallbacks revert to per-cell rendering + // on their own refresh); DOM-rendered panes keep xterm's unshaped default. + const joinerId = terminal.registerCharacterJoiner((text) => + isShapingActive() ? findRtlJoinRanges(text) : [] + ) + return () => { + terminal.deregisterCharacterJoiner(joinerId) + } +}