...

Source file src/gitlab.com/tslocum/cview/util.go

Documentation: gitlab.com/tslocum/cview

     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  // ColorUnset represents an unset color. This is necessary because the zero
    16  // value of color, ColorDefault, results in default terminal colors.
    17  var ColorUnset = tcell.ColorSpecial | 108
    18  
    19  // Text alignment within a box.
    20  const (
    21  	AlignLeft = iota
    22  	AlignCenter
    23  	AlignRight
    24  )
    25  
    26  // Common regular expressions.
    27  var (
    28  	colorPattern     = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([lbdiru]+|\-)?)?)?\]`)
    29  	regionPattern    = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`)
    30  	escapePattern    = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`)
    31  	nonEscapePattern = regexp.MustCompile(`(\[[a-zA-Z0-9_,;: \-\."#]+\[*)\]`)
    32  	boundaryPattern  = regexp.MustCompile(`(([,\.\-:;!\?&#+]|\n)[ \t\f\r]*|([ \t\f\r]+))`)
    33  	spacePattern     = regexp.MustCompile(`\s+`)
    34  )
    35  
    36  // Positions of substrings in regular expressions.
    37  const (
    38  	colorForegroundPos = 1
    39  	colorBackgroundPos = 3
    40  	colorFlagPos       = 5
    41  )
    42  
    43  // Predefined InputField acceptance functions.
    44  var (
    45  	// InputFieldInteger accepts integers.
    46  	InputFieldInteger func(text string, ch rune) bool
    47  
    48  	// InputFieldFloat accepts floating-point numbers.
    49  	InputFieldFloat func(text string, ch rune) bool
    50  
    51  	// InputFieldMaxLength returns an input field accept handler which accepts
    52  	// input strings up to a given length. Use it like this:
    53  	//
    54  	//   inputField.SetAcceptanceFunc(InputFieldMaxLength(10)) // Accept up to 10 characters.
    55  	InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool
    56  )
    57  
    58  // Transformation describes a widget modification.
    59  type Transformation int
    60  
    61  // Widget transformations.
    62  const (
    63  	TransformFirstItem    Transformation = 1
    64  	TransformLastItem     Transformation = 2
    65  	TransformPreviousItem Transformation = 3
    66  	TransformNextItem     Transformation = 4
    67  	TransformPreviousPage Transformation = 5
    68  	TransformNextPage     Transformation = 6
    69  )
    70  
    71  // Package initialization.
    72  func init() {
    73  	runewidth.EastAsianWidth = true
    74  
    75  	// We'll use zero width joiners.
    76  	runewidth.ZeroWidthJoiner = true
    77  
    78  	// Initialize the predefined input field handlers.
    79  	InputFieldInteger = func(text string, ch rune) bool {
    80  		if text == "-" {
    81  			return true
    82  		}
    83  		_, err := strconv.Atoi(text)
    84  		return err == nil
    85  	}
    86  	InputFieldFloat = func(text string, ch rune) bool {
    87  		if text == "-" || text == "." || text == "-." {
    88  			return true
    89  		}
    90  		_, err := strconv.ParseFloat(text, 64)
    91  		return err == nil
    92  	}
    93  	InputFieldMaxLength = func(maxLength int) func(text string, ch rune) bool {
    94  		return func(text string, ch rune) bool {
    95  			return len([]rune(text)) <= maxLength
    96  		}
    97  	}
    98  }
    99  
   100  // StripTags returns the provided text without color and/or region tags.
   101  func StripTags(text []byte, colors bool, regions bool) []byte {
   102  	if !colors && !regions {
   103  		stripped := make([]byte, len(text))
   104  		copy(stripped, text)
   105  		return stripped
   106  	}
   107  
   108  	var stripped []byte
   109  	src := text
   110  	if regions {
   111  		stripped = regionPattern.ReplaceAll(text, nil)
   112  		src = stripped
   113  	}
   114  	if colors {
   115  		stripped = colorPattern.ReplaceAllFunc(src, func(match []byte) []byte {
   116  			if len(match) > 2 {
   117  				return nil
   118  			}
   119  			return match
   120  		})
   121  	}
   122  
   123  	return escapePattern.ReplaceAll(stripped, []byte(`[$1$2]`))
   124  }
   125  
   126  // ColorHex returns the hexadecimal value of a color as a string, prefixed with #.
   127  // If the color is invalid, a blank string is returned.
   128  func ColorHex(c tcell.Color) string {
   129  	if !c.Valid() {
   130  		return ""
   131  	}
   132  	r, g, b := c.RGB()
   133  	return fmt.Sprintf("#%02x%02x%02x", r, g, b)
   134  }
   135  
   136  // styleFromTag takes the given style, defined by a foreground color (fgColor),
   137  // a background color (bgColor), and style attributes, and modifies it based on
   138  // the substrings (tagSubstrings) extracted by the regular expression for color
   139  // tags. The new colors and attributes are returned where empty strings mean
   140  // "don't modify" and a dash ("-") means "reset to default".
   141  func styleFromTag(fgColor, bgColor, attributes string, tagSubstrings [][]byte) (newFgColor, newBgColor, newAttributes string) {
   142  	if len(tagSubstrings[colorForegroundPos]) > 0 {
   143  		color := string(tagSubstrings[colorForegroundPos])
   144  		if color == "-" {
   145  			fgColor = "-"
   146  		} else if color != "" {
   147  			fgColor = color
   148  		}
   149  	}
   150  
   151  	if len(tagSubstrings[colorBackgroundPos-1]) > 0 {
   152  		color := string(tagSubstrings[colorBackgroundPos])
   153  		if color == "-" {
   154  			bgColor = "-"
   155  		} else if color != "" {
   156  			bgColor = color
   157  		}
   158  	}
   159  
   160  	if len(tagSubstrings[colorFlagPos-1]) > 0 {
   161  		flags := string(tagSubstrings[colorFlagPos])
   162  		if flags == "-" {
   163  			attributes = "-"
   164  		} else if flags != "" {
   165  			attributes = flags
   166  		}
   167  	}
   168  
   169  	return fgColor, bgColor, attributes
   170  }
   171  
   172  // overlayStyle mixes a background color with a foreground color (fgColor),
   173  // a (possibly new) background color (bgColor), and style attributes, and
   174  // returns the resulting style. For a definition of the colors and attributes,
   175  // see styleFromTag(). Reset instructions cause the corresponding part of the
   176  // default style to be used.
   177  func overlayStyle(background tcell.Color, defaultStyle tcell.Style, fgColor, bgColor, attributes string) tcell.Style {
   178  	defFg, defBg, defAttr := defaultStyle.Decompose()
   179  	style := defaultStyle.Background(background)
   180  
   181  	style = style.Foreground(defFg)
   182  	if fgColor != "" {
   183  		if fgColor == "-" {
   184  			style = style.Foreground(defFg)
   185  		} else {
   186  			style = style.Foreground(tcell.GetColor(fgColor).TrueColor())
   187  		}
   188  	}
   189  
   190  	if bgColor == "-" || bgColor == "" && defBg != tcell.ColorDefault {
   191  		style = style.Background(defBg)
   192  	} else if bgColor != "" {
   193  		style = style.Background(tcell.GetColor(bgColor).TrueColor())
   194  	}
   195  
   196  	if attributes == "-" {
   197  		style = style.Bold(defAttr&tcell.AttrBold > 0)
   198  		style = style.Blink(defAttr&tcell.AttrBlink > 0)
   199  		style = style.Dim(defAttr&tcell.AttrDim > 0)
   200  		style = style.Italic(defAttr&tcell.AttrItalic > 0)
   201  		style = style.Reverse(defAttr&tcell.AttrReverse > 0)
   202  		style = style.Underline(defAttr&tcell.AttrUnderline > 0)
   203  		style = style.StrikeThrough(defAttr&tcell.AttrStrikeThrough > 0)
   204  	} else if attributes != "" {
   205  		style = style.Normal()
   206  		for _, flag := range attributes {
   207  			switch flag {
   208  			case 'l':
   209  				style = style.Blink(true)
   210  			case 'b':
   211  				style = style.Bold(true)
   212  			case 'd':
   213  				style = style.Dim(true)
   214  			case 'i':
   215  				style = style.Italic(true)
   216  			case 'r':
   217  				style = style.Reverse(true)
   218  			case 'u':
   219  				style = style.Underline(true)
   220  			case 's':
   221  				style = style.StrikeThrough(true)
   222  			}
   223  		}
   224  	}
   225  
   226  	return style
   227  }
   228  
   229  // SetAttributes sets attributes on a style.
   230  func SetAttributes(style tcell.Style, attrs tcell.AttrMask) tcell.Style {
   231  	return style.
   232  		Blink(attrs&tcell.AttrBlink != 0).
   233  		Bold(attrs&tcell.AttrBold != 0).
   234  		Dim(attrs&tcell.AttrDim != 0).
   235  		Italic(attrs&tcell.AttrItalic != 0).
   236  		Reverse(attrs&tcell.AttrReverse != 0).
   237  		Underline(attrs&tcell.AttrUnderline != 0)
   238  }
   239  
   240  // decomposeText returns information about a string which may contain color
   241  // tags or region tags, depending on which ones are requested to be found. It
   242  // returns the indices of the color tags (as returned by
   243  // re.FindAllStringIndex()), the color tags themselves (as returned by
   244  // re.FindAllStringSubmatch()), the indices of region tags and the region tags
   245  // themselves, the indices of an escaped tags (only if at least color tags or
   246  // region tags are requested), the string stripped by any tags and escaped, and
   247  // the screen width of the stripped string.
   248  func decomposeText(text []byte, findColors, findRegions bool) (colorIndices [][]int, colors [][][]byte, regionIndices [][]int, regions [][][]byte, escapeIndices [][]int, stripped []byte, width int) {
   249  	// Shortcut for the trivial case.
   250  	if !findColors && !findRegions {
   251  		return nil, nil, nil, nil, nil, text, runewidth.StringWidth(string(text))
   252  	}
   253  
   254  	// Get positions of any tags.
   255  	if findColors {
   256  		colorIndices = colorPattern.FindAllIndex(text, -1)
   257  		colors = colorPattern.FindAllSubmatch(text, -1)
   258  	}
   259  	if findRegions {
   260  		regionIndices = regionPattern.FindAllIndex(text, -1)
   261  		regions = regionPattern.FindAllSubmatch(text, -1)
   262  	}
   263  	escapeIndices = escapePattern.FindAllIndex(text, -1)
   264  
   265  	// Because the color pattern detects empty tags, we need to filter them out.
   266  	for i := len(colorIndices) - 1; i >= 0; i-- {
   267  		if colorIndices[i][1]-colorIndices[i][0] == 2 {
   268  			colorIndices = append(colorIndices[:i], colorIndices[i+1:]...)
   269  			colors = append(colors[:i], colors[i+1:]...)
   270  		}
   271  	}
   272  
   273  	// Make a (sorted) list of all tags.
   274  	var allIndices [][]int
   275  	if findColors && findRegions {
   276  		allIndices = colorIndices
   277  		allIndices = make([][]int, len(colorIndices)+len(regionIndices))
   278  		copy(allIndices, colorIndices)
   279  		copy(allIndices[len(colorIndices):], regionIndices)
   280  		sort.Slice(allIndices, func(i int, j int) bool {
   281  			return allIndices[i][0] < allIndices[j][0]
   282  		})
   283  	} else if findColors {
   284  		allIndices = colorIndices
   285  	} else {
   286  		allIndices = regionIndices
   287  	}
   288  
   289  	// Remove the tags from the original string.
   290  	var from int
   291  	buf := make([]byte, 0, len(text))
   292  	for _, indices := range allIndices {
   293  		buf = append(buf, text[from:indices[0]]...)
   294  		from = indices[1]
   295  	}
   296  	buf = append(buf, text[from:]...)
   297  
   298  	// Escape string.
   299  	stripped = escapePattern.ReplaceAll(buf, []byte("[$1$2]"))
   300  
   301  	// Get the width of the stripped string.
   302  	width = runewidth.StringWidth(string(stripped))
   303  
   304  	return
   305  }
   306  
   307  // Print prints text onto the screen into the given box at (x,y,maxWidth,1),
   308  // not exceeding that box. "align" is one of AlignLeft, AlignCenter, or
   309  // AlignRight. The screen's background color will not be changed.
   310  //
   311  // You can change the colors and text styles mid-text by inserting a color tag.
   312  // See the package description for details.
   313  //
   314  // Returns the number of actual bytes of the text printed (including color tags)
   315  // and the actual width used for the printed runes.
   316  func Print(screen tcell.Screen, text []byte, x, y, maxWidth, align int, color tcell.Color) (int, int) {
   317  	return PrintStyle(screen, text, x, y, maxWidth, align, tcell.StyleDefault.Foreground(color))
   318  }
   319  
   320  // PrintStyle works like Print() but it takes a style instead of just a
   321  // foreground color.
   322  func PrintStyle(screen tcell.Screen, text []byte, x, y, maxWidth, align int, style tcell.Style) (int, int) {
   323  	if maxWidth <= 0 || len(text) == 0 {
   324  		return 0, 0
   325  	}
   326  
   327  	// Decompose the text.
   328  	colorIndices, colors, _, _, escapeIndices, strippedText, strippedWidth := decomposeText(text, true, false)
   329  
   330  	// We want to reduce all alignments to AlignLeft.
   331  	if align == AlignRight {
   332  		if strippedWidth <= maxWidth {
   333  			// There's enough space for the entire text.
   334  			return PrintStyle(screen, text, x+maxWidth-strippedWidth, y, maxWidth, AlignLeft, style)
   335  		}
   336  		// Trim characters off the beginning.
   337  		var (
   338  			bytes, width, colorPos, escapePos, tagOffset int
   339  			foregroundColor, backgroundColor, attributes string
   340  		)
   341  		_, originalBackground, _ := style.Decompose()
   342  		iterateString(string(strippedText), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
   343  			// Update color/escape tag offset and style.
   344  			if colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
   345  				foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
   346  				style = overlayStyle(originalBackground, style, foregroundColor, backgroundColor, attributes)
   347  				tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
   348  				colorPos++
   349  			}
   350  			if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
   351  				tagOffset++
   352  				escapePos++
   353  			}
   354  			if strippedWidth-screenPos < maxWidth {
   355  				// We chopped off enough.
   356  				if escapePos > 0 && textPos+tagOffset-1 >= escapeIndices[escapePos-1][0] && textPos+tagOffset-1 < escapeIndices[escapePos-1][1] {
   357  					// Unescape open escape sequences.
   358  					escapeCharPos := escapeIndices[escapePos-1][1] - 2
   359  					text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
   360  				}
   361  				// Print and return.
   362  				bytes, width = PrintStyle(screen, text[textPos+tagOffset:], x, y, maxWidth, AlignLeft, style)
   363  				return true
   364  			}
   365  			return false
   366  		})
   367  		return bytes, width
   368  	} else if align == AlignCenter {
   369  		if strippedWidth == maxWidth {
   370  			// Use the exact space.
   371  			return PrintStyle(screen, text, x, y, maxWidth, AlignLeft, style)
   372  		} else if strippedWidth < maxWidth {
   373  			// We have more space than we need.
   374  			half := (maxWidth - strippedWidth) / 2
   375  			return PrintStyle(screen, text, x+half, y, maxWidth-half, AlignLeft, style)
   376  		} else {
   377  			// Chop off runes until we have a perfect fit.
   378  			var choppedLeft, choppedRight, leftIndex, rightIndex int
   379  			rightIndex = len(strippedText)
   380  			for rightIndex-1 > leftIndex && strippedWidth-choppedLeft-choppedRight > maxWidth {
   381  				if choppedLeft < choppedRight {
   382  					// Iterate on the left by one character.
   383  					iterateString(string(strippedText[leftIndex:]), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
   384  						choppedLeft += screenWidth
   385  						leftIndex += textWidth
   386  						return true
   387  					})
   388  				} else {
   389  					// Iterate on the right by one character.
   390  					iterateStringReverse(string(strippedText[leftIndex:rightIndex]), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
   391  						choppedRight += screenWidth
   392  						rightIndex -= textWidth
   393  						return true
   394  					})
   395  				}
   396  			}
   397  
   398  			// Add tag offsets and determine start style.
   399  			var (
   400  				colorPos, escapePos, tagOffset               int
   401  				foregroundColor, backgroundColor, attributes string
   402  			)
   403  			_, originalBackground, _ := style.Decompose()
   404  			for index := range strippedText {
   405  				// We only need the offset of the left index.
   406  				if index > leftIndex {
   407  					// We're done.
   408  					if escapePos > 0 && leftIndex+tagOffset-1 >= escapeIndices[escapePos-1][0] && leftIndex+tagOffset-1 < escapeIndices[escapePos-1][1] {
   409  						// Unescape open escape sequences.
   410  						escapeCharPos := escapeIndices[escapePos-1][1] - 2
   411  						text = append(text[:escapeCharPos], text[escapeCharPos+1:]...)
   412  					}
   413  					break
   414  				}
   415  
   416  				// Update color/escape tag offset.
   417  				if colorPos < len(colorIndices) && index+tagOffset >= colorIndices[colorPos][0] && index+tagOffset < colorIndices[colorPos][1] {
   418  					if index <= leftIndex {
   419  						foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
   420  						style = overlayStyle(originalBackground, style, foregroundColor, backgroundColor, attributes)
   421  					}
   422  					tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
   423  					colorPos++
   424  				}
   425  				if escapePos < len(escapeIndices) && index+tagOffset >= escapeIndices[escapePos][0] && index+tagOffset < escapeIndices[escapePos][1] {
   426  					tagOffset++
   427  					escapePos++
   428  				}
   429  			}
   430  			return PrintStyle(screen, text[leftIndex+tagOffset:], x, y, maxWidth, AlignLeft, style)
   431  		}
   432  	}
   433  
   434  	// Draw text.
   435  	var (
   436  		drawn, drawnWidth, colorPos, escapePos, tagOffset int
   437  		foregroundColor, backgroundColor, attributes      string
   438  	)
   439  	iterateString(string(strippedText), func(main rune, comb []rune, textPos, length, screenPos, screenWidth int) bool {
   440  		// Only continue if there is still space.
   441  		if drawnWidth+screenWidth > maxWidth {
   442  			return true
   443  		}
   444  
   445  		// Handle color tags.
   446  		for colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] {
   447  			foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos])
   448  			tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0]
   449  			colorPos++
   450  		}
   451  
   452  		// Handle scape tags.
   453  		if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] {
   454  			if textPos+tagOffset == escapeIndices[escapePos][1]-2 {
   455  				tagOffset++
   456  				escapePos++
   457  			}
   458  		}
   459  
   460  		// Print the rune sequence.
   461  		finalX := x + drawnWidth
   462  		_, _, finalStyle, _ := screen.GetContent(finalX, y)
   463  		_, background, _ := finalStyle.Decompose()
   464  		finalStyle = overlayStyle(background, style, foregroundColor, backgroundColor, attributes)
   465  		for offset := screenWidth - 1; offset >= 0; offset-- {
   466  			// To avoid undesired effects, we populate all cells.
   467  			if offset == 0 {
   468  				screen.SetContent(finalX+offset, y, main, comb, finalStyle)
   469  			} else {
   470  				screen.SetContent(finalX+offset, y, ' ', nil, finalStyle)
   471  			}
   472  		}
   473  
   474  		// Advance.
   475  		drawn += length
   476  		drawnWidth += screenWidth
   477  
   478  		return false
   479  	})
   480  
   481  	return drawn + tagOffset + len(escapeIndices), drawnWidth
   482  }
   483  
   484  // PrintSimple prints white text to the screen at the given position.
   485  func PrintSimple(screen tcell.Screen, text []byte, x, y int) {
   486  	Print(screen, text, x, y, math.MaxInt32, AlignLeft, Styles.PrimaryTextColor)
   487  }
   488  
   489  // TaggedTextWidth returns the width of the given string needed to print it on
   490  // screen. The text may contain color tags which are not counted.
   491  func TaggedTextWidth(text []byte) int {
   492  	_, _, _, _, _, _, width := decomposeText(text, true, false)
   493  	return width
   494  }
   495  
   496  // TaggedStringWidth returns the width of the given string needed to print it on
   497  // screen. The text may contain color tags which are not counted.
   498  func TaggedStringWidth(text string) int {
   499  	return TaggedTextWidth([]byte(text))
   500  }
   501  
   502  // WordWrap splits a text such that each resulting line does not exceed the
   503  // given screen width. Possible split points are after any punctuation or
   504  // whitespace. Whitespace after split points will be dropped.
   505  //
   506  // This function considers color tags to have no width.
   507  //
   508  // Text is always split at newline characters ('\n').
   509  //
   510  // Text must not be escaped.
   511  func WordWrap(text string, width int) (lines []string) {
   512  	colorTagIndices, _, _, _, escapeIndices, strippedText, _ := decomposeText([]byte(text), true, false)
   513  
   514  	// Find candidate breakpoints.
   515  	breakpoints := boundaryPattern.FindAllSubmatchIndex(strippedText, -1)
   516  	// Results in one entry for each candidate. Each entry is an array a of
   517  	// indices into strippedText where a[6] < 0 for newline/punctuation matches
   518  	// and a[4] < 0 for whitespace matches.
   519  
   520  	// Process stripped text one character at a time.
   521  	var (
   522  		colorPos, escapePos, breakpointPos, tagOffset      int
   523  		lastBreakpoint, lastContinuation, currentLineStart int
   524  		lineWidth, overflow                                int
   525  		forceBreak                                         bool
   526  	)
   527  	unescape := func(substr string, startIndex int) string {
   528  		// A helper function to unescape escaped tags.
   529  		for index := escapePos; index >= 0; index-- {
   530  			if index < len(escapeIndices) && startIndex > escapeIndices[index][0] && startIndex < escapeIndices[index][1]-1 {
   531  				pos := escapeIndices[index][1] - 2 - startIndex
   532  				if pos < 0 || pos > len(substr) {
   533  					return substr
   534  				}
   535  				return substr[:pos] + substr[pos+1:]
   536  			}
   537  		}
   538  		return substr
   539  	}
   540  	iterateString(string(strippedText), func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool {
   541  		// Handle tags.
   542  		for {
   543  			if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] {
   544  				// Colour tags.
   545  				tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0]
   546  				colorPos++
   547  			} else if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 {
   548  				// Escape tags.
   549  				tagOffset++
   550  				escapePos++
   551  			} else {
   552  				break
   553  			}
   554  		}
   555  
   556  		// Is this a breakpoint?
   557  		if breakpointPos < len(breakpoints) && textPos+tagOffset == breakpoints[breakpointPos][0]+1 {
   558  			// Yes, it is. Set up breakpoint infos depending on its type.
   559  			lastBreakpoint = breakpoints[breakpointPos][0] + tagOffset
   560  			lastContinuation = breakpoints[breakpointPos][1] + tagOffset
   561  			overflow = 0
   562  			forceBreak = main == '\n'
   563  			if breakpoints[breakpointPos][6] < 0 && !forceBreak {
   564  				lastBreakpoint++ // Don't skip punctuation.
   565  			}
   566  			breakpointPos++
   567  		}
   568  
   569  		// Check if a break is warranted.
   570  		if forceBreak || lineWidth > 0 && lineWidth+screenWidth > width {
   571  			breakpoint := lastBreakpoint
   572  			continuation := lastContinuation
   573  			if forceBreak {
   574  				breakpoint = textPos + tagOffset
   575  				continuation = textPos + tagOffset + 1
   576  				lastBreakpoint = 0
   577  				overflow = 0
   578  			} else if lastBreakpoint <= currentLineStart {
   579  				breakpoint = textPos + tagOffset
   580  				continuation = textPos + tagOffset
   581  				overflow = 0
   582  			}
   583  			lines = append(lines, unescape(text[currentLineStart:breakpoint], currentLineStart))
   584  			currentLineStart, lineWidth, forceBreak = continuation, overflow, false
   585  		}
   586  
   587  		// Remember the characters since the last breakpoint.
   588  		if lastBreakpoint > 0 && lastContinuation <= textPos+tagOffset {
   589  			overflow += screenWidth
   590  		}
   591  
   592  		// Advance.
   593  		lineWidth += screenWidth
   594  
   595  		// But if we're still inside a breakpoint, skip next character (whitespace).
   596  		if textPos+tagOffset < currentLineStart {
   597  			lineWidth -= screenWidth
   598  		}
   599  
   600  		return false
   601  	})
   602  
   603  	// Flush the rest.
   604  	if currentLineStart < len(text) {
   605  		lines = append(lines, unescape(text[currentLineStart:], currentLineStart))
   606  	}
   607  
   608  	return
   609  }
   610  
   611  // EscapeBytes escapes the given text such that color and/or region tags are not
   612  // recognized and substituted by the print functions of this package. For
   613  // example, to include a tag-like string in a box title or in a TextView:
   614  //
   615  //   box.SetTitle(cview.Escape("[squarebrackets]"))
   616  //   fmt.Fprint(textView, cview.EscapeBytes(`["quoted"]`))
   617  func EscapeBytes(text []byte) []byte {
   618  	return nonEscapePattern.ReplaceAll(text, []byte("$1[]"))
   619  }
   620  
   621  // Escape escapes the given text such that color and/or region tags are not
   622  // recognized and substituted by the print functions of this package. For
   623  // example, to include a tag-like string in a box title or in a TextView:
   624  //
   625  //   box.SetTitle(cview.Escape("[squarebrackets]"))
   626  //   fmt.Fprint(textView, cview.Escape(`["quoted"]`))
   627  func Escape(text string) string {
   628  	return nonEscapePattern.ReplaceAllString(text, "$1[]")
   629  }
   630  
   631  // iterateString iterates through the given string one printed character at a
   632  // time. For each such character, the callback function is called with the
   633  // Unicode code points of the character (the first rune and any combining runes
   634  // which may be nil if there aren't any), the starting position (in bytes)
   635  // within the original string, its length in bytes, the screen position of the
   636  // character, and the screen width of it. The iteration stops if the callback
   637  // returns true. This function returns true if the iteration was stopped before
   638  // the last character.
   639  func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool {
   640  	var screenPos int
   641  
   642  	gr := uniseg.NewGraphemes(text)
   643  	for gr.Next() {
   644  		r := gr.Runes()
   645  		from, to := gr.Positions()
   646  		width := runewidth.StringWidth(gr.Str())
   647  		var comb []rune
   648  		if len(r) > 1 {
   649  			comb = r[1:]
   650  		}
   651  
   652  		if callback(r[0], comb, from, to-from, screenPos, width) {
   653  			return true
   654  		}
   655  
   656  		screenPos += width
   657  	}
   658  
   659  	return false
   660  }
   661  
   662  // iterateStringReverse iterates through the given string in reverse, starting
   663  // from the end of the string, one printed character at a time. For each such
   664  // character, the callback function is called with the Unicode code points of
   665  // the character (the first rune and any combining runes which may be nil if
   666  // there aren't any), the starting position (in bytes) within the original
   667  // string, its length in bytes, the screen position of the character, and the
   668  // screen width of it. The iteration stops if the callback returns true. This
   669  // function returns true if the iteration was stopped before the last character.
   670  func iterateStringReverse(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool {
   671  	type cluster struct {
   672  		main                                       rune
   673  		comb                                       []rune
   674  		textPos, textWidth, screenPos, screenWidth int
   675  	}
   676  
   677  	// Create the grapheme clusters.
   678  	var clusters []cluster
   679  	iterateString(text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth int) bool {
   680  		clusters = append(clusters, cluster{
   681  			main:        main,
   682  			comb:        comb,
   683  			textPos:     textPos,
   684  			textWidth:   textWidth,
   685  			screenPos:   screenPos,
   686  			screenWidth: screenWidth,
   687  		})
   688  		return false
   689  	})
   690  
   691  	// Iterate in reverse.
   692  	for index := len(clusters) - 1; index >= 0; index-- {
   693  		if callback(
   694  			clusters[index].main,
   695  			clusters[index].comb,
   696  			clusters[index].textPos,
   697  			clusters[index].textWidth,
   698  			clusters[index].screenPos,
   699  			clusters[index].screenWidth,
   700  		) {
   701  			return true
   702  		}
   703  	}
   704  
   705  	return false
   706  }
   707  
   708  // ScrollBarVisibility specifies the display of a scroll bar.
   709  type ScrollBarVisibility int
   710  
   711  const (
   712  	// ScrollBarNever never shows a scroll bar.
   713  	ScrollBarNever ScrollBarVisibility = iota
   714  
   715  	// ScrollBarAuto shows a scroll bar when there are items offscreen.
   716  	ScrollBarAuto
   717  
   718  	// ScrollBarAlways always shows a scroll bar.
   719  	ScrollBarAlways
   720  )
   721  
   722  // Scroll bar render text (must be one cell wide)
   723  var (
   724  	ScrollBarArea          = []byte("[-:-:-]░")
   725  	ScrollBarAreaFocused   = []byte("[-:-:-]▒")
   726  	ScrollBarHandle        = []byte("[-:-:-]▓")
   727  	ScrollBarHandleFocused = []byte("[::r] [-:-:-]")
   728  )
   729  
   730  // RenderScrollBar renders a scroll bar at the specified position.
   731  func RenderScrollBar(screen tcell.Screen, visibility ScrollBarVisibility, x int, y int, height int, items int, cursor int, printed int, focused bool, color tcell.Color) {
   732  	if visibility == ScrollBarNever || (visibility == ScrollBarAuto && items <= height) {
   733  		return
   734  	}
   735  
   736  	// Place cursor at top when there are no items offscreen.
   737  	if items <= height {
   738  		cursor = 0
   739  	}
   740  
   741  	// Handle negative cursor.
   742  	if cursor < 0 {
   743  		cursor = 0
   744  	}
   745  
   746  	// Calculate handle position.
   747  	handlePosition := int(float64(height-1) * (float64(cursor) / float64(items-1)))
   748  
   749  	// Print scroll bar.
   750  	var text []byte
   751  	if printed == handlePosition {
   752  		if focused {
   753  			text = ScrollBarHandleFocused
   754  		} else {
   755  			text = ScrollBarHandle
   756  		}
   757  	} else {
   758  		if focused {
   759  			text = ScrollBarAreaFocused
   760  		} else {
   761  			text = ScrollBarArea
   762  		}
   763  	}
   764  	Print(screen, text, x, y, 1, AlignLeft, color)
   765  }
   766  

View as plain text