bidi.ts 4.2 KB

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