...

Source file src/codeberg.org/tslocum/cview/util.go

Documentation: codeberg.org/tslocum/cview

     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  // TrueColorTags is a flag which controls whether color tags should render as
    16  // the specific colors defined by tcell, or as the colors defined by the user's
    17  // terminal configuration (the default).
    18  var TrueColorTags = false
    19  
    20  // ColorUnset represents an unset color. This is necessary because the zero
    21  // value of color, ColorDefault, results in default terminal colors.
    22  var ColorUnset = tcell.ColorSpecial | 108
    23  
    24  // Horizontal alignment within a box.
    25  const (
    26  	AlignLeft = iota
    27  	AlignCenter
    28  	AlignRight
    29  )
    30  
    31  // VerticalAlignment represents vertical alignment.
    32  type VerticalAlignment int
    33  
    34  // Vertical alignment within a box.
    35  const (
    36  	AlignTop VerticalAlignment = iota
    37  	AlignMiddle
    38  	AlignBottom
    39  )
    40  
    41  // Common regular expressions.
    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  // Positions of substrings in regular expressions.
    52  const (
    53  	colorForegroundPos = 1
    54  	colorBackgroundPos = 3
    55  	colorFlagPos       = 5
    56  )
    57  
    58  // Predefined InputField acceptance functions.
    59  var (
    60  	// InputFieldInteger accepts integers.
    61  	InputFieldInteger func(text string, ch rune) bool
    62  
    63  	// InputFieldFloat accepts floating-point numbers.
    64  	InputFieldFloat func(text string, ch rune) bool
    65  
    66  	// InputFieldMaxLength returns an input field accept handler which accepts
    67  	// input strings up to a given length. Use it like this:
    68  	//
    69  	//   inputField.SetAcceptanceFunc(InputFieldMaxLength(10)) // Accept up to 10 characters.
    70  	InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool
    71  )
    72  
    73  // Transformation describes a widget state modification.
    74  type Transformation int
    75  
    76  // Widget transformations.
    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  // Package initialization.
    87  func init() {
    88  	runewidth.EastAsianWidth = true
    89  	runewidth.CreateLUT() // Create lookup table
    90  
    91  	// Initialize the predefined input field handlers.
    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  // StripTags returns the provided text without color and/or region tags.
   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  // ColorHex returns the hexadecimal value of a color as a string, prefixed with #.
   140  // If the color is invalid, a blank string is returned.
   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  // styleFromTag takes the given style, defined by a foreground color (fgColor),
   150  // a background color (bgColor), and style attributes, and modifies it based on
   151  // the substrings (tagSubstrings) extracted by the regular expression for color
   152  // tags. The new colors and attributes are returned where empty strings mean
   153  // "don't modify" and a dash ("-") means "reset to default".
   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  // overlayStyle mixes a background color with a foreground color (fgColor),
   186  // a (possibly new) background color (bgColor), and style attributes, and
   187  // returns the resulting style. For a definition of the colors and attributes,
   188  // see styleFromTag(). Reset instructions cause the corresponding part of the
   189  // default style to be used.
   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  // SetAttributes sets attributes on a style.
   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  // decomposeText returns information about a string which may contain color
   262  // tags or region tags, depending on which ones are requested to be found. It
   263  // returns the indices of the color tags (as returned by
   264  // re.FindAllStringIndex()), the color tags themselves (as returned by
   265  // re.FindAllStringSubmatch()), the indices of region tags and the region tags
   266  // themselves, the indices of an escaped tags (only if at least color tags or
   267  // region tags are requested), the string stripped by any tags and escaped, and
   268  // the screen width of the stripped string.
   269  func decomposeText(text []byte, findColors, findRegions bool) (colorIndices [][]int, colors [][][]byte, regionIndices [][]int, regions [][][]byte, escapeIndices [][]int, stripped []byte, width int) {
   270  	// Shortcut for the trivial case.
   271  	if !findColors && !findRegions {
   272  		return nil, nil, nil, nil, nil, text, runewidth.StringWidth(string(text))
   273  	}
   274  
   275  	// Get positions of any tags.
   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  	// Because the color pattern detects empty tags, we need to filter them out.
   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  	// Make a (sorted) list of all tags.
   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  	// Remove the tags from the original string.
   306  	var from int
   307  	buf := make([]byte, 0, len(text))
   308  	for _, indices := range allIndices {
   309  		if indices[2] == 2 { // Escape sequences are not simply removed.
   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  	// Get the width of the stripped string.
   322  	width = runewidth.StringWidth(string(stripped))
   323  
   324  	return
   325  }
   326  
   327  // Print prints text onto the screen into the given box at (x,y,maxWidth,1),
   328  // not exceeding that box. "align" is one of AlignLeft, AlignCenter, or
   329  // AlignRight. The screen's background color will not be changed.
   330  //
   331  // You can change the colors and text styles mid-text by inserting a color tag.
   332  // See the package description for details.
   333  //
   334  // Returns the number of actual bytes of the text printed (including color tags)
   335  // and the actual width used for the printed runes.
   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  // PrintStyle works like Print() but it takes a style instead of just a
   341  // foreground color.
   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  	// Decompose the text.
   348  	colorIndices, colors, _, _, escapeIndices, strippedText, strippedWidth := decomposeText(text, true, false)
   349  
   350  	// We want to reduce all alignments to AlignLeft.
   351  	if align == AlignRight {
   352  		if strippedWidth <= maxWidth {
   353  			// There's enough space for the entire text.
   354  			return PrintStyle(screen, text, x+maxWidth-strippedWidth, y, maxWidth, AlignLeft, style)
   355  		}
   356  		// Trim characters off the beginning.
   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  			// Update color/escape tag offset and style.
   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  				// We chopped off enough.
   376  				if escapePos > 0 && textPos+tagOffset-1 >= escapeIndices[escapePos-1][0] && textPos+tagOffset-1 < escapeIndices[escapePos-1][1] {
   377  					// Unescape open escape sequences.
   378  					escapeCharPos := escapeIndices[escapePos-1][1] - 2
   379  					text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
   380  				}
   381  				// Print and return.
   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  			// Use the exact space.
   391  			return PrintStyle(screen, text, x, y, maxWidth, AlignLeft, style)
   392  		} else if strippedWidth < maxWidth {
   393  			// We have more space than we need.
   394  			half := (maxWidth - strippedWidth) / 2
   395  			return PrintStyle(screen, text, x+half, y, maxWidth-half, AlignLeft, style)
   396  		} else {
   397  			// Chop off runes until we have a perfect fit.
   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  					// Iterate on the left by one character.
   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  					// Iterate on the right by one character.
   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  			// Add tag offsets and determine start style.
   419  			var (
   420  				colorPos, escapePos, tagOffset               int
   421  				foregroundColor, backgroundColor, attributes string
   422  			)
   423  			originalBackground := style.GetBackground()
   424  			for index := range strippedText {
   425  				// We only need the offset of the left index.
   426  				if index > leftIndex {
   427  					// We're done.
   428  					if escapePos > 0 && leftIndex+tagOffset-1 >= escapeIndices[escapePos-1][0] && leftIndex+tagOffset-1 < escapeIndices[escapePos-1][1] {
   429  						// Unescape open escape sequences.
   430  						escapeCharPos := escapeIndices[escapePos-1][1] - 2
   431  						text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
   432  					}
   433  					break
   434  				}
   435  
   436  				// Update color/escape tag offset.
   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  	// Draw text.
   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  		// Only continue if there is still space.
   461  		if drawnWidth+screenWidth > maxWidth {
   462  			return true
   463  		}
   464  
   465  		// Handle color tags.
   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  		// Handle scape tags.
   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  		// Print the rune sequence.
   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  			// To avoid undesired effects, we populate all cells.
   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  		// Advance.
   495  		drawn += length
   496  		drawnWidth += screenWidth
   497  
   498  		return false
   499  	})
   500  
   501  	return drawn + tagOffset + len(escapeIndices), drawnWidth
   502  }
   503  
   504  // PrintSimple prints white text to the screen at the given position.
   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  // TaggedTextWidth returns the width of the given string needed to print it on
   510  // screen. The text may contain color tags which are not counted.
   511  func TaggedTextWidth(text []byte) int {
   512  	_, _, _, _, _, _, width := decomposeText(text, true, false)
   513  	return width
   514  }
   515  
   516  // TaggedStringWidth returns the width of the given string needed to print it on
   517  // screen. The text may contain color tags which are not counted.
   518  func TaggedStringWidth(text string) int {
   519  	return TaggedTextWidth([]byte(text))
   520  }
   521  
   522  // WordWrap splits a text such that each resulting line does not exceed the
   523  // given screen width. Possible split points are after any punctuation or
   524  // whitespace. Whitespace after split points will be dropped.
   525  //
   526  // This function considers color tags to have no width.
   527  //
   528  // Text is always split at newline characters ('\n').
   529  //
   530  // BUG(tslocum) Text containing square brackets is not escaped properly.
   531  // Use TextView.SetWrapWidth where possible.
   532  //
   533  // Issue: https://codeberg.org/tslocum/cview/issues/27
   534  func WordWrap(text string, width int) (lines []string) {
   535  	colorTagIndices, _, _, _, escapeIndices, strippedText, _ := decomposeText([]byte(text), true, false)
   536  
   537  	// Find candidate breakpoints.
   538  	breakpoints := boundaryPattern.FindAllSubmatchIndex(strippedText, -1)
   539  	// Results in one entry for each candidate. Each entry is an array a of
   540  	// indices into strippedText where a[6] < 0 for newline/punctuation matches
   541  	// and a[4] < 0 for whitespace matches.
   542  
   543  	// Process stripped text one character at a time.
   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  		// A helper function to unescape escaped tags.
   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) { // Workaround for issue #27
   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  		// Handle tags.
   565  		for {
   566  			if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] {
   567  				// Colour tags.
   568  				tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0]
   569  				colorPos++
   570  			} else if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 {
   571  				// Escape tags.
   572  				tagOffset++
   573  				escapePos++
   574  			} else {
   575  				break
   576  			}
   577  		}
   578  
   579  		// Is this a breakpoint?
   580  		if breakpointPos < len(breakpoints) && textPos+tagOffset == breakpoints[breakpointPos][0] {
   581  			// Yes, it is. Set up breakpoint infos depending on its type.
   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++ // Don't skip punctuation.
   588  			}
   589  			breakpointPos++
   590  		}
   591  
   592  		// Check if a break is warranted.
   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  		// Remember the characters since the last breakpoint.
   611  		if lastBreakpoint > 0 && lastContinuation <= textPos+tagOffset {
   612  			overflow += screenWidth
   613  		}
   614  
   615  		// Advance.
   616  		lineWidth += screenWidth
   617  
   618  		// But if we're still inside a breakpoint, skip next character (whitespace).
   619  		if textPos+tagOffset < currentLineStart {
   620  			lineWidth -= screenWidth
   621  		}
   622  
   623  		return false
   624  	})
   625  
   626  	// Flush the rest.
   627  	if currentLineStart < len(text) {
   628  		lines = append(lines, unescape(text[currentLineStart:], currentLineStart))
   629  	}
   630  
   631  	return
   632  }
   633  
   634  // EscapeBytes escapes the given text such that color and/or region tags are not
   635  // recognized and substituted by the print functions of this package. For
   636  // example, to include a tag-like string in a box title or in a TextView:
   637  //
   638  //	box.SetTitle(cview.Escape("[squarebrackets]"))
   639  //	fmt.Fprint(textView, cview.EscapeBytes(`["quoted"]`))
   640  func EscapeBytes(text []byte) []byte {
   641  	return nonEscapePattern.ReplaceAll(text, []byte("$1[]"))
   642  }
   643  
   644  // Escape escapes the given text such that color and/or region tags are not
   645  // recognized and substituted by the print functions of this package. For
   646  // example, to include a tag-like string in a box title or in a TextView:
   647  //
   648  //	box.SetTitle(cview.Escape("[squarebrackets]"))
   649  //	fmt.Fprint(textView, cview.Escape(`["quoted"]`))
   650  func Escape(text string) string {
   651  	return nonEscapePattern.ReplaceAllString(text, "$1[]")
   652  }
   653  
   654  // iterateString iterates through the given string one printed character at a
   655  // time. For each such character, the callback function is called with the
   656  // Unicode code points of the character (the first rune and any combining runes
   657  // which may be nil if there aren't any), the starting position (in bytes)
   658  // within the original string, its length in bytes, the screen position of the
   659  // character, and the screen width of it. The iteration stops if the callback
   660  // returns true. This function returns true if the iteration was stopped before
   661  // the last character.
   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  // iterateStringReverse iterates through the given string in reverse, starting
   686  // from the end of the string, one printed character at a time. For each such
   687  // character, the callback function is called with the Unicode code points of
   688  // the character (the first rune and any combining runes which may be nil if
   689  // there aren't any), the starting position (in bytes) within the original
   690  // string, its length in bytes, the screen position of the character, and the
   691  // screen width of it. The iteration stops if the callback returns true. This
   692  // function returns true if the iteration was stopped before the last character.
   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  	// Create the grapheme clusters.
   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  	// Iterate in reverse.
   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  // ScrollBarVisibility specifies the display of a scroll bar.
   732  type ScrollBarVisibility int
   733  
   734  const (
   735  	// ScrollBarNever never shows a scroll bar.
   736  	ScrollBarNever ScrollBarVisibility = iota
   737  
   738  	// ScrollBarAuto shows a scroll bar when there are items offscreen.
   739  	ScrollBarAuto
   740  
   741  	// ScrollBarAlways always shows a scroll bar.
   742  	ScrollBarAlways
   743  )
   744  
   745  // Scroll bar render text (must be one cell wide)
   746  var (
   747  	ScrollBarArea          = []byte("[-:-:-]░")
   748  	ScrollBarAreaFocused   = []byte("[-:-:-]▒")
   749  	ScrollBarHandle        = []byte("[-:-:-]▓")
   750  	ScrollBarHandleFocused = []byte("[::r] [-:-:-]")
   751  )
   752  
   753  // RenderScrollBar renders a scroll bar at the specified position.
   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  	// Place cursor at top when there are no items offscreen.
   760  	if items <= height {
   761  		cursor = 0
   762  	}
   763  
   764  	// Handle negative cursor.
   765  	if cursor < 0 {
   766  		cursor = 0
   767  	}
   768  
   769  	// Calculate handle position.
   770  	handlePosition := int(float64(height-1) * (float64(cursor) / float64(items-1)))
   771  
   772  	// Print scroll bar.
   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