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