master sewn/kohai / ui / style.go
  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}