1
0
mirror of https://github.com/rivo/tview.git synced 2025-04-28 13:48:53 +08:00

Rewrote TextView's reindex() and Draw() to introduce text alignment and

word wrapping
This commit is contained in:
Oliver 2018-01-13 14:16:49 +01:00
parent 38d663c267
commit 1e78c506cb
3 changed files with 226 additions and 107 deletions

View File

@ -62,6 +62,8 @@ Add your issue here on GitHub. Feel free to get in touch if you have any questio
## Releases ## Releases
- v0.5 (2018-01-13)
- `TextView` now has word wrapping and text alignment
- v0.4 (2018-01-12) - v0.4 (2018-01-12)
- `TextView` now accepts color tags with any W3C color (including RGB hex values). - `TextView` now accepts color tags with any W3C color (including RGB hex values).
- Support for wide unicode characters. - Support for wide unicode characters.

View File

@ -24,6 +24,7 @@ func main() {
textView := tview.NewTextView(). textView := tview.NewTextView().
SetDynamicColors(true). SetDynamicColors(true).
SetRegions(true). SetRegions(true).
SetWordWrap(true).
SetChangedFunc(func() { SetChangedFunc(func() {
app.Draw() app.Draw()
}) })

View File

@ -2,7 +2,6 @@ package tview
import ( import (
"bytes" "bytes"
"math"
"regexp" "regexp"
"sync" "sync"
"unicode/utf8" "unicode/utf8"
@ -13,20 +12,24 @@ import (
// Regular expressions commonly used throughout the TextView class. // Regular expressions commonly used throughout the TextView class.
var ( var (
colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6})\]`) colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6})\]`)
regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`)
boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)")
spacePattern = regexp.MustCompile(`\s+`)
) )
// TabSize is the number of spaces to be drawn for a tab character. // TabSize is the number of spaces with which a tab character will be replaced.
var TabSize = 4 var TabSize = 4
// textViewIndex contains information about each line displayed in the text // textViewIndex contains information about each line displayed in the text
// view. // view.
type textViewIndex struct { type textViewIndex struct {
Line int // The index into the "buffer" variable. Line int // The index into the "buffer" variable.
Pos int // The index into the "buffer" string. Pos int // The index into the "buffer" string (byte position).
Color tcell.Color // The starting color. NextPos int // The (byte) index of the next character in this buffer line.
Region string // The starting region ID. Width int // The screen width of this line.
Color tcell.Color // The starting color.
Region string // The starting region ID.
} }
// TextView is a box which displays text. It implements the io.Writer interface // TextView is a box which displays text. It implements the io.Writer interface
@ -103,6 +106,9 @@ type TextView struct {
// to be re-indexed. // to be re-indexed.
index []*textViewIndex index []*textViewIndex
// The text alignment, one of AlignLeft, AlignCenter, or AlignRight.
align int
// Indices into the "index" slice which correspond to the first line of the // Indices into the "index" slice which correspond to the first line of the
// first highlight and the last line of the last highlight. This is calculated // first highlight and the last line of the last highlight. This is calculated
// during re-indexing. Set to -1 if there is no current highlight. // during re-indexing. Set to -1 if there is no current highlight.
@ -111,10 +117,10 @@ type TextView struct {
// A set of region IDs that are currently highlighted. // A set of region IDs that are currently highlighted.
highlights map[string]struct{} highlights map[string]struct{}
// The display width for which the index is created. // The last width for which the current table is drawn.
indexWidth int lastWidth int
// The width of the longest line in the index (not the buffer). // The screen width of the longest line in the index (not the buffer).
longestLine int longestLine int
// The index of the first line shown in the text view. // The index of the first line shown in the text view.
@ -138,6 +144,10 @@ type TextView struct {
// width are discarded. // width are discarded.
wrap bool wrap bool
// If set to true and if wrap is also true, lines are split at spaces or
// after punctuation characters.
wordWrap bool
// The (starting) color of the text. // The (starting) color of the text.
textColor tcell.Color textColor tcell.Color
@ -172,6 +182,7 @@ func NewTextView() *TextView {
highlights: make(map[string]struct{}), highlights: make(map[string]struct{}),
lineOffset: -1, lineOffset: -1,
scrollable: true, scrollable: true,
align: AlignLeft,
wrap: true, wrap: true,
textColor: Styles.PrimaryTextColor, textColor: Styles.PrimaryTextColor,
dynamicColors: false, dynamicColors: false,
@ -199,6 +210,29 @@ func (t *TextView) SetWrap(wrap bool) *TextView {
return t return t
} }
// SetWordWrap sets the flag that, if true and if the "wrap" flag is also true
// (see SetWrap()), wraps the line at spaces or after punctuation marks. Note
// that trailing spaces will not be printed.
//
// This flag is ignored if the "wrap" flag is false.
func (t *TextView) SetWordWrap(wrapOnWords bool) *TextView {
if t.wordWrap != wrapOnWords {
t.index = nil
}
t.wordWrap = wrapOnWords
return t
}
// SetTextAlign sets the text alignment within the text view. This must be
// either AlignLeft, AlignCenter, or AlignRight.
func (t *TextView) SetTextAlign(align int) *TextView {
if t.align != align {
t.index = nil
}
t.align = align
return t
}
// SetTextColor sets the initial color of the text (which can be changed // SetTextColor sets the initial color of the text (which can be changed
// dynamically by sending color strings in square brackets to the text view if // dynamically by sending color strings in square brackets to the text view if
// dynamic colors are enabled). // dynamic colors are enabled).
@ -220,6 +254,9 @@ func (t *TextView) SetDynamicColors(dynamic bool) *TextView {
// SetRegions sets the flag that allows to define regions in the text. See class // SetRegions sets the flag that allows to define regions in the text. See class
// description for details. // description for details.
func (t *TextView) SetRegions(regions bool) *TextView { func (t *TextView) SetRegions(regions bool) *TextView {
if t.regions != regions {
t.index = nil
}
t.regions = regions t.regions = regions
return t return t
} }
@ -273,6 +310,7 @@ func (t *TextView) Highlight(regionIDs ...string) *TextView {
} }
t.highlights[id] = struct{}{} t.highlights[id] = struct{}{}
} }
t.index = nil
return t return t
} }
@ -374,7 +412,9 @@ func (t *TextView) GetRegionText(regionID string) string {
return buffer.String() return buffer.String()
} }
// Write lets us implement the io.Writer interface. // Write lets us implement the io.Writer interface. Tab characters will be
// replaced with TabSize space characters. A "\n" or "\r\n" will be interpreted
// as a new line.
func (t *TextView) Write(p []byte) (n int, err error) { func (t *TextView) Write(p []byte) (n int, err error) {
// Notify at the end. // Notify at the end.
if t.changed != nil { if t.changed != nil {
@ -396,7 +436,7 @@ func (t *TextView) Write(p []byte) (n int, err error) {
// If we have a trailing open dynamic color, exclude it. // If we have a trailing open dynamic color, exclude it.
if t.dynamicColors { if t.dynamicColors {
openColor := regexp.MustCompile(`\[[a-z]+$`) openColor := regexp.MustCompile(`\[([a-zA-Z]*|#[0-9a-zA-Z]*)$`)
location := openColor.FindIndex(newBytes) location := openColor.FindIndex(newBytes)
if location != nil { if location != nil {
t.recentBytes = newBytes[location[0]:] t.recentBytes = newBytes[location[0]:]
@ -404,8 +444,19 @@ func (t *TextView) Write(p []byte) (n int, err error) {
} }
} }
// If we have a trailing open region, exclude it.
if t.regions {
openRegion := regexp.MustCompile(`\["[a-zA-Z0-9_,;: \-\.]*"?$`)
location := openRegion.FindIndex(newBytes)
if location != nil {
t.recentBytes = newBytes[location[0]:]
newBytes = newBytes[:location[0]]
}
}
// Transform the new bytes into strings. // Transform the new bytes into strings.
newLine := regexp.MustCompile(`\r?\n`) newLine := regexp.MustCompile(`\r?\n`)
newBytes = bytes.Replace(newBytes, []byte{'\t'}, bytes.Repeat([]byte{' '}, TabSize), -1)
for index, line := range newLine.Split(string(newBytes), -1) { for index, line := range newLine.Split(string(newBytes), -1) {
if index == 0 { if index == 0 {
if len(t.buffer) == 0 { if len(t.buffer) == 0 {
@ -429,23 +480,25 @@ func (t *TextView) Write(p []byte) (n int, err error) {
// into the buffer from which on we will print text. It will also contain the // into the buffer from which on we will print text. It will also contain the
// color with which the line starts. // color with which the line starts.
func (t *TextView) reindexBuffer(width int) { func (t *TextView) reindexBuffer(width int) {
if t.index != nil && width == t.indexWidth { if t.index != nil {
return // Nothing has changed. We can still use the current index. return // Nothing has changed. We can still use the current index.
} }
t.index = nil t.index = nil
t.fromHighlight, t.toHighlight = -1, -1 t.fromHighlight, t.toHighlight = -1, -1
var ( // If there's no space, there's no index.
regionID string if width < 1 {
highlighted bool return
)
t.longestLine = 0
color := t.textColor
if !t.wrap {
width = math.MaxInt32
} }
for index, str := range t.buffer {
// Find all color tags in this line. // Initial states.
regionID := ""
var highlighted bool
color := t.textColor
// Go through each line in the buffer.
for bufferIndex, str := range t.buffer {
// Find all color tags in this line. Then remove them.
var ( var (
colorTagIndices [][]int colorTagIndices [][]int
colorTags [][]string colorTags [][]string
@ -453,9 +506,10 @@ func (t *TextView) reindexBuffer(width int) {
if t.dynamicColors { if t.dynamicColors {
colorTagIndices = colorPattern.FindAllStringIndex(str, -1) colorTagIndices = colorPattern.FindAllStringIndex(str, -1)
colorTags = colorPattern.FindAllStringSubmatch(str, -1) colorTags = colorPattern.FindAllStringSubmatch(str, -1)
str = colorPattern.ReplaceAllString(str, "")
} }
// Find all regions in this line. // Find all regions in this line. Then remove them.
var ( var (
regionIndices [][]int regionIndices [][]int
regions [][]string regions [][]string
@ -463,89 +517,109 @@ func (t *TextView) reindexBuffer(width int) {
if t.regions { if t.regions {
regionIndices = regionPattern.FindAllStringIndex(str, -1) regionIndices = regionPattern.FindAllStringIndex(str, -1)
regions = regionPattern.FindAllStringSubmatch(str, -1) regions = regionPattern.FindAllStringSubmatch(str, -1)
str = regionPattern.ReplaceAllString(str, "")
} }
// We also keep a reference to empty lines. // Split the line if required.
if len(str) == 0 { var splitLines []string
t.index = append(t.index, &textViewIndex{ if t.wrap && len(str) > 0 {
Line: index, for len(str) > 0 {
Pos: 0, extract := runewidth.Truncate(str, width, "")
if t.wordWrap && len(extract) < len(str) {
// Add any spaces from the next line.
if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 {
extract = str[:len(extract)+spaces[1]]
}
// Can we split before the mandatory end?
matches := boundaryPattern.FindAllStringIndex(extract, -1)
if len(matches) > 0 {
// Yes. Let's split there.
extract = extract[:matches[len(matches)-1][1]]
}
}
splitLines = append(splitLines, extract)
str = str[len(extract):]
}
} else {
// No need to split the line.
splitLines = []string{str}
}
// Create index from split lines.
var startPos, originalPos, colorPos, regionPos int
for _, splitLine := range splitLines {
line := &textViewIndex{
Line: bufferIndex,
Pos: originalPos,
Color: color, Color: color,
Region: regionID, Region: regionID,
})
}
// Break down the line.
var currentTag, currentRegion, currentWidth int
for pos, ch := range str {
// Skip any color tags.
if currentTag < len(colorTags) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] {
if pos == colorTagIndices[currentTag][1]-1 {
color = tcell.GetColor(colorTags[currentTag][1])
currentTag++
}
continue
} }
// Check regions. // Shift original position with tags.
if currentRegion < len(regionIndices) && pos >= regionIndices[currentRegion][0] && pos < regionIndices[currentRegion][1] { lineWidth := 0
if pos == regionIndices[currentRegion][1]-1 { for index, ch := range splitLine {
// We're done with this region. // Get the width of the current rune.
regionID = regions[currentRegion][1] lineWidth += runewidth.RuneWidth(ch)
// Is this region highlighted? // Process color tags.
for colorPos < len(colorTagIndices) && colorTagIndices[colorPos][0] <= originalPos+index {
originalPos += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0]
color = tcell.GetColor(colorTags[colorPos][1])
colorPos++
}
// Process region tags.
for regionPos < len(regionIndices) && regionIndices[regionPos][0] <= originalPos+index {
originalPos += regionIndices[regionPos][1] - regionIndices[regionPos][0]
regionID = regions[regionPos][1]
_, highlighted = t.highlights[regionID] _, highlighted = t.highlights[regionID]
currentRegion++ // Update highlight range.
} if highlighted {
continue line := len(t.index)
} if t.fromHighlight < 0 {
t.fromHighlight, t.toHighlight = line, line
} else if line > t.toHighlight {
t.toHighlight = line
}
}
// Get the width of the current rune. regionPos++
chWidth := runewidth.RuneWidth(ch)
if ch == '\t' {
chWidth = TabSize
}
if chWidth == 0 {
continue // Skip width-less runes.
}
// Add this line.
if currentWidth == 0 {
t.index = append(t.index, &textViewIndex{
Line: index,
Pos: pos,
Color: color,
Region: regionID,
})
}
// Update highlight range.
if highlighted {
line := len(t.index) - 1
if t.fromHighlight < 0 {
t.fromHighlight, t.toHighlight = line, line
} else if line > t.toHighlight {
t.toHighlight = line
} }
} }
// Proceed. // Advance to next line.
currentWidth += chWidth startPos += len(splitLine)
originalPos += len(splitLine)
// Have we crossed the width? // Append this line.
if t.wrap && currentWidth >= width { line.NextPos = originalPos
currentWidth = 0 line.Width = lineWidth
} t.index = append(t.index, line)
}
// Do we have a new maximum width? // Word-wrapped lines may have trailing whitespace. Remove it.
if currentWidth > t.longestLine { if t.wrap && t.wordWrap {
t.longestLine = currentWidth for _, line := range t.index {
str := t.buffer[line.Line][line.Pos:line.NextPos]
spaces := spacePattern.FindAllStringIndex(str, -1)
if spaces != nil && spaces[len(spaces)-1][1] == len(str) {
oldNextPos := line.NextPos
line.NextPos -= spaces[len(spaces)-1][1] - spaces[len(spaces)-1][0]
line.Width -= runewidth.StringWidth(t.buffer[line.Line][line.NextPos:oldNextPos])
}
} }
} }
} }
t.indexWidth = width // Calculate longest line.
t.longestLine = 0
for _, line := range t.index {
if line.Width > t.longestLine {
t.longestLine = line.Width
}
}
} }
// Draw draws this primitive onto the screen. // Draw draws this primitive onto the screen.
@ -558,9 +632,20 @@ func (t *TextView) Draw(screen tcell.Screen) {
x, y, width, height := t.GetInnerRect() x, y, width, height := t.GetInnerRect()
t.pageSize = height t.pageSize = height
// If the width has changed, we need to reindex.
if width != t.lastWidth {
t.index = nil
}
t.lastWidth = width
// Re-index. // Re-index.
t.reindexBuffer(width) t.reindexBuffer(width)
// If we don't have an index, there's nothing to draw.
if t.index == nil {
return
}
// Move to highlighted regions. // Move to highlighted regions.
if t.regions && t.scrollToHighlights && t.fromHighlight >= 0 { if t.regions && t.scrollToHighlights && t.fromHighlight >= 0 {
// Do we fit the entire height? // Do we fit the entire height?
@ -586,11 +671,32 @@ func (t *TextView) Draw(screen tcell.Screen) {
} }
// Adjust column offset. // Adjust column offset.
if t.columnOffset+width > t.longestLine { if t.align == AlignLeft {
t.columnOffset = t.longestLine - width if t.columnOffset+width > t.longestLine {
} t.columnOffset = t.longestLine - width
if t.columnOffset < 0 { }
t.columnOffset = 0 if t.columnOffset < 0 {
t.columnOffset = 0
}
} else if t.align == AlignRight {
if t.columnOffset-width < -t.longestLine {
t.columnOffset = width - t.longestLine
}
if t.columnOffset > 0 {
t.columnOffset = 0
}
} else { // AlignCenter.
half := (t.longestLine - width) / 2
if half > 0 {
if t.columnOffset > half {
t.columnOffset = half
}
if t.columnOffset < -half {
t.columnOffset = -half
}
} else {
t.columnOffset = 0
}
} }
// Draw the buffer. // Draw the buffer.
@ -602,7 +708,7 @@ func (t *TextView) Draw(screen tcell.Screen) {
// Get the text for this line. // Get the text for this line.
index := t.index[line] index := t.index[line]
text := t.buffer[index.Line][index.Pos:] text := t.buffer[index.Line][index.Pos:index.NextPos]
color := index.Color color := index.Color
regionID := index.Region regionID := index.Region
@ -626,8 +732,22 @@ func (t *TextView) Draw(screen tcell.Screen) {
regions = regionPattern.FindAllStringSubmatch(text, -1) regions = regionPattern.FindAllStringSubmatch(text, -1)
} }
// Print one line. // Calculate the position of the line.
var currentTag, currentRegion, skip, posX int var skip, posX int
if t.align == AlignLeft {
posX = -t.columnOffset
} else if t.align == AlignRight {
posX = width - index.Width - t.columnOffset
} else { // AlignCenter.
posX = (width-index.Width)/2 - t.columnOffset
}
if posX < 0 {
skip = -posX
posX = 0
}
// Print the line.
var currentTag, currentRegion, skipped int
for pos, ch := range text { for pos, ch := range text {
// Get the color. // Get the color.
if currentTag < len(colorTags) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { if currentTag < len(colorTags) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] {
@ -647,19 +767,15 @@ func (t *TextView) Draw(screen tcell.Screen) {
continue continue
} }
// Skip to the right. // Determine the width of this rune.
if !t.wrap && skip < t.columnOffset { chWidth := runewidth.RuneWidth(ch)
skip++ if chWidth == 0 {
continue continue
} }
// Determine the width of this rune. // Skip to the right.
chWidth := runewidth.RuneWidth(ch) if !t.wrap && skipped < skip {
if ch == '\t' { skipped += chWidth
chWidth = TabSize
ch = ' '
}
if chWidth == 0 {
continue continue
} }