overlay.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  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. // We need to get the style just before the border position to preserve background
  101. leftStyle := ansiStyle{}
  102. if pos > 0 {
  103. leftStyle = getStyleAtPosition(bgLine, pos-1)
  104. } else {
  105. leftStyle = getStyleAtPosition(bgLine, pos)
  106. }
  107. rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
  108. // Left border - combine background from original with border foreground
  109. leftSeq := combineStyles(leftStyle, options.borderColor)
  110. if leftSeq != "" {
  111. b.WriteString(leftSeq)
  112. }
  113. b.WriteString("┃")
  114. if leftSeq != "" {
  115. b.WriteString("\x1b[0m") // Reset all styles only if we applied any
  116. }
  117. pos++
  118. // Content
  119. b.WriteString(fgLine)
  120. pos += fgLineWidth
  121. // Right border - combine background from original with border foreground
  122. rightSeq := combineStyles(rightStyle, options.borderColor)
  123. if rightSeq != "" {
  124. b.WriteString(rightSeq)
  125. }
  126. b.WriteString("┃")
  127. if rightSeq != "" {
  128. b.WriteString("\x1b[0m") // Reset all styles only if we applied any
  129. }
  130. pos++
  131. } else {
  132. // No border, just render the content
  133. fgLine := fgLines[i-y]
  134. b.WriteString(fgLine)
  135. pos += ansi.PrintableRuneWidth(fgLine)
  136. }
  137. // Handle right side of the line after the overlay
  138. right := cutLeft(bgLine, pos)
  139. bgWidth := ansi.PrintableRuneWidth(bgLine)
  140. rightWidth := ansi.PrintableRuneWidth(right)
  141. if rightWidth <= bgWidth-pos {
  142. b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
  143. }
  144. b.WriteString(right)
  145. }
  146. return b.String()
  147. }
  148. // cutLeft cuts printable characters from the left.
  149. // This function is heavily based on muesli's ansi and truncate packages.
  150. func cutLeft(s string, cutWidth int) string {
  151. return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
  152. }
  153. // ansiStyle represents parsed ANSI style attributes
  154. type ansiStyle struct {
  155. fgColor string
  156. bgColor string
  157. attrs []string
  158. }
  159. // parseANSISequence parses an ANSI escape sequence into its components
  160. func parseANSISequence(seq string) ansiStyle {
  161. style := ansiStyle{}
  162. // Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
  163. if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
  164. return style
  165. }
  166. params := seq[2 : len(seq)-1]
  167. if params == "" {
  168. return style
  169. }
  170. parts := strings.Split(params, ";")
  171. i := 0
  172. for i < len(parts) {
  173. switch parts[i] {
  174. case "0": // Reset
  175. // Mark this as a reset by adding it to attrs
  176. style.attrs = append(style.attrs, "0")
  177. // Don't clear the style here, let the caller handle it
  178. case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
  179. style.attrs = append(style.attrs, parts[i])
  180. case "38": // Foreground color
  181. if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
  182. // 256 color mode
  183. style.fgColor = strings.Join(parts[i:i+3], ";")
  184. i += 2
  185. } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
  186. // RGB color mode
  187. style.fgColor = strings.Join(parts[i:i+5], ";")
  188. i += 4
  189. }
  190. case "48": // Background color
  191. if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
  192. // 256 color mode
  193. style.bgColor = strings.Join(parts[i:i+3], ";")
  194. i += 2
  195. } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
  196. // RGB color mode
  197. style.bgColor = strings.Join(parts[i:i+5], ";")
  198. i += 4
  199. }
  200. case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
  201. style.fgColor = parts[i]
  202. case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
  203. style.bgColor = parts[i]
  204. case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
  205. style.fgColor = parts[i]
  206. case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
  207. style.bgColor = parts[i]
  208. }
  209. i++
  210. }
  211. return style
  212. }
  213. // combineStyles creates an ANSI sequence that combines background from one style with foreground from another
  214. func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
  215. if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
  216. return ""
  217. }
  218. var parts []string
  219. // Add attributes
  220. parts = append(parts, bgStyle.attrs...)
  221. // Add background color from the original style
  222. if bgStyle.bgColor != "" {
  223. parts = append(parts, bgStyle.bgColor)
  224. }
  225. // Add foreground color if specified
  226. if fgColor != nil {
  227. // Use the adaptive color which automatically selects based on terminal background
  228. // The RGBA method already handles light/dark selection
  229. r, g, b, _ := fgColor.RGBA()
  230. // RGBA returns 16-bit values, we need 8-bit
  231. parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
  232. }
  233. if len(parts) == 0 {
  234. return ""
  235. }
  236. return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
  237. }
  238. // getStyleAtPosition extracts the active ANSI style at a given visual position
  239. func getStyleAtPosition(s string, targetPos int) ansiStyle {
  240. // ANSI escape sequence regex
  241. ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
  242. visualPos := 0
  243. currentStyle := ansiStyle{}
  244. i := 0
  245. for i < len(s) && visualPos <= targetPos {
  246. // Check if we're at an ANSI escape sequence
  247. if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
  248. // Found an ANSI sequence at current position
  249. seq := s[i : i+match[1]]
  250. parsedStyle := parseANSISequence(seq)
  251. // Check if this is a reset sequence
  252. if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
  253. // Reset all styles
  254. currentStyle = ansiStyle{}
  255. } else {
  256. // Update current style (merge with existing)
  257. if parsedStyle.fgColor != "" {
  258. currentStyle.fgColor = parsedStyle.fgColor
  259. }
  260. if parsedStyle.bgColor != "" {
  261. currentStyle.bgColor = parsedStyle.bgColor
  262. }
  263. if len(parsedStyle.attrs) > 0 {
  264. currentStyle.attrs = parsedStyle.attrs
  265. }
  266. }
  267. i += match[1]
  268. } else if i < len(s) {
  269. // Regular character
  270. if visualPos == targetPos {
  271. return currentStyle
  272. }
  273. _, size := utf8.DecodeRuneInString(s[i:])
  274. i += size
  275. visualPos++
  276. }
  277. }
  278. return currentStyle
  279. }
  280. type whitespace struct {
  281. style termenv.Style
  282. chars string
  283. }
  284. // Render whitespaces.
  285. func (w whitespace) render(width int) string {
  286. if w.chars == "" {
  287. w.chars = " "
  288. }
  289. r := []rune(w.chars)
  290. j := 0
  291. b := strings.Builder{}
  292. // Cycle through runes and print them into the whitespace.
  293. for i := 0; i < width; {
  294. b.WriteRune(r[j])
  295. j++
  296. if j >= len(r) {
  297. j = 0
  298. }
  299. i += ansi.PrintableRuneWidth(string(r[j]))
  300. }
  301. // Fill any extra gaps white spaces. This might be necessary if any runes
  302. // are more than one cell wide, which could leave a one-rune gap.
  303. short := width - ansi.PrintableRuneWidth(b.String())
  304. if short > 0 {
  305. b.WriteString(strings.Repeat(" ", short))
  306. }
  307. return w.style.Styled(b.String())
  308. }
  309. // WhitespaceOption sets a styling rule for rendering whitespace.
  310. type WhitespaceOption func(*whitespace)
  311. // WithWhitespace sets whitespace options for the overlay
  312. func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
  313. return func(o *overlayOptions) {
  314. for _, opt := range opts {
  315. opt(o.whitespace)
  316. }
  317. }
  318. }
  319. // WithOverlayBorder enables border rendering for the overlay
  320. func WithOverlayBorder() OverlayOption {
  321. return func(o *overlayOptions) {
  322. o.border = true
  323. }
  324. }
  325. // WithOverlayBorderColor sets the border color for the overlay
  326. func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
  327. return func(o *overlayOptions) {
  328. o.borderColor = &color
  329. }
  330. }