overlay.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. package layout
  2. import (
  3. "fmt"
  4. "regexp"
  5. "strings"
  6. "unicode/utf8"
  7. "github.com/charmbracelet/lipgloss/v2"
  8. "github.com/charmbracelet/lipgloss/v2/compat"
  9. chAnsi "github.com/charmbracelet/x/ansi"
  10. "github.com/muesli/ansi"
  11. "github.com/muesli/reflow/truncate"
  12. "github.com/muesli/termenv"
  13. "github.com/sst/opencode/internal/util"
  14. )
  15. // Split a string into lines, additionally returning the size of the widest line.
  16. func getLines(s string) (lines []string, widest int) {
  17. lines = strings.Split(s, "\n")
  18. for _, l := range lines {
  19. w := ansi.PrintableRuneWidth(l)
  20. if widest < w {
  21. widest = w
  22. }
  23. }
  24. return lines, widest
  25. }
  26. // overlayOptions holds configuration for overlay rendering
  27. type overlayOptions struct {
  28. whitespace *whitespace
  29. border bool
  30. borderColor *compat.AdaptiveColor
  31. }
  32. // OverlayOption sets options for overlay rendering
  33. type OverlayOption func(*overlayOptions)
  34. // PlaceOverlay places fg on top of bg.
  35. func PlaceOverlay(
  36. x, y int,
  37. fg, bg string,
  38. opts ...OverlayOption,
  39. ) string {
  40. fgLines, fgWidth := getLines(fg)
  41. bgLines, bgWidth := getLines(bg)
  42. bgHeight := len(bgLines)
  43. fgHeight := len(fgLines)
  44. // Parse options
  45. options := &overlayOptions{
  46. whitespace: &whitespace{},
  47. }
  48. for _, opt := range opts {
  49. opt(options)
  50. }
  51. // Adjust for borders if enabled
  52. if options.border {
  53. // Add space for left and right borders
  54. adjustedFgWidth := fgWidth + 2
  55. // Adjust placement to account for borders
  56. x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
  57. y = util.Clamp(y, 0, bgHeight-fgHeight)
  58. // Pad all foreground lines to the same width for consistent borders
  59. for i := range fgLines {
  60. lineWidth := ansi.PrintableRuneWidth(fgLines[i])
  61. if lineWidth < fgWidth {
  62. fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
  63. }
  64. }
  65. } else {
  66. if fgWidth >= bgWidth && fgHeight >= bgHeight {
  67. // FIXME: return fg or bg?
  68. return fg
  69. }
  70. // TODO: allow placement outside of the bg box?
  71. x = util.Clamp(x, 0, bgWidth-fgWidth)
  72. y = util.Clamp(y, 0, bgHeight-fgHeight)
  73. }
  74. var b strings.Builder
  75. for i, bgLine := range bgLines {
  76. if i > 0 {
  77. b.WriteByte('\n')
  78. }
  79. if i < y || i >= y+fgHeight {
  80. b.WriteString(bgLine)
  81. continue
  82. }
  83. pos := 0
  84. // Handle left side of the line up to the overlay
  85. if x > 0 {
  86. left := truncate.String(bgLine, uint(x))
  87. pos = ansi.PrintableRuneWidth(left)
  88. b.WriteString(left)
  89. if pos < x {
  90. b.WriteString(options.whitespace.render(x - pos))
  91. pos = x
  92. }
  93. }
  94. // Render the overlay content with optional borders
  95. if options.border {
  96. // Get the foreground line
  97. fgLine := fgLines[i-y]
  98. fgLineWidth := ansi.PrintableRuneWidth(fgLine)
  99. // Extract the styles at the border positions
  100. leftStyle := getStyleAtPosition(bgLine, pos)
  101. rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
  102. // Left border - combine background from original with border foreground
  103. leftSeq := combineStyles(leftStyle, options.borderColor)
  104. if leftSeq != "" {
  105. b.WriteString(leftSeq)
  106. }
  107. b.WriteString("┃")
  108. b.WriteString("\x1b[0m") // Reset all styles
  109. pos++
  110. // Content
  111. b.WriteString(fgLine)
  112. pos += fgLineWidth
  113. // Right border - combine background from original with border foreground
  114. rightSeq := combineStyles(rightStyle, options.borderColor)
  115. if rightSeq != "" {
  116. b.WriteString(rightSeq)
  117. }
  118. b.WriteString("┃")
  119. b.WriteString("\x1b[0m") // Reset all styles
  120. pos++
  121. } else {
  122. // No border, just render the content
  123. fgLine := fgLines[i-y]
  124. b.WriteString(fgLine)
  125. pos += ansi.PrintableRuneWidth(fgLine)
  126. }
  127. // Handle right side of the line after the overlay
  128. right := cutLeft(bgLine, pos)
  129. bgWidth := ansi.PrintableRuneWidth(bgLine)
  130. rightWidth := ansi.PrintableRuneWidth(right)
  131. if rightWidth <= bgWidth-pos {
  132. b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
  133. }
  134. b.WriteString(right)
  135. }
  136. return b.String()
  137. }
  138. // cutLeft cuts printable characters from the left.
  139. // This function is heavily based on muesli's ansi and truncate packages.
  140. func cutLeft(s string, cutWidth int) string {
  141. return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
  142. }
  143. // ansiStyle represents parsed ANSI style attributes
  144. type ansiStyle struct {
  145. fgColor string
  146. bgColor string
  147. attrs []string
  148. }
  149. // parseANSISequence parses an ANSI escape sequence into its components
  150. func parseANSISequence(seq string) ansiStyle {
  151. style := ansiStyle{}
  152. // Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
  153. if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
  154. return style
  155. }
  156. params := seq[2 : len(seq)-1]
  157. if params == "" {
  158. return style
  159. }
  160. parts := strings.Split(params, ";")
  161. i := 0
  162. for i < len(parts) {
  163. switch parts[i] {
  164. case "0": // Reset
  165. style = ansiStyle{}
  166. case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
  167. style.attrs = append(style.attrs, parts[i])
  168. case "38": // Foreground color
  169. if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
  170. // 256 color mode
  171. style.fgColor = strings.Join(parts[i:i+3], ";")
  172. i += 2
  173. } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
  174. // RGB color mode
  175. style.fgColor = strings.Join(parts[i:i+5], ";")
  176. i += 4
  177. }
  178. case "48": // Background color
  179. if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
  180. // 256 color mode
  181. style.bgColor = strings.Join(parts[i:i+3], ";")
  182. i += 2
  183. } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
  184. // RGB color mode
  185. style.bgColor = strings.Join(parts[i:i+5], ";")
  186. i += 4
  187. }
  188. case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
  189. style.fgColor = parts[i]
  190. case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
  191. style.bgColor = parts[i]
  192. case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
  193. style.fgColor = parts[i]
  194. case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
  195. style.bgColor = parts[i]
  196. }
  197. i++
  198. }
  199. return style
  200. }
  201. // combineStyles creates an ANSI sequence that combines background from one style with foreground from another
  202. func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
  203. if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
  204. return ""
  205. }
  206. var parts []string
  207. // Add attributes
  208. parts = append(parts, bgStyle.attrs...)
  209. // Add background color from the original style
  210. if bgStyle.bgColor != "" {
  211. parts = append(parts, bgStyle.bgColor)
  212. }
  213. // Add foreground color if specified
  214. if fgColor != nil {
  215. // Use the light color (could be improved to detect terminal background)
  216. color := (*fgColor).Light
  217. // Use RGBA to get color components
  218. r, g, b, _ := color.RGBA()
  219. // RGBA returns 16-bit values, we need 8-bit
  220. parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
  221. }
  222. if len(parts) == 0 {
  223. return ""
  224. }
  225. return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
  226. }
  227. // getStyleAtPosition extracts the active ANSI style at a given visual position
  228. func getStyleAtPosition(s string, targetPos int) ansiStyle {
  229. // ANSI escape sequence regex
  230. ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
  231. visualPos := 0
  232. currentStyle := ansiStyle{}
  233. i := 0
  234. for i < len(s) && visualPos <= targetPos {
  235. // Check if we're at an ANSI escape sequence
  236. if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
  237. // Found an ANSI sequence at current position
  238. seq := s[i : i+match[1]]
  239. parsedStyle := parseANSISequence(seq)
  240. // Update current style (merge with existing)
  241. if parsedStyle.fgColor != "" {
  242. currentStyle.fgColor = parsedStyle.fgColor
  243. }
  244. if parsedStyle.bgColor != "" {
  245. currentStyle.bgColor = parsedStyle.bgColor
  246. }
  247. if len(parsedStyle.attrs) > 0 {
  248. currentStyle.attrs = parsedStyle.attrs
  249. }
  250. i += match[1]
  251. } else if i < len(s) {
  252. // Regular character
  253. if visualPos == targetPos {
  254. return currentStyle
  255. }
  256. _, size := utf8.DecodeRuneInString(s[i:])
  257. i += size
  258. visualPos++
  259. }
  260. }
  261. return currentStyle
  262. }
  263. type whitespace struct {
  264. style termenv.Style
  265. chars string
  266. }
  267. // Render whitespaces.
  268. func (w whitespace) render(width int) string {
  269. if w.chars == "" {
  270. w.chars = " "
  271. }
  272. r := []rune(w.chars)
  273. j := 0
  274. b := strings.Builder{}
  275. // Cycle through runes and print them into the whitespace.
  276. for i := 0; i < width; {
  277. b.WriteRune(r[j])
  278. j++
  279. if j >= len(r) {
  280. j = 0
  281. }
  282. i += ansi.PrintableRuneWidth(string(r[j]))
  283. }
  284. // Fill any extra gaps white spaces. This might be necessary if any runes
  285. // are more than one cell wide, which could leave a one-rune gap.
  286. short := width - ansi.PrintableRuneWidth(b.String())
  287. if short > 0 {
  288. b.WriteString(strings.Repeat(" ", short))
  289. }
  290. return w.style.Styled(b.String())
  291. }
  292. // WhitespaceOption sets a styling rule for rendering whitespace.
  293. type WhitespaceOption func(*whitespace)
  294. // WithWhitespace sets whitespace options for the overlay
  295. func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
  296. return func(o *overlayOptions) {
  297. for _, opt := range opts {
  298. opt(o.whitespace)
  299. }
  300. }
  301. }
  302. // WithOverlayBorder enables border rendering for the overlay
  303. func WithOverlayBorder() OverlayOption {
  304. return func(o *overlayOptions) {
  305. o.border = true
  306. }
  307. }
  308. // WithOverlayBorderColor sets the border color for the overlay
  309. func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
  310. return func(o *overlayOptions) {
  311. o.borderColor = &color
  312. }
  313. }