textHighlighting.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import {
  2. type AnsiCode,
  3. ansiCodesToString,
  4. reduceAnsiCodes,
  5. type Token,
  6. tokenize,
  7. undoAnsiCodes,
  8. } from '@alcalzone/ansi-tokenize'
  9. import type { Theme } from './theme.js'
  10. export type TextHighlight = {
  11. start: number
  12. end: number
  13. color: keyof Theme | undefined
  14. dimColor?: boolean
  15. inverse?: boolean
  16. shimmerColor?: keyof Theme
  17. priority: number
  18. }
  19. export type TextSegment = {
  20. text: string
  21. start: number
  22. highlight?: TextHighlight
  23. }
  24. export function segmentTextByHighlights(
  25. text: string,
  26. highlights: TextHighlight[],
  27. ): TextSegment[] {
  28. if (highlights.length === 0) {
  29. return [{ text, start: 0 }]
  30. }
  31. const sortedHighlights = [...highlights].sort((a, b) => {
  32. if (a.start !== b.start) return a.start - b.start
  33. return b.priority - a.priority
  34. })
  35. const resolvedHighlights: TextHighlight[] = []
  36. const usedRanges: Array<{ start: number; end: number }> = []
  37. for (const highlight of sortedHighlights) {
  38. if (highlight.start === highlight.end) continue
  39. const overlaps = usedRanges.some(
  40. range =>
  41. (highlight.start >= range.start && highlight.start < range.end) ||
  42. (highlight.end > range.start && highlight.end <= range.end) ||
  43. (highlight.start <= range.start && highlight.end >= range.end),
  44. )
  45. if (!overlaps) {
  46. resolvedHighlights.push(highlight)
  47. usedRanges.push({ start: highlight.start, end: highlight.end })
  48. }
  49. }
  50. return new HighlightSegmenter(text).segment(resolvedHighlights)
  51. }
  52. class HighlightSegmenter {
  53. private readonly tokens: Token[]
  54. // Two position systems: "visible" (what the user sees, excluding ANSI codes)
  55. // and "string" (raw positions including ANSI codes for substring extraction)
  56. private visiblePos = 0
  57. private stringPos = 0
  58. private tokenIdx = 0
  59. private charIdx = 0 // offset within current text token (for partial consumption)
  60. private codes: AnsiCode[] = []
  61. constructor(private readonly text: string) {
  62. this.tokens = tokenize(text)
  63. }
  64. segment(highlights: TextHighlight[]): TextSegment[] {
  65. const segments: TextSegment[] = []
  66. for (const highlight of highlights) {
  67. const before = this.segmentTo(highlight.start)
  68. if (before) segments.push(before)
  69. const highlighted = this.segmentTo(highlight.end)
  70. if (highlighted) {
  71. highlighted.highlight = highlight
  72. segments.push(highlighted)
  73. }
  74. }
  75. const after = this.segmentTo(Infinity)
  76. if (after) segments.push(after)
  77. return segments
  78. }
  79. private segmentTo(targetVisiblePos: number): TextSegment | null {
  80. if (
  81. this.tokenIdx >= this.tokens.length ||
  82. targetVisiblePos <= this.visiblePos
  83. ) {
  84. return null
  85. }
  86. const visibleStart = this.visiblePos
  87. // Consume leading ANSI codes before first visible char
  88. while (this.tokenIdx < this.tokens.length) {
  89. const token = this.tokens[this.tokenIdx]!
  90. if (token.type !== 'ansi') break
  91. this.codes.push(token)
  92. this.stringPos += token.code.length
  93. this.tokenIdx++
  94. }
  95. const stringStart = this.stringPos
  96. const codesStart = [...this.codes]
  97. // Advance through tokens until we reach target
  98. while (
  99. this.visiblePos < targetVisiblePos &&
  100. this.tokenIdx < this.tokens.length
  101. ) {
  102. const token = this.tokens[this.tokenIdx]!
  103. if (token.type === 'ansi') {
  104. this.codes.push(token)
  105. this.stringPos += token.code.length
  106. this.tokenIdx++
  107. } else {
  108. const charsNeeded = targetVisiblePos - this.visiblePos
  109. const charsAvailable = (token as any).value.length - this.charIdx
  110. const charsToTake = Math.min(charsNeeded, charsAvailable)
  111. this.stringPos += charsToTake
  112. this.visiblePos += charsToTake
  113. this.charIdx += charsToTake
  114. if (this.charIdx >= (token as any).value.length) {
  115. this.tokenIdx++
  116. this.charIdx = 0
  117. }
  118. }
  119. }
  120. // Empty segment (can occur when only trailing ANSI codes remain)
  121. if (this.stringPos === stringStart) {
  122. return null
  123. }
  124. const prefixCodes = reduceCodes(codesStart)
  125. const suffixCodes = reduceCodes(this.codes)
  126. this.codes = suffixCodes
  127. const prefix = ansiCodesToString(prefixCodes)
  128. const suffix = ansiCodesToString(undoAnsiCodes(suffixCodes))
  129. return {
  130. text: prefix + this.text.substring(stringStart, this.stringPos) + suffix,
  131. start: visibleStart,
  132. }
  133. }
  134. }
  135. function reduceCodes(codes: AnsiCode[]): AnsiCode[] {
  136. return reduceAnsiCodes(codes).filter(c => c.code !== c.endCode)
  137. }