tview/util.go

347 lines
10 KiB
Go

package tview
import (
"math"
"regexp"
"strconv"
"strings"
"github.com/gdamore/tcell"
runewidth "github.com/mattn/go-runewidth"
)
// Text alignment within a box.
const (
AlignLeft = iota
AlignCenter
AlignRight
)
// Semigraphical runes.
const (
GraphicsHoriBar = '\u2500'
GraphicsVertBar = '\u2502'
GraphicsTopLeftCorner = '\u250c'
GraphicsTopRightCorner = '\u2510'
GraphicsBottomRightCorner = '\u2518'
GraphicsBottomLeftCorner = '\u2514'
GraphicsDbVertBar = '\u2550'
GraphicsDbHorBar = '\u2551'
GraphicsDbTopLeftCorner = '\u2554'
GraphicsDbTopRightCorner = '\u2557'
GraphicsDbBottomRightCorner = '\u255d'
GraphicsDbBottomLeftCorner = '\u255a'
GraphicsRightT = '\u2524'
GraphicsLeftT = '\u251c'
GraphicsTopT = '\u252c'
GraphicsBottomT = '\u2534'
GraphicsCross = '\u253c'
GraphicsEllipsis = '\u2026'
)
// Common regular expressions.
var (
colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6})\]`)
regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`)
escapePattern = regexp.MustCompile(`\[("[a-zA-Z0-9_,;: \-\.]*"|[a-zA-Z]+|#[0-9a-zA-Z]{6})\[(\[*)\]`)
boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)")
spacePattern = regexp.MustCompile(`\s+`)
)
// Predefined InputField acceptance functions.
var (
// InputFieldInteger accepts integers.
InputFieldInteger func(text string, ch rune) bool
// InputFieldFloat accepts floating-point numbers.
InputFieldFloat func(text string, ch rune) bool
// InputFieldMaxLength returns an input field accept handler which accepts
// input strings up to a given length. Use it like this:
//
// inputField.SetAcceptanceFunc(InputFieldMaxLength(10)) // Accept up to 10 characters.
InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool
)
// Package initialization.
func init() {
// Initialize the predefined input field handlers.
InputFieldInteger = func(text string, ch rune) bool {
if text == "-" {
return true
}
_, err := strconv.Atoi(text)
return err == nil
}
InputFieldFloat = func(text string, ch rune) bool {
if text == "-" || text == "." || text == "-." {
return true
}
_, err := strconv.ParseFloat(text, 64)
return err == nil
}
InputFieldMaxLength = func(maxLength int) func(text string, ch rune) bool {
return func(text string, ch rune) bool {
return len([]rune(text)) <= maxLength
}
}
}
// Print prints text onto the screen into the given box at (x,y,maxWidth,1),
// not exceeding that box. "align" is one of AlignLeft, AlignCenter, or
// AlignRight. The screen's background color will not be changed.
//
// You can change the text color mid-text by inserting a color tag. See the
// package description for details.
//
// Returns the number of actual runes printed (not including color tags) and the
// actual width used for the printed runes.
func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tcell.Color) (int, int) {
if maxWidth < 0 {
return 0, 0
}
// Get positions of color and escape tags. Remove them from original string.
colorIndices := colorPattern.FindAllStringIndex(text, -1)
colors := colorPattern.FindAllStringSubmatch(text, -1)
escapeIndices := escapePattern.FindAllStringIndex(text, -1)
strippedText := escapePattern.ReplaceAllString(colorPattern.ReplaceAllString(text, ""), "[$1$2]")
// We deal with runes, not with bytes.
runes := []rune(strippedText)
// This helper function takes positions for a substring of "runes" and a start
// color and returns the substring with the original tags and the new start
// color.
substring := func(from, to int, color tcell.Color) (string, tcell.Color) {
var colorPos, escapePos, runePos, startPos int
for pos := range text {
// Handle color tags.
if colorPos < len(colorIndices) && pos >= colorIndices[colorPos][0] && pos < colorIndices[colorPos][1] {
if pos == colorIndices[colorPos][1]-1 {
if runePos <= from {
color = tcell.GetColor(colors[colorPos][1])
}
colorPos++
}
continue
}
// Handle escape tags.
if escapePos < len(escapeIndices) && pos >= escapeIndices[escapePos][0] && pos < escapeIndices[escapePos][1] {
if pos == escapeIndices[escapePos][1]-1 {
escapePos++
} else if pos == escapeIndices[escapePos][1]-2 {
continue
}
}
// Check boundaries.
if runePos == from {
startPos = pos
} else if runePos >= to {
return text[startPos:pos], color
}
runePos++
}
return text[startPos:len(text)], color
}
// We want to reduce everything to AlignLeft.
if align == AlignRight {
width := 0
start := len(runes)
for index := start - 1; index >= 0; index-- {
w := runewidth.RuneWidth(runes[index])
if width+w > maxWidth {
break
}
width += w
start = index
}
text, color = substring(start, len(runes), color)
return Print(screen, text, x+maxWidth-width, y, width, AlignLeft, color)
} else if align == AlignCenter {
width := runewidth.StringWidth(strippedText)
if width == maxWidth {
// Use the exact space.
return Print(screen, text, x, y, maxWidth, AlignLeft, color)
} else if width < maxWidth {
// We have more space than we need.
half := (maxWidth - width) / 2
return Print(screen, text, x+half, y, maxWidth-half, AlignLeft, color)
} else {
// Chop off runes until we have a perfect fit.
var choppedLeft, choppedRight, leftIndex, rightIndex int
rightIndex = len(runes) - 1
for rightIndex > leftIndex && width-choppedLeft-choppedRight > maxWidth {
leftWidth := runewidth.RuneWidth(runes[leftIndex])
rightWidth := runewidth.RuneWidth(runes[rightIndex])
if choppedLeft < choppedRight {
choppedLeft += leftWidth
leftIndex++
} else {
choppedRight += rightWidth
rightIndex--
}
}
text, color = substring(leftIndex, rightIndex, color)
return Print(screen, text, x, y, maxWidth, AlignLeft, color)
}
}
// Draw text.
drawn := 0
drawnWidth := 0
var colorPos, escapePos int
for pos, ch := range text {
// Handle color tags.
if colorPos < len(colorIndices) && pos >= colorIndices[colorPos][0] && pos < colorIndices[colorPos][1] {
if pos == colorIndices[colorPos][1]-1 {
color = tcell.GetColor(colors[colorPos][1])
colorPos++
}
continue
}
// Handle escape tags.
if escapePos < len(escapeIndices) && pos >= escapeIndices[escapePos][0] && pos < escapeIndices[escapePos][1] {
if pos == escapeIndices[escapePos][1]-1 {
escapePos++
} else if pos == escapeIndices[escapePos][1]-2 {
continue
}
}
// Check if we have enough space for this rune.
chWidth := runewidth.RuneWidth(ch)
if drawnWidth+chWidth > maxWidth {
break
}
finalX := x + drawnWidth
// Print the rune.
_, _, style, _ := screen.GetContent(finalX, y)
style = style.Foreground(color)
for offset := 0; offset < chWidth; offset++ {
// To avoid undesired effects, we place the same character in all cells.
screen.SetContent(finalX+offset, y, ch, nil, style)
}
drawn++
drawnWidth += chWidth
}
return drawn, drawnWidth
}
// PrintSimple prints white text to the screen at the given position.
func PrintSimple(screen tcell.Screen, text string, x, y int) {
Print(screen, text, x, y, math.MaxInt32, AlignLeft, Styles.PrimaryTextColor)
}
// StringWidth returns the width of the given string needed to print it on
// screen. The text may contain color tags which are not counted.
func StringWidth(text string) int {
return runewidth.StringWidth(escapePattern.ReplaceAllString(colorPattern.ReplaceAllString(text, ""), "[$1$2]"))
}
// WordWrap splits a text such that each resulting line does not exceed the
// given screen width. Possible split points are after any punctuation or
// whitespace. Whitespace after split points will be dropped.
//
// This function considers color tags to have no width.
//
// Text is always split at newline characters ('\n').
func WordWrap(text string, width int) (lines []string) {
x := 0
start := 0
candidate := -1 // -1 = no candidate yet.
startAfterCandidate := 0
countAfterCandidate := 0
var evaluatingCandidate bool
text = strings.TrimSpace(text)
colorIndices := colorPattern.FindAllStringIndex(text, -1)
escapeIndices := escapePattern.FindAllStringIndex(text, -1)
var colorPos, escapePos int
for pos, ch := range text {
// Skip color tags.
if colorPos < len(colorIndices) && pos >= colorIndices[colorPos][0] && pos < colorIndices[colorPos][1] {
if pos == colorIndices[colorPos][1]-1 {
colorPos++
}
continue
}
// Handle escape tags.
if escapePos < len(escapeIndices) && pos >= escapeIndices[escapePos][0] && pos < escapeIndices[escapePos][1] {
if pos == escapeIndices[escapePos][1]-1 {
escapePos++
} else if pos == escapeIndices[escapePos][1]-2 {
continue
}
}
// What's the width of this rune?
chWidth := runewidth.RuneWidth(ch)
if !evaluatingCandidate && x >= width {
// We've exceeded the width, we must split.
if candidate >= 0 {
lines = append(lines, text[start:candidate])
start = startAfterCandidate
x = countAfterCandidate
} else {
lines = append(lines, text[start:pos])
start = pos
x = 0
}
candidate = -1
evaluatingCandidate = false
}
switch {
// We have a candidate.
case ch >= '!' && ch <= '/', ch >= ':' && ch <= '@', ch >= '[' && ch <= '`', ch >= '{' && ch <= '~':
if x > 0 {
candidate = pos + 1
evaluatingCandidate = true
}
// If we've had a candidate, skip whitespace. If not, we have a candidate.
case ch == ' ', ch == '\t':
if x > 0 && !evaluatingCandidate {
candidate = pos
evaluatingCandidate = true
}
// Split in any case.
case ch == '\n':
lines = append(lines, text[start:pos])
start = pos + 1
evaluatingCandidate = false
countAfterCandidate = 0
x = 0
continue
// If we've had a candidate, we have a new start.
default:
if evaluatingCandidate {
startAfterCandidate = pos
evaluatingCandidate = false
countAfterCandidate = 0
}
}
x += chWidth
countAfterCandidate += chWidth
}
// Process remaining text.
text = strings.TrimSpace(text[start:])
if len(text) > 0 {
lines = append(lines, text)
}
return
}