mirror of
https://github.com/rivo/tview.git
synced 2025-04-24 13:48:56 +08:00
495 lines
15 KiB
Go
495 lines
15 KiB
Go
package tview
|
|
|
|
import (
|
|
"math"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/uniseg"
|
|
)
|
|
|
|
// Text alignment within a box. Also used to align images.
|
|
const (
|
|
AlignLeft = iota
|
|
AlignCenter
|
|
AlignRight
|
|
AlignTop = 0
|
|
AlignBottom = 2
|
|
)
|
|
|
|
// Common regular expressions.
|
|
var (
|
|
colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([lbidrus]+|\-)?)?)?\]`)
|
|
regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`)
|
|
escapePattern = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`)
|
|
nonEscapePattern = regexp.MustCompile(`(\[[a-zA-Z0-9_,;: \-\."#]+\[*)\]`)
|
|
boundaryPattern = regexp.MustCompile(`(([,\.\-:;!\?&#+]|\n)[ \t\f\r]*|([ \t\f\r]+))`)
|
|
spacePattern = regexp.MustCompile(`\s+`)
|
|
)
|
|
|
|
// Positions of substrings in regular expressions.
|
|
const (
|
|
colorForegroundPos = 1
|
|
colorBackgroundPos = 3
|
|
colorFlagPos = 5
|
|
)
|
|
|
|
// The number of colors available in the terminal.
|
|
var availableColors = 256
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Determine the number of colors available in the terminal.
|
|
info, err := tcell.LookupTerminfo(os.Getenv("TERM"))
|
|
if err == nil {
|
|
availableColors = info.Colors
|
|
}
|
|
}
|
|
|
|
// styleFromTag takes the given style, defined by a foreground color (fgColor),
|
|
// a background color (bgColor), and style attributes, and modifies it based on
|
|
// the substrings (tagSubstrings) extracted by the regular expression for color
|
|
// tags. The new colors and attributes are returned where empty strings mean
|
|
// "don't modify" and a dash ("-") means "reset to default".
|
|
func styleFromTag(fgColor, bgColor, attributes string, tagSubstrings []string) (newFgColor, newBgColor, newAttributes string) {
|
|
if tagSubstrings[colorForegroundPos] != "" {
|
|
color := tagSubstrings[colorForegroundPos]
|
|
if color == "-" {
|
|
fgColor = "-"
|
|
} else if color != "" {
|
|
fgColor = color
|
|
}
|
|
}
|
|
|
|
if tagSubstrings[colorBackgroundPos-1] != "" {
|
|
color := tagSubstrings[colorBackgroundPos]
|
|
if color == "-" {
|
|
bgColor = "-"
|
|
} else if color != "" {
|
|
bgColor = color
|
|
}
|
|
}
|
|
|
|
if tagSubstrings[colorFlagPos-1] != "" {
|
|
flags := tagSubstrings[colorFlagPos]
|
|
if flags == "-" {
|
|
attributes = "-"
|
|
} else if flags != "" {
|
|
attributes = flags
|
|
}
|
|
}
|
|
|
|
return fgColor, bgColor, attributes
|
|
}
|
|
|
|
// overlayStyle calculates a new style based on "style" and applying tag-based
|
|
// colors/attributes to it (see also styleFromTag()).
|
|
func overlayStyle(style tcell.Style, fgColor, bgColor, attributes string) tcell.Style {
|
|
_, _, defAttr := style.Decompose()
|
|
|
|
if fgColor != "" && fgColor != "-" {
|
|
style = style.Foreground(tcell.GetColor(fgColor))
|
|
}
|
|
|
|
if bgColor != "" && bgColor != "-" {
|
|
style = style.Background(tcell.GetColor(bgColor))
|
|
}
|
|
|
|
if attributes == "-" {
|
|
style = style.Bold(defAttr&tcell.AttrBold > 0).
|
|
Italic(defAttr&tcell.AttrItalic > 0).
|
|
Blink(defAttr&tcell.AttrBlink > 0).
|
|
Reverse(defAttr&tcell.AttrReverse > 0).
|
|
Underline(defAttr&tcell.AttrUnderline > 0).
|
|
Dim(defAttr&tcell.AttrDim > 0)
|
|
} else if attributes != "" {
|
|
style = style.Normal()
|
|
for _, flag := range attributes {
|
|
switch flag {
|
|
case 'l':
|
|
style = style.Blink(true)
|
|
case 'b':
|
|
style = style.Bold(true)
|
|
case 'i':
|
|
style = style.Italic(true)
|
|
case 'd':
|
|
style = style.Dim(true)
|
|
case 'r':
|
|
style = style.Reverse(true)
|
|
case 'u':
|
|
style = style.Underline(true)
|
|
case 's':
|
|
style = style.StrikeThrough(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
return style
|
|
}
|
|
|
|
// decomposeString returns information about a string which may contain color
|
|
// tags or region tags, depending on which ones are requested to be found. It
|
|
// returns the indices of the style tags (as returned by
|
|
// re.FindAllStringIndex()), the style tags themselves (as returned by
|
|
// re.FindAllStringSubmatch()), the indices of region tags and the region tags
|
|
// themselves, the indices of an escaped tags (only if at least style tags or
|
|
// region tags are requested), the string stripped by any tags and escaped, and
|
|
// the screen width of the stripped string.
|
|
func decomposeString(text string, findColors, findRegions bool) (colorIndices [][]int, colors [][]string, regionIndices [][]int, regions [][]string, escapeIndices [][]int, stripped string, width int) {
|
|
// Shortcut for the trivial case.
|
|
if !findColors && !findRegions {
|
|
return nil, nil, nil, nil, nil, text, uniseg.StringWidth(text)
|
|
}
|
|
|
|
// Get positions of any tags.
|
|
if findColors {
|
|
colorIndices = colorPattern.FindAllStringIndex(text, -1)
|
|
colors = colorPattern.FindAllStringSubmatch(text, -1)
|
|
}
|
|
if findRegions {
|
|
regionIndices = regionPattern.FindAllStringIndex(text, -1)
|
|
regions = regionPattern.FindAllStringSubmatch(text, -1)
|
|
}
|
|
escapeIndices = escapePattern.FindAllStringIndex(text, -1)
|
|
|
|
// Because the color pattern detects empty tags, we need to filter them out.
|
|
for i := len(colorIndices) - 1; i >= 0; i-- {
|
|
if colorIndices[i][1]-colorIndices[i][0] == 2 {
|
|
colorIndices = append(colorIndices[:i], colorIndices[i+1:]...)
|
|
colors = append(colors[:i], colors[i+1:]...)
|
|
}
|
|
}
|
|
|
|
// Make a (sorted) list of all tags.
|
|
allIndices := make([][3]int, 0, len(colorIndices)+len(regionIndices)+len(escapeIndices))
|
|
for indexType, index := range [][][]int{colorIndices, regionIndices, escapeIndices} {
|
|
for _, tag := range index {
|
|
allIndices = append(allIndices, [3]int{tag[0], tag[1], indexType})
|
|
}
|
|
}
|
|
sort.Slice(allIndices, func(i int, j int) bool {
|
|
return allIndices[i][0] < allIndices[j][0]
|
|
})
|
|
|
|
// Remove the tags from the original string.
|
|
var from int
|
|
buf := make([]byte, 0, len(text))
|
|
for _, indices := range allIndices {
|
|
if indices[2] == 2 { // Escape sequences are not simply removed.
|
|
buf = append(buf, []byte(text[from:indices[1]-2])...)
|
|
buf = append(buf, ']')
|
|
from = indices[1]
|
|
} else {
|
|
buf = append(buf, []byte(text[from:indices[0]])...)
|
|
from = indices[1]
|
|
}
|
|
}
|
|
buf = append(buf, text[from:]...)
|
|
stripped = string(buf)
|
|
|
|
// Get the width of the stripped string.
|
|
width = uniseg.StringWidth(stripped)
|
|
|
|
return
|
|
}
|
|
|
|
// 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 colors and text styles mid-text by inserting a style tag.
|
|
// See the package description for details.
|
|
//
|
|
// Returns the number of actual bytes of the text printed (including style 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) {
|
|
start, end, width := printWithStyle(screen, text, x, y, 0, maxWidth, align, tcell.StyleDefault.Foreground(color), true)
|
|
return end - start, width
|
|
}
|
|
|
|
// printWithStyle works like Print() but it takes a style instead of just a
|
|
// foreground color. The skipWidth parameter specifies the number of cells
|
|
// skipped at the beginning of the text. It returns the start index, end index
|
|
// (exclusively), and screen width of the text actually printed. If
|
|
// maintainBackground is "true", the existing screen background is not changed
|
|
// (i.e. the style's background color is ignored).
|
|
func printWithStyle(screen tcell.Screen, text string, x, y, skipWidth, maxWidth, align int, style tcell.Style, maintainBackground bool) (start, end, printedWidth int) {
|
|
totalWidth, totalHeight := screen.Size()
|
|
if maxWidth <= 0 || len(text) == 0 || y < 0 || y >= totalHeight {
|
|
return 0, 0, 0
|
|
}
|
|
|
|
// If we don't overwrite the background, we use the default color.
|
|
if maintainBackground {
|
|
style = style.Background(tcell.ColorDefault)
|
|
}
|
|
|
|
// Skip beginning and measure width.
|
|
var (
|
|
state *stepState
|
|
textWidth int
|
|
)
|
|
str := text
|
|
for len(str) > 0 {
|
|
_, str, state = step(str, state, stepOptionsStyle)
|
|
if skipWidth > 0 {
|
|
skipWidth -= state.Width()
|
|
if skipWidth <= 0 {
|
|
text = str
|
|
style = state.Style()
|
|
}
|
|
start += state.GrossLength()
|
|
} else {
|
|
textWidth += state.Width()
|
|
}
|
|
}
|
|
|
|
// Reduce all alignments to AlignLeft.
|
|
if align == AlignRight {
|
|
// Chop off characters on the left until it fits.
|
|
state = nil
|
|
for len(text) > 0 && textWidth > maxWidth {
|
|
_, text, state = step(text, state, stepOptionsStyle)
|
|
textWidth -= state.Width()
|
|
start += state.GrossLength()
|
|
style = state.Style()
|
|
}
|
|
x, maxWidth = x+maxWidth-textWidth, textWidth
|
|
} else if align == AlignCenter {
|
|
// Chop off characters on the left until it fits.
|
|
state = nil
|
|
subtracted := (textWidth - maxWidth) / 2
|
|
for len(text) > 0 && subtracted > 0 {
|
|
_, text, state = step(text, state, stepOptionsStyle)
|
|
subtracted -= state.Width()
|
|
textWidth -= state.Width()
|
|
start += state.GrossLength()
|
|
style = state.Style()
|
|
}
|
|
if textWidth < maxWidth {
|
|
x, maxWidth = x+maxWidth/2-textWidth/2, textWidth
|
|
}
|
|
}
|
|
|
|
// Draw left-aligned text.
|
|
end = start
|
|
rightBorder := x + maxWidth
|
|
state = &stepState{
|
|
unisegState: -1,
|
|
style: style,
|
|
}
|
|
for len(text) > 0 && x < rightBorder && x < totalWidth {
|
|
var c string
|
|
c, text, state = step(text, state, stepOptionsStyle)
|
|
if c == "" {
|
|
break // We don't care about the style at the end.
|
|
}
|
|
runes := []rune(c)
|
|
width := state.Width()
|
|
|
|
finalStyle := state.Style()
|
|
if maintainBackground {
|
|
_, backgroundColor, _ := finalStyle.Decompose()
|
|
if backgroundColor == tcell.ColorDefault {
|
|
_, _, existingStyle, _ := screen.GetContent(x, y)
|
|
_, background, _ := existingStyle.Decompose()
|
|
finalStyle = finalStyle.Background(background)
|
|
}
|
|
}
|
|
for offset := width - 1; offset >= 0; offset-- {
|
|
// To avoid undesired effects, we populate all cells.
|
|
if offset == 0 {
|
|
screen.SetContent(x+offset, y, runes[0], runes[1:], finalStyle)
|
|
} else {
|
|
screen.SetContent(x+offset, y, ' ', nil, finalStyle)
|
|
}
|
|
}
|
|
|
|
x += width
|
|
end += state.GrossLength()
|
|
printedWidth += width
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// TaggedStringWidth returns the width of the given string needed to print it on
|
|
// screen. The text may contain style tags which are not counted.
|
|
func TaggedStringWidth(text string) (width int) {
|
|
var state *stepState
|
|
for len(text) > 0 {
|
|
_, text, state = step(text, state, stepOptionsStyle)
|
|
width += state.Width()
|
|
}
|
|
return
|
|
}
|
|
|
|
// WordWrap splits a text such that each resulting line does not exceed the
|
|
// given screen width. Split points are determined using the algorithm described
|
|
// in [Unicode Standard Annex #14] .
|
|
//
|
|
// This function considers style tags to have no width.
|
|
//
|
|
// [Unicode Standard Annex #14]: https://www.unicode.org/reports/tr14/
|
|
func WordWrap(text string, width int) (lines []string) {
|
|
if width <= 0 {
|
|
return
|
|
}
|
|
|
|
var (
|
|
state *stepState
|
|
lineWidth, lineLength, lastOption, lastOptionWidth int
|
|
)
|
|
str := text
|
|
for len(str) > 0 {
|
|
// Parse the next character.
|
|
var c string
|
|
c, str, state = step(str, state, stepOptionsStyle)
|
|
cWidth := state.Width()
|
|
|
|
// Would it exceed the line width?
|
|
if lineWidth+cWidth > width {
|
|
if lastOptionWidth == 0 {
|
|
// No split point so far. Just split at the current position.
|
|
lines = append(lines, text[:lineLength])
|
|
text = text[lineLength:]
|
|
lineWidth, lineLength, lastOption, lastOptionWidth = 0, 0, 0, 0
|
|
} else {
|
|
// Split at the last split point.
|
|
lines = append(lines, text[:lastOption])
|
|
text = text[lastOption:]
|
|
lineWidth -= lastOptionWidth
|
|
lineLength -= lastOption
|
|
lastOption, lastOptionWidth = 0, 0
|
|
}
|
|
}
|
|
|
|
// Move ahead.
|
|
lineWidth += cWidth
|
|
lineLength += state.GrossLength()
|
|
|
|
// Check for split points.
|
|
if lineBreak, optional := state.LineBreak(); lineBreak {
|
|
if optional {
|
|
// Remember this split point.
|
|
lastOption = lineLength
|
|
lastOptionWidth = lineWidth
|
|
} else if str != "" || c != "" && uniseg.HasTrailingLineBreakInString(c) {
|
|
// We must split here.
|
|
lines = append(lines, strings.TrimRight(text[:lineLength], "\n\r"))
|
|
text = text[lineLength:]
|
|
lineWidth, lineLength, lastOption, lastOptionWidth = 0, 0, 0, 0
|
|
}
|
|
}
|
|
}
|
|
lines = append(lines, text)
|
|
|
|
return
|
|
}
|
|
|
|
// Escape escapes the given text such that color and/or region tags are not
|
|
// recognized and substituted by the print functions of this package. For
|
|
// example, to include a tag-like string in a box title or in a TextView:
|
|
//
|
|
// box.SetTitle(tview.Escape("[squarebrackets]"))
|
|
// fmt.Fprint(textView, tview.Escape(`["quoted"]`))
|
|
func Escape(text string) string {
|
|
return nonEscapePattern.ReplaceAllString(text, "$1[]")
|
|
}
|
|
|
|
// iterateString iterates through the given string one printed character at a
|
|
// time. For each such character, the callback function is called with the
|
|
// Unicode code points of the character (the first rune and any combining runes
|
|
// which may be nil if there aren't any), the starting position (in bytes)
|
|
// within the original string, its length in bytes, the screen position of the
|
|
// character, the screen width of it, and a boundaries value which includes
|
|
// word/sentence boundary or line break information (see the
|
|
// github.com/rivo/uniseg package, Step() function, for more information). The
|
|
// iteration stops if the callback returns true. This function returns true if
|
|
// the iteration was stopped before the last character.
|
|
func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth, boundaries int) bool) bool {
|
|
var screenPos, textPos, boundaries int
|
|
|
|
state := -1
|
|
for len(text) > 0 {
|
|
var cluster string
|
|
cluster, text, boundaries, state = uniseg.StepString(text, state)
|
|
|
|
width := boundaries >> uniseg.ShiftWidth
|
|
runes := []rune(cluster)
|
|
var comb []rune
|
|
if len(runes) > 1 {
|
|
comb = runes[1:]
|
|
}
|
|
|
|
if callback(runes[0], comb, textPos, len(cluster), screenPos, width, boundaries) {
|
|
return true
|
|
}
|
|
|
|
screenPos += width
|
|
textPos += len(cluster)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// stripTags strips style tags from the given string. (Region tags are not
|
|
// stripped.)
|
|
func stripTags(text string) string {
|
|
var (
|
|
str strings.Builder
|
|
state *stepState
|
|
)
|
|
for len(text) > 0 {
|
|
var c string
|
|
c, text, state = step(text, state, stepOptionsStyle)
|
|
str.WriteString(c)
|
|
}
|
|
return str.String()
|
|
}
|