embed_renderer.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. package chat
  2. import (
  3. "strings"
  4. "github.com/ayn2op/discordo/internal/config"
  5. "github.com/ayn2op/discordo/internal/ui"
  6. "github.com/ayn2op/tview"
  7. "github.com/diamondburned/arikawa/v3/discord"
  8. "github.com/gdamore/tcell/v3"
  9. "github.com/rivo/uniseg"
  10. )
  11. type embedLine struct {
  12. Text string
  13. Kind embedLineKind
  14. URL string
  15. }
  16. type embedLineKind uint8
  17. const (
  18. // Keep this ordering stable: drawEmbeds indexes precomputed style slots by this enum.
  19. embedLineProvider embedLineKind = iota
  20. embedLineAuthor
  21. embedLineTitle
  22. embedLineDescription
  23. embedLineFieldName
  24. embedLineFieldValue
  25. embedLineFooter
  26. embedLineURL
  27. embedLineKindCount
  28. )
  29. func embedLineStyles(baseStyle tcell.Style, theme config.MessagesListEmbedsTheme) [embedLineKindCount]tcell.Style {
  30. styles := [embedLineKindCount]tcell.Style{}
  31. styles[embedLineProvider] = ui.MergeStyle(baseStyle, theme.ProviderStyle.Style)
  32. styles[embedLineAuthor] = ui.MergeStyle(baseStyle, theme.AuthorStyle.Style)
  33. styles[embedLineTitle] = ui.MergeStyle(baseStyle, theme.TitleStyle.Style)
  34. styles[embedLineDescription] = ui.MergeStyle(baseStyle, theme.DescriptionStyle.Style)
  35. styles[embedLineFieldName] = ui.MergeStyle(baseStyle, theme.FieldNameStyle.Style)
  36. styles[embedLineFieldValue] = ui.MergeStyle(baseStyle, theme.FieldValueStyle.Style)
  37. styles[embedLineFooter] = ui.MergeStyle(baseStyle, theme.FooterStyle.Style)
  38. styles[embedLineURL] = ui.MergeStyle(baseStyle, theme.URLStyle.Style)
  39. return styles
  40. }
  41. type embedLineDedupKey struct {
  42. kind embedLineKind
  43. text string
  44. }
  45. func embedLines(embed discord.Embed, contentURLs map[string]struct{}) []embedLine {
  46. lines := make([]embedLine, 0, 8)
  47. seen := make(map[embedLineDedupKey]struct{}, 8)
  48. appendUnique := func(s string, kind embedLineKind, rawURL string) {
  49. s = strings.TrimSpace(s)
  50. if s == "" {
  51. return
  52. }
  53. // Deduplicate by kind+text so the same value can intentionally appear in multiple semantic slots with different styles (e.g. title vs. field).
  54. key := embedLineDedupKey{kind: kind, text: s}
  55. if _, ok := seen[key]; ok {
  56. return
  57. }
  58. seen[key] = struct{}{}
  59. lines = append(lines, embedLine{
  60. Text: s,
  61. Kind: kind,
  62. URL: rawURL,
  63. })
  64. }
  65. appendURL := func(url discord.URL) {
  66. u := strings.TrimSpace(string(url))
  67. if u == "" {
  68. return
  69. }
  70. // Avoid duplicating links that already appear in message body content.
  71. if _, ok := contentURLs[u]; ok {
  72. return
  73. }
  74. appendUnique(ui.LinkDisplayText(u), embedLineURL, u)
  75. }
  76. if embed.Provider != nil {
  77. appendUnique(embed.Provider.Name, embedLineProvider, "")
  78. }
  79. if embed.Author != nil {
  80. appendUnique(embed.Author.Name, embedLineAuthor, "")
  81. }
  82. appendUnique(embed.Title, embedLineTitle, string(embed.URL))
  83. // Some Discord embeds include markdown-escaped punctuation in raw payload text (e.g. "\."), so normalize for display.
  84. appendUnique(unescapeMarkdownEscapes(embed.Description), embedLineDescription, "")
  85. for _, field := range embed.Fields {
  86. switch {
  87. case field.Name != "" && field.Value != "":
  88. appendUnique(field.Name, embedLineFieldName, "")
  89. appendUnique(field.Value, embedLineFieldValue, "")
  90. case field.Name != "":
  91. appendUnique(field.Name, embedLineFieldName, "")
  92. default:
  93. appendUnique(field.Value, embedLineFieldValue, "")
  94. }
  95. }
  96. if embed.Footer != nil {
  97. appendUnique(embed.Footer.Text, embedLineFooter, "")
  98. }
  99. // Prefer media URLs after textual fields so previews read top-to-bottom before jumping to link targets.
  100. // When a title exists, embed.URL is represented by title Style.Url metadata instead of a separate URL row.
  101. if embed.Title == "" {
  102. appendURL(embed.URL)
  103. }
  104. if embed.Image != nil {
  105. appendURL(embed.Image.URL)
  106. }
  107. if embed.Video != nil {
  108. appendURL(embed.Video.URL)
  109. }
  110. return lines
  111. }
  112. func unescapeMarkdownEscapes(s string) string {
  113. if !strings.ContainsRune(s, '\\') {
  114. return s
  115. }
  116. var b strings.Builder
  117. b.Grow(len(s))
  118. for i := range len(s) {
  119. if s[i] == '\\' && i+1 < len(s) && isMarkdownEscapable(s[i+1]) {
  120. continue
  121. }
  122. b.WriteByte(s[i])
  123. }
  124. return b.String()
  125. }
  126. func isMarkdownEscapable(c byte) bool {
  127. switch c {
  128. case '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '|', '>', '~':
  129. return true
  130. default:
  131. return false
  132. }
  133. }
  134. func lineWithURL(line tview.Line, rawURL string) tview.Line {
  135. out := make(tview.Line, len(line))
  136. for i, segment := range line {
  137. out[i] = segment
  138. out[i].Style = out[i].Style.Url(rawURL)
  139. }
  140. return out
  141. }
  142. func wrapStyledLine(line tview.Line, width int) []tview.Line {
  143. if width <= 0 {
  144. return []tview.Line{line}
  145. }
  146. if len(line) == 0 {
  147. return []tview.Line{line}
  148. }
  149. lines := make([]tview.Line, 0, 2)
  150. current := make(tview.Line, 0, len(line))
  151. currentWidth := 0
  152. pushSegment := func(text string, style tcell.Style) {
  153. if text == "" {
  154. return
  155. }
  156. if n := len(current); n > 0 && current[n-1].Style == style {
  157. current[n-1].Text += text
  158. return
  159. }
  160. current = append(current, tview.Segment{Text: text, Style: style})
  161. }
  162. flush := func() {
  163. lineCopy := make(tview.Line, len(current))
  164. copy(lineCopy, current)
  165. lines = append(lines, lineCopy)
  166. current = current[:0]
  167. currentWidth = 0
  168. }
  169. for _, segment := range line {
  170. state := -1
  171. rest := segment.Text
  172. for len(rest) > 0 {
  173. cluster, nextRest, boundaries, nextState := uniseg.StepString(rest, state)
  174. state = nextState
  175. rest = nextRest
  176. if cluster == "" {
  177. continue
  178. }
  179. // Use grapheme width (not rune count) so wrapping stays correct with wide glyphs, emoji, and combining characters.
  180. clusterWidth := graphemeClusterWidth(boundaries)
  181. if currentWidth > 0 && currentWidth+clusterWidth > width {
  182. flush()
  183. }
  184. pushSegment(cluster, segment.Style)
  185. currentWidth += clusterWidth
  186. if currentWidth >= width {
  187. flush()
  188. }
  189. }
  190. }
  191. if len(current) > 0 {
  192. flush()
  193. }
  194. if len(lines) == 0 {
  195. return []tview.Line{{}}
  196. }
  197. return lines
  198. }
  199. func graphemeClusterWidth(boundaries int) int {
  200. return boundaries >> uniseg.ShiftWidth
  201. }