1 package cview
2
3 import (
4 "fmt"
5 "math"
6 "regexp"
7 "sort"
8 "strconv"
9
10 "github.com/gdamore/tcell/v2"
11 "github.com/mattn/go-runewidth"
12 "github.com/rivo/uniseg"
13 )
14
15
16
17
18 var TrueColorTags = false
19
20
21
22 var ColorUnset = tcell.ColorSpecial | 108
23
24
25 const (
26 AlignLeft = iota
27 AlignCenter
28 AlignRight
29 )
30
31
32 type VerticalAlignment int
33
34
35 const (
36 AlignTop VerticalAlignment = iota
37 AlignMiddle
38 AlignBottom
39 )
40
41
42 var (
43 colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([bdilrsu]+|\-)?)?)?\]`)
44 regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`)
45 escapePattern = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`)
46 nonEscapePattern = regexp.MustCompile(`(\[[a-zA-Z0-9_,;: \-\."#]+\[*)\]`)
47 boundaryPattern = regexp.MustCompile(`(([,\.\-:;!\?&#+]|\n)[ \t\f\r]*|([ \t\f\r]+))`)
48 spacePattern = regexp.MustCompile(`\s+`)
49 )
50
51
52 const (
53 colorForegroundPos = 1
54 colorBackgroundPos = 3
55 colorFlagPos = 5
56 )
57
58
59 var (
60
61 InputFieldInteger func(text string, ch rune) bool
62
63
64 InputFieldFloat func(text string, ch rune) bool
65
66
67
68
69
70 InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool
71 )
72
73
74 type Transformation int
75
76
77 const (
78 TransformFirstItem Transformation = 1
79 TransformLastItem Transformation = 2
80 TransformPreviousItem Transformation = 3
81 TransformNextItem Transformation = 4
82 TransformPreviousPage Transformation = 5
83 TransformNextPage Transformation = 6
84 )
85
86
87 func init() {
88 runewidth.EastAsianWidth = true
89 runewidth.CreateLUT()
90
91
92 InputFieldInteger = func(text string, ch rune) bool {
93 if text == "-" {
94 return true
95 }
96 _, err := strconv.Atoi(text)
97 return err == nil
98 }
99 InputFieldFloat = func(text string, ch rune) bool {
100 if text == "-" || text == "." || text == "-." {
101 return true
102 }
103 _, err := strconv.ParseFloat(text, 64)
104 return err == nil
105 }
106 InputFieldMaxLength = func(maxLength int) func(text string, ch rune) bool {
107 return func(text string, ch rune) bool {
108 return len([]rune(text)) <= maxLength
109 }
110 }
111 }
112
113
114 func StripTags(text []byte, colors bool, regions bool) []byte {
115 if !colors && !regions {
116 stripped := make([]byte, len(text))
117 copy(stripped, text)
118 return stripped
119 }
120
121 var stripped []byte
122 src := text
123 if regions {
124 stripped = regionPattern.ReplaceAll(text, nil)
125 src = stripped
126 }
127 if colors {
128 stripped = colorPattern.ReplaceAllFunc(src, func(match []byte) []byte {
129 if len(match) > 2 {
130 return nil
131 }
132 return match
133 })
134 }
135
136 return escapePattern.ReplaceAll(stripped, []byte(`[$1$2]`))
137 }
138
139
140
141 func ColorHex(c tcell.Color) string {
142 if !c.Valid() {
143 return ""
144 }
145 r, g, b := c.RGB()
146 return fmt.Sprintf("#%02x%02x%02x", r, g, b)
147 }
148
149
150
151
152
153
154 func styleFromTag(fgColor, bgColor, attributes string, tagSubstrings [][]byte) (newFgColor, newBgColor, newAttributes string) {
155 if len(tagSubstrings[colorForegroundPos]) > 0 {
156 color := string(tagSubstrings[colorForegroundPos])
157 if color == "-" {
158 fgColor = "-"
159 } else if color != "" {
160 fgColor = color
161 }
162 }
163
164 if len(tagSubstrings[colorBackgroundPos-1]) > 0 {
165 color := string(tagSubstrings[colorBackgroundPos])
166 if color == "-" {
167 bgColor = "-"
168 } else if color != "" {
169 bgColor = color
170 }
171 }
172
173 if len(tagSubstrings[colorFlagPos-1]) > 0 {
174 flags := string(tagSubstrings[colorFlagPos])
175 if flags == "-" {
176 attributes = "-"
177 } else if flags != "" {
178 attributes = flags
179 }
180 }
181
182 return fgColor, bgColor, attributes
183 }
184
185
186
187
188
189
190 func overlayStyle(background tcell.Color, defaultStyle tcell.Style, fgColor, bgColor, attributes string) tcell.Style {
191 defFg, defBg, defAttr := defaultStyle.Decompose()
192 style := defaultStyle.Background(background)
193
194 style = style.Foreground(defFg)
195 if fgColor != "" {
196 if fgColor == "-" {
197 style = style.Foreground(defFg)
198 } else {
199 c := tcell.GetColor(fgColor)
200 if TrueColorTags {
201 c = c.TrueColor()
202 }
203 style = style.Foreground(c)
204 }
205 }
206
207 if bgColor == "-" || bgColor == "" && defBg != tcell.ColorDefault {
208 style = style.Background(defBg)
209 } else if bgColor != "" {
210 c := tcell.GetColor(bgColor)
211 if TrueColorTags {
212 c = c.TrueColor()
213 }
214 style = style.Background(c)
215 }
216
217 if attributes == "-" {
218 style = style.Bold(defAttr&tcell.AttrBold > 0)
219 style = style.Dim(defAttr&tcell.AttrDim > 0)
220 style = style.Italic(defAttr&tcell.AttrItalic > 0)
221 style = style.Blink(defAttr&tcell.AttrBlink > 0)
222 style = style.Reverse(defAttr&tcell.AttrReverse > 0)
223 style = style.StrikeThrough(defAttr&tcell.AttrStrikeThrough > 0)
224 style = style.Underline(defAttr&tcell.AttrUnderline > 0)
225 } else if attributes != "" {
226 style = style.Normal()
227 for _, flag := range attributes {
228 switch flag {
229 case 'b':
230 style = style.Bold(true)
231 case 'd':
232 style = style.Dim(true)
233 case 'i':
234 style = style.Italic(true)
235 case 'l':
236 style = style.Blink(true)
237 case 'r':
238 style = style.Reverse(true)
239 case 's':
240 style = style.StrikeThrough(true)
241 case 'u':
242 style = style.Underline(true)
243 }
244 }
245 }
246
247 return style
248 }
249
250
251 func SetAttributes(style tcell.Style, attrs tcell.AttrMask) tcell.Style {
252 return style.
253 Bold(attrs&tcell.AttrBold != 0).
254 Dim(attrs&tcell.AttrDim != 0).
255 Italic(attrs&tcell.AttrItalic != 0).
256 Blink(attrs&tcell.AttrBlink != 0).
257 Reverse(attrs&tcell.AttrReverse != 0).
258 StrikeThrough(attrs&tcell.AttrStrikeThrough != 0).
259 Underline(attrs&tcell.AttrUnderline != 0)
260 }
261
262
263
264
265
266
267
268
269
270 func decomposeText(text []byte, findColors, findRegions bool) (colorIndices [][]int, colors [][][]byte, regionIndices [][]int, regions [][][]byte, escapeIndices [][]int, stripped []byte, width int) {
271
272 if !findColors && !findRegions {
273 return nil, nil, nil, nil, nil, text, runewidth.StringWidth(string(text))
274 }
275
276
277 if findColors {
278 colorIndices = colorPattern.FindAllIndex(text, -1)
279 colors = colorPattern.FindAllSubmatch(text, -1)
280 }
281 if findRegions {
282 regionIndices = regionPattern.FindAllIndex(text, -1)
283 regions = regionPattern.FindAllSubmatch(text, -1)
284 }
285 escapeIndices = escapePattern.FindAllIndex(text, -1)
286
287
288 for i := len(colorIndices) - 1; i >= 0; i-- {
289 if colorIndices[i][1]-colorIndices[i][0] == 2 {
290 colorIndices = append(colorIndices[:i], colorIndices[i+1:]...)
291 colors = append(colors[:i], colors[i+1:]...)
292 }
293 }
294
295
296 var allIndices [][]int
297 if findColors && findRegions {
298 allIndices = colorIndices
299 allIndices = make([][]int, len(colorIndices)+len(regionIndices))
300 copy(allIndices, colorIndices)
301 copy(allIndices[len(colorIndices):], regionIndices)
302 sort.Slice(allIndices, func(i int, j int) bool {
303 return allIndices[i][0] < allIndices[j][0]
304 })
305 } else if findColors {
306 allIndices = colorIndices
307 } else {
308 allIndices = regionIndices
309 }
310
311
312 var from int
313 buf := make([]byte, 0, len(text))
314 for _, indices := range allIndices {
315 buf = append(buf, text[from:indices[0]]...)
316 from = indices[1]
317 }
318 buf = append(buf, text[from:]...)
319
320
321 stripped = escapePattern.ReplaceAll(buf, []byte("[$1$2]"))
322
323
324 width = runewidth.StringWidth(string(stripped))
325
326 return
327 }
328
329
330
331
332
333
334
335
336
337
338 func Print(screen tcell.Screen, text []byte, x, y, maxWidth, align int, color tcell.Color) (int, int) {
339 return PrintStyle(screen, text, x, y, maxWidth, align, tcell.StyleDefault.Foreground(color))
340 }
341
342
343
344 func PrintStyle(screen tcell.Screen, text []byte, x, y, maxWidth, align int, style tcell.Style) (int, int) {
345 if maxWidth <= 0 || len(text) == 0 {
346 return 0, 0
347 }
348
349
350 colorIndices, colors, _, _, escapeIndices, strippedText, strippedWidth := decomposeText(text, true, false)
351
352
353 if align == AlignRight {
354 if strippedWidth <= maxWidth {
355
356 return PrintStyle(screen, text, x+maxWidth-strippedWidth, y, maxWidth, AlignLeft, style)
357 }
358
359 var (
360 bytes, width, colorPos, escapePos, tagOffset int
361 foregroundColor, backgroundColor, attributes string
362 )
363 _, originalBackground, _ := style.Decompose()
364 iterateString(string(strippedText), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
365
366 if colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
367 foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
368 style = overlayStyle(originalBackground, style, foregroundColor, backgroundColor, attributes)
369 tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
370 colorPos++
371 }
372 if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
373 tagOffset++
374 escapePos++
375 }
376 if strippedWidth-screenPos < maxWidth {
377
378 if escapePos > 0 && textPos+tagOffset-1 >= escapeIndices[escapePos-1][0] && textPos+tagOffset-1 < escapeIndices[escapePos-1][1] {
379
380 escapeCharPos := escapeIndices[escapePos-1][1] - 2
381 text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
382 }
383
384 bytes, width = PrintStyle(screen, text[textPos+tagOffset:], x, y, maxWidth, AlignLeft, style)
385 return true
386 }
387 return false
388 })
389 return bytes, width
390 } else if align == AlignCenter {
391 if strippedWidth == maxWidth {
392
393 return PrintStyle(screen, text, x, y, maxWidth, AlignLeft, style)
394 } else if strippedWidth < maxWidth {
395
396 half := (maxWidth - strippedWidth) / 2
397 return PrintStyle(screen, text, x+half, y, maxWidth-half, AlignLeft, style)
398 } else {
399
400 var choppedLeft, choppedRight, leftIndex, rightIndex int
401 rightIndex = len(strippedText)
402 for rightIndex-1 > leftIndex && strippedWidth-choppedLeft-choppedRight > maxWidth {
403 if choppedLeft < choppedRight {
404
405 iterateString(string(strippedText[leftIndex:]), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
406 choppedLeft += screenWidth
407 leftIndex += textWidth
408 return true
409 })
410 } else {
411
412 iterateStringReverse(string(strippedText[leftIndex:rightIndex]), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
413 choppedRight += screenWidth
414 rightIndex -= textWidth
415 return true
416 })
417 }
418 }
419
420
421 var (
422 colorPos, escapePos, tagOffset int
423 foregroundColor, backgroundColor, attributes string
424 )
425 _, originalBackground, _ := style.Decompose()
426 for index := range strippedText {
427
428 if index > leftIndex {
429
430 if escapePos > 0 && leftIndex+tagOffset-1 >= escapeIndices[escapePos-1][0] && leftIndex+tagOffset-1 < escapeIndices[escapePos-1][1] {
431
432 escapeCharPos := escapeIndices[escapePos-1][1] - 2
433 text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
434 }
435 break
436 }
437
438
439 if colorPos < len(colorIndices) && index+tagOffset >= colorIndices[colorPos][0] && index+tagOffset < colorIndices[colorPos][1] {
440 if index <= leftIndex {
441 foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
442 style = overlayStyle(originalBackground, style, foregroundColor, backgroundColor, attributes)
443 }
444 tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
445 colorPos++
446 }
447 if escapePos < len(escapeIndices) && index+tagOffset >= escapeIndices[escapePos][0] && index+tagOffset < escapeIndices[escapePos][1] {
448 tagOffset++
449 escapePos++
450 }
451 }
452 return PrintStyle(screen, text[leftIndex+tagOffset:], x, y, maxWidth, AlignLeft, style)
453 }
454 }
455
456
457 var (
458 drawn, drawnWidth, colorPos, escapePos, tagOffset int
459 foregroundColor, backgroundColor, attributes string
460 )
461 iterateString(string(strippedText), func(main rune, comb []rune, textPos, length, screenPos, screenWidth int) bool {
462
463 if drawnWidth+screenWidth > maxWidth {
464 return true
465 }
466
467
468 for colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
469 foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
470 tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
471 colorPos++
472 }
473
474
475 if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
476 if textPos+tagOffset == escapeIndices[escapePos][1]-2 {
477 tagOffset++
478 escapePos++
479 }
480 }
481
482
483 finalX := x + drawnWidth
484 _, _, finalStyle, _ := screen.GetContent(finalX, y)
485 _, background, _ := finalStyle.Decompose()
486 finalStyle = overlayStyle(background, style, foregroundColor, backgroundColor, attributes)
487 for offset := screenWidth - 1; offset >= 0; offset-- {
488
489 if offset == 0 {
490 screen.SetContent(finalX+offset, y, main, comb, finalStyle)
491 } else {
492 screen.SetContent(finalX+offset, y, ' ', nil, finalStyle)
493 }
494 }
495
496
497 drawn += length
498 drawnWidth += screenWidth
499
500 return false
501 })
502
503 return drawn + tagOffset + len(escapeIndices), drawnWidth
504 }
505
506
507 func PrintSimple(screen tcell.Screen, text []byte, x, y int) {
508 Print(screen, text, x, y, math.MaxInt32, AlignLeft, Styles.PrimaryTextColor)
509 }
510
511
512
513 func TaggedTextWidth(text []byte) int {
514 _, _, _, _, _, _, width := decomposeText(text, true, false)
515 return width
516 }
517
518
519
520 func TaggedStringWidth(text string) int {
521 return TaggedTextWidth([]byte(text))
522 }
523
524
525
526
527
528
529
530
531
532
533
534
535
536 func WordWrap(text string, width int) (lines []string) {
537 colorTagIndices, _, _, _, escapeIndices, strippedText, _ := decomposeText([]byte(text), true, false)
538
539
540 breakpoints := boundaryPattern.FindAllSubmatchIndex(strippedText, -1)
541
542
543
544
545
546 var (
547 colorPos, escapePos, breakpointPos, tagOffset int
548 lastBreakpoint, lastContinuation, currentLineStart int
549 lineWidth, overflow int
550 forceBreak bool
551 )
552 unescape := func(substr string, startIndex int) string {
553
554 for index := escapePos; index >= 0; index-- {
555 if index < len(escapeIndices) && startIndex > escapeIndices[index][0] && startIndex < escapeIndices[index][1]-1 {
556 pos := escapeIndices[index][1] - 2 - startIndex
557 if pos < 0 || pos > len(substr) {
558 return substr
559 }
560 return substr[:pos] + substr[pos+1:]
561 }
562 }
563 return substr
564 }
565 iterateString(string(strippedText), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
566
567 for {
568 if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] {
569
570 tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0]
571 colorPos++
572 } else if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 {
573
574 tagOffset++
575 escapePos++
576 } else {
577 break
578 }
579 }
580
581
582 if breakpointPos < len(breakpoints) && textPos+tagOffset == breakpoints[breakpointPos][0] {
583
584 lastBreakpoint = breakpoints[breakpointPos][0] + tagOffset
585 lastContinuation = breakpoints[breakpointPos][1] + tagOffset
586 overflow = 0
587 forceBreak = main == '\n'
588 if breakpoints[breakpointPos][6] < 0 && !forceBreak {
589 lastBreakpoint++
590 }
591 breakpointPos++
592 }
593
594
595 if forceBreak || lineWidth > 0 && lineWidth+screenWidth > width {
596 breakpoint := lastBreakpoint
597 continuation := lastContinuation
598 if forceBreak {
599 breakpoint = textPos + tagOffset
600 continuation = textPos + tagOffset + 1
601 lastBreakpoint = 0
602 overflow = 0
603 } else if lastBreakpoint <= currentLineStart {
604 breakpoint = textPos + tagOffset
605 continuation = textPos + tagOffset
606 overflow = 0
607 }
608 lines = append(lines, unescape(text[currentLineStart:breakpoint], currentLineStart))
609 currentLineStart, lineWidth, forceBreak = continuation, overflow, false
610 }
611
612
613 if lastBreakpoint > 0 && lastContinuation <= textPos+tagOffset {
614 overflow += screenWidth
615 }
616
617
618 lineWidth += screenWidth
619
620
621 if textPos+tagOffset < currentLineStart {
622 lineWidth -= screenWidth
623 }
624
625 return false
626 })
627
628
629 if currentLineStart < len(text) {
630 lines = append(lines, unescape(text[currentLineStart:], currentLineStart))
631 }
632
633 return
634 }
635
636
637
638
639
640
641
642 func EscapeBytes(text []byte) []byte {
643 return nonEscapePattern.ReplaceAll(text, []byte("$1[]"))
644 }
645
646
647
648
649
650
651
652 func Escape(text string) string {
653 return nonEscapePattern.ReplaceAllString(text, "$1[]")
654 }
655
656
657
658
659
660
661
662
663
664 func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool {
665 var screenPos int
666
667 gr := uniseg.NewGraphemes(text)
668 for gr.Next() {
669 r := gr.Runes()
670 from, to := gr.Positions()
671 width := runewidth.StringWidth(gr.Str())
672 var comb []rune
673 if len(r) > 1 {
674 comb = r[1:]
675 }
676
677 if callback(r[0], comb, from, to-from, screenPos, width) {
678 return true
679 }
680
681 screenPos += width
682 }
683
684 return false
685 }
686
687
688
689
690
691
692
693
694
695 func iterateStringReverse(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool {
696 type cluster struct {
697 main rune
698 comb []rune
699 textPos, textWidth, screenPos, screenWidth int
700 }
701
702
703 var clusters []cluster
704 iterateString(text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth int) bool {
705 clusters = append(clusters, cluster{
706 main: main,
707 comb: comb,
708 textPos: textPos,
709 textWidth: textWidth,
710 screenPos: screenPos,
711 screenWidth: screenWidth,
712 })
713 return false
714 })
715
716
717 for index := len(clusters) - 1; index >= 0; index-- {
718 if callback(
719 clusters[index].main,
720 clusters[index].comb,
721 clusters[index].textPos,
722 clusters[index].textWidth,
723 clusters[index].screenPos,
724 clusters[index].screenWidth,
725 ) {
726 return true
727 }
728 }
729
730 return false
731 }
732
733
734 type ScrollBarVisibility int
735
736 const (
737
738 ScrollBarNever ScrollBarVisibility = iota
739
740
741 ScrollBarAuto
742
743
744 ScrollBarAlways
745 )
746
747
748 var (
749 ScrollBarArea = []byte("[-:-:-]░")
750 ScrollBarAreaFocused = []byte("[-:-:-]▒")
751 ScrollBarHandle = []byte("[-:-:-]▓")
752 ScrollBarHandleFocused = []byte("[::r] [-:-:-]")
753 )
754
755
756 func RenderScrollBar(screen tcell.Screen, visibility ScrollBarVisibility, x int, y int, height int, items int, cursor int, printed int, focused bool, color tcell.Color) {
757 if visibility == ScrollBarNever || (visibility == ScrollBarAuto && items <= height) {
758 return
759 }
760
761
762 if items <= height {
763 cursor = 0
764 }
765
766
767 if cursor < 0 {
768 cursor = 0
769 }
770
771
772 handlePosition := int(float64(height-1) * (float64(cursor) / float64(items-1)))
773
774
775 var text []byte
776 if printed == handlePosition {
777 if focused {
778 text = ScrollBarHandleFocused
779 } else {
780 text = ScrollBarHandle
781 }
782 } else {
783 if focused {
784 text = ScrollBarAreaFocused
785 } else {
786 text = ScrollBarArea
787 }
788 }
789 Print(screen, text, x, y, 1, AlignLeft, color)
790 }
791
View as plain text