1 package cview
2
3 import (
4 "fmt"
5 "math"
6 "regexp"
7 "sort"
8 "strconv"
9
10 "github.com/gdamore/tcell/v3"
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, _ rune) bool {
93 if text == "-" {
94 return true
95 }
96 _, err := strconv.Atoi(text)
97 return err == nil
98 }
99 InputFieldFloat = func(text string, _ 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.GetForeground(), defaultStyle.GetBackground(), defaultStyle.GetAttributes()
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(defaultStyle.GetUnderlineColor(), defaultStyle.GetUnderlineStyle())
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 }
260
261
262
263
264
265
266
267
268
269 func decomposeText(text []byte, findColors, findRegions bool) (colorIndices [][]int, colors [][][]byte, regionIndices [][]int, regions [][][]byte, escapeIndices [][]int, stripped []byte, width int) {
270
271 if !findColors && !findRegions {
272 return nil, nil, nil, nil, nil, text, runewidth.StringWidth(string(text))
273 }
274
275
276 if findColors {
277 colorIndices = colorPattern.FindAllIndex(text, -1)
278 colors = colorPattern.FindAllSubmatch(text, -1)
279 }
280 if findRegions {
281 regionIndices = regionPattern.FindAllIndex(text, -1)
282 regions = regionPattern.FindAllSubmatch(text, -1)
283 }
284 escapeIndices = escapePattern.FindAllIndex(text, -1)
285
286
287 for i := len(colorIndices) - 1; i >= 0; i-- {
288 if colorIndices[i][1]-colorIndices[i][0] == 2 {
289 colorIndices = append(colorIndices[:i], colorIndices[i+1:]...)
290 colors = append(colors[:i], colors[i+1:]...)
291 }
292 }
293
294
295 allIndices := make([][3]int, 0, len(colorIndices)+len(regionIndices)+len(escapeIndices))
296 for indexType, index := range [][][]int{colorIndices, regionIndices, escapeIndices} {
297 for _, tag := range index {
298 allIndices = append(allIndices, [3]int{tag[0], tag[1], indexType})
299 }
300 }
301 sort.Slice(allIndices, func(i int, j int) bool {
302 return allIndices[i][0] < allIndices[j][0]
303 })
304
305
306 var from int
307 buf := make([]byte, 0, len(text))
308 for _, indices := range allIndices {
309 if indices[2] == 2 {
310 buf = append(buf, []byte(text[from:indices[1]-2])...)
311 buf = append(buf, ']')
312 from = indices[1]
313 } else {
314 buf = append(buf, []byte(text[from:indices[0]])...)
315 from = indices[1]
316 }
317 }
318 buf = append(buf, text[from:]...)
319 stripped = buf
320
321
322 width = runewidth.StringWidth(string(stripped))
323
324 return
325 }
326
327
328
329
330
331
332
333
334
335
336 func Print(screen tcell.Screen, text []byte, x, y, maxWidth, align int, color tcell.Color) (int, int) {
337 return PrintStyle(screen, text, x, y, maxWidth, align, tcell.StyleDefault.Foreground(color))
338 }
339
340
341
342 func PrintStyle(screen tcell.Screen, text []byte, x, y, maxWidth, align int, style tcell.Style) (int, int) {
343 if maxWidth <= 0 || len(text) == 0 {
344 return 0, 0
345 }
346
347
348 colorIndices, colors, _, _, escapeIndices, strippedText, strippedWidth := decomposeText(text, true, false)
349
350
351 if align == AlignRight {
352 if strippedWidth <= maxWidth {
353
354 return PrintStyle(screen, text, x+maxWidth-strippedWidth, y, maxWidth, AlignLeft, style)
355 }
356
357 var (
358 bytes, width, colorPos, escapePos, tagOffset int
359 foregroundColor, backgroundColor, attributes string
360 )
361 originalBackground := style.GetBackground()
362 iterateString(string(strippedText), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
363
364 if colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
365 foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
366 style = overlayStyle(originalBackground, style, foregroundColor, backgroundColor, attributes)
367 tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
368 colorPos++
369 }
370 if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
371 tagOffset++
372 escapePos++
373 }
374 if strippedWidth-screenPos < maxWidth {
375
376 if escapePos > 0 && textPos+tagOffset-1 >= escapeIndices[escapePos-1][0] && textPos+tagOffset-1 < escapeIndices[escapePos-1][1] {
377
378 escapeCharPos := escapeIndices[escapePos-1][1] - 2
379 text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
380 }
381
382 bytes, width = PrintStyle(screen, text[textPos+tagOffset:], x, y, maxWidth, AlignLeft, style)
383 return true
384 }
385 return false
386 })
387 return bytes, width
388 } else if align == AlignCenter {
389 if strippedWidth == maxWidth {
390
391 return PrintStyle(screen, text, x, y, maxWidth, AlignLeft, style)
392 } else if strippedWidth < maxWidth {
393
394 half := (maxWidth - strippedWidth) / 2
395 return PrintStyle(screen, text, x+half, y, maxWidth-half, AlignLeft, style)
396 } else {
397
398 var choppedLeft, choppedRight, leftIndex, rightIndex int
399 rightIndex = len(strippedText)
400 for rightIndex-1 > leftIndex && strippedWidth-choppedLeft-choppedRight > maxWidth {
401 if choppedLeft < choppedRight {
402
403 iterateString(string(strippedText[leftIndex:]), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
404 choppedLeft += screenWidth
405 leftIndex += textWidth
406 return true
407 })
408 } else {
409
410 iterateStringReverse(string(strippedText[leftIndex:rightIndex]), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
411 choppedRight += screenWidth
412 rightIndex -= textWidth
413 return true
414 })
415 }
416 }
417
418
419 var (
420 colorPos, escapePos, tagOffset int
421 foregroundColor, backgroundColor, attributes string
422 )
423 originalBackground := style.GetBackground()
424 for index := range strippedText {
425
426 if index > leftIndex {
427
428 if escapePos > 0 && leftIndex+tagOffset-1 >= escapeIndices[escapePos-1][0] && leftIndex+tagOffset-1 < escapeIndices[escapePos-1][1] {
429
430 escapeCharPos := escapeIndices[escapePos-1][1] - 2
431 text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
432 }
433 break
434 }
435
436
437 if colorPos < len(colorIndices) && index+tagOffset >= colorIndices[colorPos][0] && index+tagOffset < colorIndices[colorPos][1] {
438 if index <= leftIndex {
439 foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
440 style = overlayStyle(originalBackground, style, foregroundColor, backgroundColor, attributes)
441 }
442 tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
443 colorPos++
444 }
445 if escapePos < len(escapeIndices) && index+tagOffset >= escapeIndices[escapePos][0] && index+tagOffset < escapeIndices[escapePos][1] {
446 tagOffset++
447 escapePos++
448 }
449 }
450 return PrintStyle(screen, text[leftIndex+tagOffset:], x, y, maxWidth, AlignLeft, style)
451 }
452 }
453
454
455 var (
456 drawn, drawnWidth, colorPos, escapePos, tagOffset int
457 foregroundColor, backgroundColor, attributes string
458 )
459 iterateString(string(strippedText), func(main rune, comb []rune, textPos, length, screenPos, screenWidth int) bool {
460
461 if drawnWidth+screenWidth > maxWidth {
462 return true
463 }
464
465
466 for colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
467 foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
468 tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
469 colorPos++
470 }
471
472
473 if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
474 if textPos+tagOffset == escapeIndices[escapePos][1]-2 {
475 tagOffset++
476 escapePos++
477 }
478 }
479
480
481 finalX := x + drawnWidth
482 _, finalStyle, _ := screen.Get(finalX, y)
483 background := finalStyle.GetBackground()
484 finalStyle = overlayStyle(background, style, foregroundColor, backgroundColor, attributes)
485 for offset := screenWidth - 1; offset >= 0; offset-- {
486
487 if offset == 0 {
488 screen.Put(finalX+offset, y, string(append([]rune{main}, comb...)), finalStyle)
489 } else {
490 screen.Put(finalX+offset, y, " ", finalStyle)
491 }
492 }
493
494
495 drawn += length
496 drawnWidth += screenWidth
497
498 return false
499 })
500
501 return drawn + tagOffset + len(escapeIndices), drawnWidth
502 }
503
504
505 func PrintSimple(screen tcell.Screen, text []byte, x, y int) {
506 Print(screen, text, x, y, math.MaxInt32, AlignLeft, Styles.PrimaryTextColor)
507 }
508
509
510
511 func TaggedTextWidth(text []byte) int {
512 _, _, _, _, _, _, width := decomposeText(text, true, false)
513 return width
514 }
515
516
517
518 func TaggedStringWidth(text string) int {
519 return TaggedTextWidth([]byte(text))
520 }
521
522
523
524
525
526
527
528
529
530
531
532
533
534 func WordWrap(text string, width int) (lines []string) {
535 colorTagIndices, _, _, _, escapeIndices, strippedText, _ := decomposeText([]byte(text), true, false)
536
537
538 breakpoints := boundaryPattern.FindAllSubmatchIndex(strippedText, -1)
539
540
541
542
543
544 var (
545 colorPos, escapePos, breakpointPos, tagOffset int
546 lastBreakpoint, lastContinuation, currentLineStart int
547 lineWidth, overflow int
548 forceBreak bool
549 )
550 unescape := func(substr string, startIndex int) string {
551
552 for index := escapePos; index >= 0; index-- {
553 if index < len(escapeIndices) && startIndex > escapeIndices[index][0] && startIndex < escapeIndices[index][1]-1 {
554 pos := escapeIndices[index][1] - 2 - startIndex
555 if pos < 0 || pos > len(substr) {
556 return substr
557 }
558 return substr[:pos] + substr[pos+1:]
559 }
560 }
561 return substr
562 }
563 iterateString(string(strippedText), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
564
565 for {
566 if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] {
567
568 tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0]
569 colorPos++
570 } else if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 {
571
572 tagOffset++
573 escapePos++
574 } else {
575 break
576 }
577 }
578
579
580 if breakpointPos < len(breakpoints) && textPos+tagOffset == breakpoints[breakpointPos][0] {
581
582 lastBreakpoint = breakpoints[breakpointPos][0] + tagOffset
583 lastContinuation = breakpoints[breakpointPos][1] + tagOffset
584 overflow = 0
585 forceBreak = main == '\n'
586 if breakpoints[breakpointPos][6] < 0 && !forceBreak {
587 lastBreakpoint++
588 }
589 breakpointPos++
590 }
591
592
593 if forceBreak || lineWidth > 0 && lineWidth+screenWidth > width {
594 breakpoint := lastBreakpoint
595 continuation := lastContinuation
596 if forceBreak {
597 breakpoint = textPos + tagOffset
598 continuation = textPos + tagOffset + 1
599 lastBreakpoint = 0
600 overflow = 0
601 } else if lastBreakpoint <= currentLineStart {
602 breakpoint = textPos + tagOffset
603 continuation = textPos + tagOffset
604 overflow = 0
605 }
606 lines = append(lines, unescape(text[currentLineStart:breakpoint], currentLineStart))
607 currentLineStart, lineWidth, forceBreak = continuation, overflow, false
608 }
609
610
611 if lastBreakpoint > 0 && lastContinuation <= textPos+tagOffset {
612 overflow += screenWidth
613 }
614
615
616 lineWidth += screenWidth
617
618
619 if textPos+tagOffset < currentLineStart {
620 lineWidth -= screenWidth
621 }
622
623 return false
624 })
625
626
627 if currentLineStart < len(text) {
628 lines = append(lines, unescape(text[currentLineStart:], currentLineStart))
629 }
630
631 return
632 }
633
634
635
636
637
638
639
640 func EscapeBytes(text []byte) []byte {
641 return nonEscapePattern.ReplaceAll(text, []byte("$1[]"))
642 }
643
644
645
646
647
648
649
650 func Escape(text string) string {
651 return nonEscapePattern.ReplaceAllString(text, "$1[]")
652 }
653
654
655
656
657
658
659
660
661
662 func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool {
663 var screenPos int
664
665 gr := uniseg.NewGraphemes(text)
666 for gr.Next() {
667 r := gr.Runes()
668 from, to := gr.Positions()
669 width := runewidth.StringWidth(gr.Str())
670 var comb []rune
671 if len(r) > 1 {
672 comb = r[1:]
673 }
674
675 if callback(r[0], comb, from, to-from, screenPos, width) {
676 return true
677 }
678
679 screenPos += width
680 }
681
682 return false
683 }
684
685
686
687
688
689
690
691
692
693 func iterateStringReverse(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool {
694 type cluster struct {
695 main rune
696 comb []rune
697 textPos, textWidth, screenPos, screenWidth int
698 }
699
700
701 var clusters []cluster
702 iterateString(text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth int) bool {
703 clusters = append(clusters, cluster{
704 main: main,
705 comb: comb,
706 textPos: textPos,
707 textWidth: textWidth,
708 screenPos: screenPos,
709 screenWidth: screenWidth,
710 })
711 return false
712 })
713
714
715 for index := len(clusters) - 1; index >= 0; index-- {
716 if callback(
717 clusters[index].main,
718 clusters[index].comb,
719 clusters[index].textPos,
720 clusters[index].textWidth,
721 clusters[index].screenPos,
722 clusters[index].screenWidth,
723 ) {
724 return true
725 }
726 }
727
728 return false
729 }
730
731
732 type ScrollBarVisibility int
733
734 const (
735
736 ScrollBarNever ScrollBarVisibility = iota
737
738
739 ScrollBarAuto
740
741
742 ScrollBarAlways
743 )
744
745
746 var (
747 ScrollBarArea = []byte("[-:-:-]░")
748 ScrollBarAreaFocused = []byte("[-:-:-]▒")
749 ScrollBarHandle = []byte("[-:-:-]▓")
750 ScrollBarHandleFocused = []byte("[::r] [-:-:-]")
751 )
752
753
754 func RenderScrollBar(screen tcell.Screen, visibility ScrollBarVisibility, x int, y int, height int, items int, cursor int, printed int, focused bool, color tcell.Color) {
755 if visibility == ScrollBarNever || (visibility == ScrollBarAuto && items <= height) {
756 return
757 }
758
759
760 if items <= height {
761 cursor = 0
762 }
763
764
765 if cursor < 0 {
766 cursor = 0
767 }
768
769
770 handlePosition := int(float64(height-1) * (float64(cursor) / float64(items-1)))
771
772
773 var text []byte
774 if printed == handlePosition {
775 if focused {
776 text = ScrollBarHandleFocused
777 } else {
778 text = ScrollBarHandle
779 }
780 } else {
781 if focused {
782 text = ScrollBarAreaFocused
783 } else {
784 text = ScrollBarArea
785 }
786 }
787 Print(screen, text, x, y, 1, AlignLeft, color)
788 }
789
View as plain text