File size: 4,290 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | /**
* Bidirectional text reordering for terminal rendering.
*
* Terminals on Windows do not implement the Unicode Bidi Algorithm,
* so RTL text (Hebrew, Arabic, etc.) appears reversed. This module
* applies the bidi algorithm to reorder ClusteredChar arrays from
* logical order to visual order before Ink's LTR cell placement loop.
*
* On macOS terminals (Terminal.app, iTerm2) bidi works natively.
* Windows Terminal (including WSL) does not implement bidi
* (https://github.com/microsoft/terminal/issues/538).
*
* Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost
* also lacks bidi. We enable bidi reordering when running on Windows or
* inside Windows Terminal (covers WSL).
*/
import bidiFactory from 'bidi-js'
type ClusteredChar = {
value: string
width: number
styleId: number
hyperlink: string | undefined
}
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
let needsSoftwareBidi: boolean | undefined
function needsBidi(): boolean {
if (needsSoftwareBidi === undefined) {
needsSoftwareBidi =
process.platform === 'win32' ||
typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal
process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js)
}
return needsSoftwareBidi
}
function getBidi() {
if (!bidiInstance) {
bidiInstance = bidiFactory()
}
return bidiInstance
}
/**
* Reorder an array of ClusteredChars from logical order to visual order
* using the Unicode Bidi Algorithm. Active on terminals that lack native
* bidi support (Windows Terminal, conhost, WSL).
*
* Returns the same array on bidi-capable terminals (no-op).
*/
export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] {
if (!needsBidi() || characters.length === 0) {
return characters
}
// Build a plain string from the clustered chars to run through bidi
const plainText = characters.map(c => c.value).join('')
// Check if there are any RTL characters — skip bidi if pure LTR
if (!hasRTLCharacters(plainText)) {
return characters
}
const bidi = getBidi()
const { levels } = bidi.getEmbeddingLevels(plainText, 'auto')
// Map bidi levels back to ClusteredChar indices.
// Each ClusteredChar may be multiple code units in the joined string.
const charLevels: number[] = []
let offset = 0
for (let i = 0; i < characters.length; i++) {
charLevels.push(levels[offset]!)
offset += characters[i]!.value.length
}
// Get reorder segments from bidi-js, but we need to work at the
// ClusteredChar level, not the string level. We'll implement the
// standard bidi reordering: find the max level, then for each level
// from max down to 1, reverse all contiguous runs >= that level.
const reordered = [...characters]
const maxLevel = Math.max(...charLevels)
for (let level = maxLevel; level >= 1; level--) {
let i = 0
while (i < reordered.length) {
if (charLevels[i]! >= level) {
// Find the end of this run
let j = i + 1
while (j < reordered.length && charLevels[j]! >= level) {
j++
}
// Reverse the run in both arrays
reverseRange(reordered, i, j - 1)
reverseRangeNumbers(charLevels, i, j - 1)
i = j
} else {
i++
}
}
}
return reordered
}
function reverseRange<T>(arr: T[], start: number, end: number): void {
while (start < end) {
const temp = arr[start]!
arr[start] = arr[end]!
arr[end] = temp
start++
end--
}
}
function reverseRangeNumbers(arr: number[], start: number, end: number): void {
while (start < end) {
const temp = arr[start]!
arr[start] = arr[end]!
arr[end] = temp
start++
end--
}
}
/**
* Quick check for RTL characters (Hebrew, Arabic, and related scripts).
* Avoids running the full bidi algorithm on pure-LTR text.
*/
function hasRTLCharacters(text: string): boolean {
// Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F
// Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF
// Thaana: U+0780-U+07BF
// Syriac: U+0700-U+074F
return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test(
text,
)
}
|