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) + } +}