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