highlight.go 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. package viewport
  2. import (
  3. "github.com/charmbracelet/lipgloss/v2"
  4. "github.com/charmbracelet/x/ansi"
  5. "github.com/rivo/uniseg"
  6. )
  7. // parseMatches converts the given matches into highlight ranges.
  8. //
  9. // Assumptions:
  10. // - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return
  11. // - matches were made against the given content
  12. // - matches are in order
  13. // - matches do not overlap
  14. // - content is line terminated with \n only
  15. //
  16. // We'll then convert the ranges into [highlightInfo]s, which hold the starting
  17. // line and the grapheme positions.
  18. func parseMatches(
  19. content string,
  20. matches [][]int,
  21. ) []highlightInfo {
  22. if len(matches) == 0 {
  23. return nil
  24. }
  25. line := 0
  26. graphemePos := 0
  27. previousLinesOffset := 0
  28. bytePos := 0
  29. highlights := make([]highlightInfo, 0, len(matches))
  30. gr := uniseg.NewGraphemes(ansi.Strip(content))
  31. for _, match := range matches {
  32. byteStart, byteEnd := match[0], match[1]
  33. // hilight for this match:
  34. hi := highlightInfo{
  35. lines: map[int][2]int{},
  36. }
  37. // find the beginning of this byte range, setup current line and
  38. // grapheme position.
  39. for byteStart > bytePos {
  40. if !gr.Next() {
  41. break
  42. }
  43. if content[bytePos] == '\n' {
  44. previousLinesOffset = graphemePos + 1
  45. line++
  46. }
  47. graphemePos += max(1, gr.Width())
  48. bytePos += len(gr.Str())
  49. }
  50. hi.lineStart = line
  51. hi.lineEnd = line
  52. graphemeStart := graphemePos
  53. // loop until we find the end
  54. for byteEnd > bytePos {
  55. if !gr.Next() {
  56. break
  57. }
  58. // if it ends with a new line, add the range, increase line, and continue
  59. if content[bytePos] == '\n' {
  60. colstart := max(0, graphemeStart-previousLinesOffset)
  61. colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself
  62. if colend > colstart {
  63. hi.lines[line] = [2]int{colstart, colend}
  64. hi.lineEnd = line
  65. }
  66. previousLinesOffset = graphemePos + 1
  67. line++
  68. }
  69. graphemePos += max(1, gr.Width())
  70. bytePos += len(gr.Str())
  71. }
  72. // we found it!, add highlight and continue
  73. if bytePos == byteEnd {
  74. colstart := max(0, graphemeStart-previousLinesOffset)
  75. colend := max(graphemePos-previousLinesOffset, colstart)
  76. if colend > colstart {
  77. hi.lines[line] = [2]int{colstart, colend}
  78. hi.lineEnd = line
  79. }
  80. }
  81. highlights = append(highlights, hi)
  82. }
  83. return highlights
  84. }
  85. type highlightInfo struct {
  86. // in which line this highlight starts and ends
  87. lineStart, lineEnd int
  88. // the grapheme highlight ranges for each of these lines
  89. lines map[int][2]int
  90. }
  91. // coords returns the line x column of this highlight.
  92. func (hi highlightInfo) coords() (int, int, int) {
  93. for i := hi.lineStart; i <= hi.lineEnd; i++ {
  94. hl, ok := hi.lines[i]
  95. if !ok {
  96. continue
  97. }
  98. return i, hl[0], hl[1]
  99. }
  100. return hi.lineStart, 0, 0
  101. }
  102. func makeHighlightRanges(
  103. highlights []highlightInfo,
  104. line int,
  105. style lipgloss.Style,
  106. ) []lipgloss.Range {
  107. result := []lipgloss.Range{}
  108. for _, hi := range highlights {
  109. lihi, ok := hi.lines[line]
  110. if !ok {
  111. continue
  112. }
  113. if lihi == [2]int{} {
  114. continue
  115. }
  116. result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style))
  117. }
  118. return result
  119. }