1package ui
2
3import (
4 "fmt"
5 "math/rand"
6 "net/url"
7 "regexp"
8 "strconv"
9 "strings"
10 "unicode"
11 "unicode/utf8"
12
13 "git.sr.ht/~rockorager/vaxis"
14 "mvdan.cc/xurls/v2"
15)
16
17// Taken from <https://modern.ircdocs.horse/formatting.html>
18
19var baseCodes = []vaxis.Color{
20 vaxis.IndexColor(15),
21 vaxis.IndexColor(0),
22 vaxis.IndexColor(4),
23 vaxis.IndexColor(2),
24 vaxis.IndexColor(9),
25 vaxis.IndexColor(1),
26 vaxis.IndexColor(5),
27 vaxis.IndexColor(3),
28 vaxis.IndexColor(11),
29 vaxis.IndexColor(10),
30 vaxis.IndexColor(6),
31 vaxis.IndexColor(14),
32 vaxis.IndexColor(12),
33 vaxis.IndexColor(13),
34 vaxis.IndexColor(8),
35 vaxis.IndexColor(7),
36}
37
38var hexCodes = []uint32{
39 0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047, 0x47002a,
40 0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449, 0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045,
41 0xb50000, 0xb56300, 0xb5b500, 0x7db500, 0x00b500, 0x00b571, 0x00b5b5, 0x0063b5, 0x0000b5, 0x7500b5, 0xb500b5, 0xb5006b,
42 0xff0000, 0xff8c00, 0xffff00, 0xb2ff00, 0x00ff00, 0x00ffa0, 0x00ffff, 0x008cff, 0x0000ff, 0xa500ff, 0xff00ff, 0xff0098,
43 0xff5959, 0xffb459, 0xffff71, 0xcfff60, 0x6fff6f, 0x65ffc9, 0x6dffff, 0x59b4ff, 0x5959ff, 0xc459ff, 0xff66ff, 0xff59bc,
44 0xff9c9c, 0xffd39c, 0xffff9c, 0xe2ff9c, 0x9cff9c, 0x9cffdb, 0x9cffff, 0x9cd3ff, 0x9c9cff, 0xdc9cff, 0xff9cff, 0xff94d3,
45 0x000000, 0x131313, 0x282828, 0x363636, 0x4d4d4d, 0x656565, 0x818181, 0x9f9f9f, 0xbcbcbc, 0xe2e2e2, 0xffffff,
46}
47
48func colorFromCode(code int) (color vaxis.Color) {
49 if code < 0 || 99 <= code {
50 color = ColorDefault
51 } else if code < 16 {
52 color = baseCodes[code]
53 } else {
54 color = vaxis.HexColor(hexCodes[code-16])
55 }
56 return
57}
58
59type rangedStyle struct {
60 Start int // byte index at which Style is effective
61 Style vaxis.Style
62}
63
64type StyledString struct {
65 string
66 styles []rangedStyle // sorted, elements cannot have the same Start value
67}
68
69func PlainString(s string) StyledString {
70 return StyledString{string: s}
71}
72
73func PlainSprintf(format string, a ...interface{}) StyledString {
74 return PlainString(fmt.Sprintf(format, a...))
75}
76
77func ColorString(s string, fg vaxis.Color) StyledString {
78 return Styled(s, vaxis.Style{Foreground: fg})
79}
80
81func Styled(s string, style vaxis.Style) StyledString {
82 rStyle := rangedStyle{
83 Start: 0,
84 Style: style,
85 }
86 return StyledString{
87 string: s,
88 styles: []rangedStyle{rStyle},
89 }
90}
91
92func (s StyledString) String() string {
93 return s.string
94}
95
96var urlRegex *regexp.Regexp
97
98func init() {
99 urlRegex, _ = xurls.StrictMatchingScheme(xurls.AnyScheme)
100 urlRegex = regexp.MustCompile(urlRegex.String() + `|#[\p{L}0-9#._-]*[\p{L}0-9]`)
101 urlRegex.Longest()
102}
103
104func (s StyledString) ParseURLs() StyledString {
105 if !strings.ContainsAny(s.string, ".#") {
106 // fast path: no URL
107 return s
108 }
109
110 styles := make([]rangedStyle, 0, len(s.styles))
111
112 urls := urlRegex.FindAllStringIndex(s.string, -1)
113 j := 0
114 lastStyle := rangedStyle{
115 Start: -1,
116 }
117 for i := 0; i < len(urls); i++ {
118 u := urls[i]
119 ub, ue := u[0], u[1]
120 link := s.string[u[0]:u[1]]
121 var params string
122 if link[0] == '#' {
123 if prev := lastRuneBefore(s.string, u[0]); !(prev == 0 || unicode.IsSpace(prev) || prev == '(' || prev == '[') {
124 // channel link preceded by a non-space character: eg a#a: drop
125 continue
126 }
127 if !strings.ContainsFunc(link[1:], func(r rune) bool {
128 return r < '0' || r > '9'
129 }) {
130 // channel link with only numbers: eg #1234: drop, because this is likely a reference to a ticket
131 continue
132 }
133 // store channel in hyperlink params, but create no link.
134 // this allows us to save the link data without actually creating a link in the terminal
135 params = fmt.Sprintf("channel=%v", link)
136 link = ""
137 } else {
138 if u, err := url.Parse(link); err != nil || u.Scheme == "" {
139 link = "https://" + link
140 }
141 params = fmt.Sprintf("id=_%010d", rand.Int31())
142 }
143 // find last style starting before or at url begin
144 for ; j < len(s.styles); j++ {
145 st := s.styles[j]
146 if st.Start > ub {
147 break
148 }
149 if st.Start == ub {
150 // a style already starts at this position, edit it
151 st.Style.Hyperlink = link
152 st.Style.HyperlinkParams = params
153 }
154 lastStyle = st
155 styles = append(styles, st)
156 }
157 if lastStyle.Start != ub {
158 // no style existed at this position, add one from the last style
159 st := lastStyle.Style
160 st.Hyperlink = link
161 st.HyperlinkParams = params
162 styles = append(styles, rangedStyle{
163 Start: ub,
164 Style: st,
165 })
166 }
167 // find last style starting before or at url end
168 for ; j < len(s.styles); j++ {
169 st := s.styles[j]
170 if st.Start > ue {
171 break
172 }
173 if st.Start < ue {
174 st.Style.Hyperlink = link
175 st.Style.HyperlinkParams = params
176 }
177 lastStyle = st
178 styles = append(styles, st)
179 }
180 if lastStyle.Start != ue {
181 // no style existed at this position, add one from the last style without the hyperlink
182 st := lastStyle.Style
183 st.Hyperlink = ""
184 st.HyperlinkParams = ""
185 styles = append(styles, rangedStyle{
186 Start: ue,
187 Style: st,
188 })
189 }
190 }
191 styles = append(styles, s.styles[j:]...)
192
193 return StyledString{
194 string: s.string,
195 styles: styles,
196 }
197}
198
199func isDigit(c byte) bool {
200 return '0' <= c && c <= '9'
201}
202
203func parseColorNumber(raw string) (color vaxis.Color, n int) {
204 if len(raw) == 0 || !isDigit(raw[0]) {
205 return
206 }
207
208 // len(raw) >= 1 and its first character is a digit.
209
210 if len(raw) == 1 || !isDigit(raw[1]) {
211 code, _ := strconv.Atoi(raw[:1])
212 return colorFromCode(code), 1
213 }
214
215 // len(raw) >= 2 and the two first characters are digits.
216
217 code, _ := strconv.Atoi(raw[:2])
218 return colorFromCode(code), 2
219}
220
221func parseColor(raw string) (fg, bg vaxis.Color, n int) {
222 fg, n = parseColorNumber(raw)
223 raw = raw[n:]
224
225 if len(raw) == 0 || raw[0] != ',' {
226 return fg, ColorDefault, n
227 }
228
229 n++
230 bg, p := parseColorNumber(raw[1:])
231 n += p
232
233 if bg == ColorDefault {
234 // Lone comma, do not parse as part of a color code.
235 return fg, ColorDefault, n - 1
236 }
237
238 return fg, bg, n
239}
240
241func parseHexColorNumber(raw string) (color vaxis.Color, n int) {
242 if len(raw) < 6 {
243 return
244 }
245 if raw[0] == '+' || raw[0] == '-' {
246 return
247 }
248 value, err := strconv.ParseInt(raw[:6], 16, 32)
249 if err != nil {
250 return
251 }
252 return vaxis.HexColor(uint32(value)), 6
253}
254
255func parseHexColor(raw string) (fg, bg vaxis.Color, n int) {
256 fg, n = parseHexColorNumber(raw)
257 raw = raw[n:]
258
259 if len(raw) == 0 || raw[0] != ',' {
260 return fg, ColorDefault, n
261 }
262
263 n++
264 bg, p := parseHexColorNumber(raw[1:])
265 n += p
266
267 if bg == ColorDefault {
268 // Lone comma, do not parse as part of a color code.
269 return fg, ColorDefault, n - 1
270 }
271
272 return fg, bg, n
273}
274
275func lastRuneBefore(s string, i int) rune {
276 var r rune
277 for ri, rr := range s {
278 if ri >= i {
279 break
280 }
281 r = rr
282 }
283 return r
284}
285
286func IRCString(raw string) StyledString {
287 var formatted strings.Builder
288 var styles []rangedStyle
289 var last vaxis.Style
290
291 for len(raw) != 0 {
292 r, runeSize := utf8.DecodeRuneInString(raw)
293 current := last
294 if r == 0x0F {
295 current = vaxis.Style{}
296 } else if r == 0x02 {
297 current.Attribute ^= vaxis.AttrBold
298 } else if r == 0x03 || r == 0x04 {
299 var fg vaxis.Color
300 var bg vaxis.Color
301 var n int
302 if r == 0x03 {
303 fg, bg, n = parseColor(raw[1:])
304 } else {
305 fg, bg, n = parseHexColor(raw[1:])
306 }
307 raw = raw[n:]
308 if n == 0 {
309 current.Foreground = ColorDefault
310 current.Background = ColorDefault
311 } else if bg == ColorDefault {
312 current.Foreground = fg
313 } else {
314 current.Foreground = fg
315 current.Background = bg
316 }
317 } else if r == 0x11 {
318 current.Attribute ^= vaxis.AttrDim
319 } else if r == 0x16 {
320 current.Attribute ^= vaxis.AttrReverse
321 } else if r == 0x1D {
322 current.Attribute ^= vaxis.AttrItalic
323 } else if r == 0x1E {
324 current.Attribute ^= vaxis.AttrStrikethrough
325 } else if r == 0x1F {
326 if last.UnderlineStyle == vaxis.UnderlineOff {
327 current.UnderlineStyle = vaxis.UnderlineSingle
328 } else {
329 current.UnderlineStyle = vaxis.UnderlineOff
330 }
331 } else {
332 formatted.WriteRune(r)
333 }
334 if last != current {
335 if len(styles) != 0 && styles[len(styles)-1].Start == formatted.Len() {
336 styles[len(styles)-1] = rangedStyle{
337 Start: formatted.Len(),
338 Style: current,
339 }
340 } else {
341 styles = append(styles, rangedStyle{
342 Start: formatted.Len(),
343 Style: current,
344 })
345 }
346 }
347 last = current
348 raw = raw[runeSize:]
349 }
350
351 return StyledString{
352 string: formatted.String(),
353 styles: styles,
354 }
355}
356
357type StyledStringBuilder struct {
358 strings.Builder
359 styles []rangedStyle
360}
361
362func (sb *StyledStringBuilder) Reset() {
363 sb.Builder.Reset()
364 sb.styles = sb.styles[:0]
365}
366
367func (sb *StyledStringBuilder) WriteStyledString(s StyledString) {
368 start := len(sb.styles)
369 sb.styles = append(sb.styles, s.styles...)
370 for i := start; i < len(sb.styles); i++ {
371 sb.styles[i].Start += sb.Len()
372 }
373 sb.WriteString(s.string)
374}
375
376func (sb *StyledStringBuilder) AddStyle(start int, style vaxis.Style) {
377 for i := 0; i < len(sb.styles); i++ {
378 if sb.styles[i].Start == i {
379 sb.styles[i].Style = style
380 break
381 } else if sb.styles[i].Start < i {
382 sb.styles = append(sb.styles[:i+1], sb.styles[i:]...)
383 sb.styles[i+1] = rangedStyle{
384 Start: start,
385 Style: style,
386 }
387 break
388 }
389 }
390 sb.styles = append(sb.styles, rangedStyle{
391 Start: start,
392 Style: style,
393 })
394}
395
396func (sb *StyledStringBuilder) SetStyle(style vaxis.Style) {
397 sb.styles = append(sb.styles, rangedStyle{
398 Start: sb.Len(),
399 Style: style,
400 })
401}
402
403func (sb *StyledStringBuilder) StyledString() StyledString {
404 string := sb.String()
405 styles := make([]rangedStyle, 0, len(sb.styles))
406 for _, style := range sb.styles {
407 if len(string) <= style.Start {
408 break
409 }
410 styles = append(styles, style)
411 }
412 return StyledString{
413 string: string,
414 styles: styles,
415 }
416}