From 83d0a16fb2f81acdebfd6cd7002a76fd2f59c146 Mon Sep 17 00:00:00 2001 From: Oliver <480930+rivo@users.noreply.github.com> Date: Sun, 1 Apr 2018 21:19:10 +0200 Subject: [PATCH] Added background colors and text attributes to color tags. Resolves #91 --- README.md | 2 + demos/box/main.go | 2 +- demos/presentation/colors.go | 6 +- doc.go | 38 +++++++- table.go | 2 +- textview.go | 81 +++++++++------- util.go | 176 ++++++++++++++++++++++++++++------- 7 files changed, 232 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 3ecbb07..31e7e95 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Add your issue here on GitHub. Feel free to get in touch if you have any questio (There are no corresponding tags in the project. I only keep such a history in this README.) +- v0.13 (2018-04-01) + - Added background colors and text attributes to color tags. - v0.12 (2018-03-13) - Added "suspended mode" to `Application`. - v0.11 (2018-03-02) diff --git a/demos/box/main.go b/demos/box/main.go index b3fc654..317cc88 100644 --- a/demos/box/main.go +++ b/demos/box/main.go @@ -6,7 +6,7 @@ import "github.com/rivo/tview" func main() { box := tview.NewBox(). SetBorder(true). - SetTitle("Box Demo") + SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] [black:red]c[:yellow]o[:green]l[:darkcyan]o[:blue]r[:darkmagenta]f[:red]u[:yellow]l[white:] [::bu]title") if err := tview.NewApplication().SetRoot(box, true).Run(); err != nil { panic(err) } diff --git a/demos/presentation/colors.go b/demos/presentation/colors.go index 07adce0..8e4bc48 100644 --- a/demos/presentation/colors.go +++ b/demos/presentation/colors.go @@ -7,7 +7,7 @@ import ( "github.com/rivo/tview" ) -const colorsText = `You can use color tags almost everywhere to partially change the color of a string. Simply put a color name or hex string in square brackets to change the following characters' color. H[green]er[white]e i[yellow]s a[darkcyan]n ex[red]amp[white]le. The tags look like this: [red[] [#00ff00[]` +const colorsText = `You can use color tags almost everywhere to partially change the color of a string. Simply put a color name or hex string in square brackets to change the following characters' color. H[green]er[white]e i[yellow]s a[darkcyan]n ex[red]amp[white]le. The [black:red]tags [black:green]look [black:yellow]like [::u]this: [blue:yellow:u[] [#00ff00[]` // Colors demonstrates how to use colors. func Colors(nextSlide func()) (title string, content tview.Primitive) { @@ -28,6 +28,6 @@ func Colors(nextSlide func()) (title string, content tview.Primitive) { } table.SetBorderPadding(1, 1, 2, 2). SetBorder(true). - SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] title") - return "Colors", Center(73, 19, table) + SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] [black:red]c[:yellow]o[:green]l[:darkcyan]o[:blue]r[:darkmagenta]f[:red]u[:yellow]l[white:] [::bu]title") + return "Colors", Center(78, 19, table) } diff --git a/doc.go b/doc.go index 00543c0..0f903b3 100644 --- a/doc.go +++ b/doc.go @@ -77,13 +77,49 @@ applies to almost everything from box titles, list text, form item labels, to table cells. In a TextView, this functionality has to be switched on explicitly. See the TextView documentation for more information. +Color tags may contain not just the foreground (text) color but also the +background color and additional flags. In fact, the full definition of a color +tag is as follows: + + [::] + +Each of the three fields can be left blank and trailing fields can be ommitted. +(Empty square brackets "[]", however, are not considered color tags.) Colors +that are not specified will be changed. (If the flags field is indicated by a +colon but left empty, it will reset any flags.) + +You can specify the following flags (some flags may not be supported by your +terminal): + + l: blink + b: bold + d: dim + r: reverse (switch foreground and background color) + u: underline + +Examples: + + [yellow]Yellow text + [yellow:red]Yellow text on red background + [:red]Red background, text color unchanged + [yellow::u]Yellow text underlined + [::bl]Bold, blinking text + [::]Colors unchanged, flags reset + [:]No effect + []Not a valid color tag, will print square brackets as they are + In the rare event that you want to display a string such as "[red]" or "[#00ff1a]" without applying its effect, you need to put an opening square -bracket before the closing square bracket. Examples: +bracket before the closing square bracket. Note that the text inside the +brackets will be matched less strictly than region or colors tags. I.e. any +character that may be used in color or region tags will be recognized. Examples: [red[] will be output as [red] ["123"[] will be output as ["123"] [#6aff00[[] will be output as [#6aff00[] + [a#"[[[] will be output as [a#"[[] + [] will be output as [] (see color tags above) + [[] will be output as [[] (not an escaped tag) Styles diff --git a/table.go b/table.go index 334cd43..d430bdf 100644 --- a/table.go +++ b/table.go @@ -590,7 +590,7 @@ ColumnLoop: expansion := 0 for _, row := range rows { if cell := getCell(row, column); cell != nil { - cellWidth := StringWidth(cell.Text) + _, _, _, _, cellWidth := decomposeString(cell.Text) if cell.MaxWidth > 0 && cell.MaxWidth < cellWidth { cellWidth = cell.MaxWidth } diff --git a/textview.go b/textview.go index 981004b..a80219e 100644 --- a/textview.go +++ b/textview.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" "github.com/gdamore/tcell" + colorful "github.com/lucasb-eyer/go-colorful" runewidth "github.com/mattn/go-runewidth" ) @@ -17,12 +18,13 @@ var TabSize = 4 // textViewIndex contains information about each line displayed in the text // view. type textViewIndex struct { - Line int // The index into the "buffer" variable. - Pos int // The index into the "buffer" string (byte position). - NextPos int // The (byte) index of the next character in this buffer line. - Width int // The screen width of this line. - Color tcell.Color // The starting color. - Region string // The starting region ID. + Line int // The index into the "buffer" variable. + Pos int // The index into the "buffer" string (byte position). + NextPos int // The (byte) index of the next character in this buffer line. + Width int // The screen width of this line. + Style tcell.Style // The starting style. + OverwriteAttr bool // The starting flag indicating if style attributes should be overwritten. + Region string // The starting region ID. } // TextView is a box which displays text. It implements the io.Writer interface @@ -498,8 +500,8 @@ func (t *TextView) reindexBuffer(width int) { // Initial states. regionID := "" - var highlighted bool - color := t.textColor + var highlighted, overwriteAttr bool + style := tcell.StyleDefault.Foreground(t.textColor) // Go through each line in the buffer. for bufferIndex, str := range t.buffer { @@ -507,11 +509,10 @@ func (t *TextView) reindexBuffer(width int) { var ( colorTagIndices [][]int colorTags [][]string + escapeIndices [][]int ) if t.dynamicColors { - colorTagIndices = colorPattern.FindAllStringIndex(str, -1) - colorTags = colorPattern.FindAllStringSubmatch(str, -1) - str = colorPattern.ReplaceAllString(str, "") + colorTagIndices, colorTags, escapeIndices, str, _ = decomposeString(str) } // Find all regions in this line. Then remove them. @@ -523,13 +524,11 @@ func (t *TextView) reindexBuffer(width int) { regionIndices = regionPattern.FindAllStringIndex(str, -1) regions = regionPattern.FindAllStringSubmatch(str, -1) str = regionPattern.ReplaceAllString(str, "") - } - - // Find all replace tags in this line. Then replace them. - var escapeIndices [][]int - if t.dynamicColors || t.regions { - escapeIndices = escapePattern.FindAllStringIndex(str, -1) - str = escapePattern.ReplaceAllString(str, "[$1$2]") + if !t.dynamicColors { + // We haven't detected escape tags yet. Do it now. + escapeIndices = escapePattern.FindAllStringIndex(str, -1) + str = escapePattern.ReplaceAllString(str, "[$1$2]") + } } // Split the line if required. @@ -562,10 +561,11 @@ func (t *TextView) reindexBuffer(width int) { var originalPos, colorPos, regionPos, escapePos int for _, splitLine := range splitLines { line := &textViewIndex{ - Line: bufferIndex, - Pos: originalPos, - Color: color, - Region: regionID, + Line: bufferIndex, + Pos: originalPos, + Style: style, + OverwriteAttr: overwriteAttr, + Region: regionID, } // Shift original position with tags. @@ -574,7 +574,7 @@ func (t *TextView) reindexBuffer(width int) { if colorPos < len(colorTagIndices) && colorTagIndices[colorPos][0] <= originalPos+lineLength { // Process color tags. originalPos += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] - color = tcell.GetColor(colorTags[colorPos][1]) + style, overwriteAttr = styleFromTag(style, overwriteAttr, colorTags[colorPos]) colorPos++ } else if regionPos < len(regionIndices) && regionIndices[regionPos][0] <= originalPos+lineLength { // Process region tags. @@ -721,17 +721,18 @@ func (t *TextView) Draw(screen tcell.Screen) { // Get the text for this line. index := t.index[line] text := t.buffer[index.Line][index.Pos:index.NextPos] - color := index.Color + style := index.Style + overwriteAttr := index.OverwriteAttr regionID := index.Region // Get color tags. var ( colorTagIndices [][]int colorTags [][]string + escapeIndices [][]int ) if t.dynamicColors { - colorTagIndices = colorPattern.FindAllStringIndex(text, -1) - colorTags = colorPattern.FindAllStringSubmatch(text, -1) + colorTagIndices, colorTags, escapeIndices, _, _ = decomposeString(text) } // Get regions. @@ -742,12 +743,9 @@ func (t *TextView) Draw(screen tcell.Screen) { if t.regions { regionIndices = regionPattern.FindAllStringIndex(text, -1) regions = regionPattern.FindAllStringSubmatch(text, -1) - } - - // Get escape tags. - var escapeIndices [][]int - if t.dynamicColors || t.regions { - escapeIndices = escapePattern.FindAllStringIndex(text, -1) + if !t.dynamicColors { + escapeIndices = escapePattern.FindAllStringIndex(text, -1) + } } // Calculate the position of the line. @@ -770,7 +768,7 @@ func (t *TextView) Draw(screen tcell.Screen) { // Get the color. if currentTag < len(colorTags) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { if pos == colorTagIndices[currentTag][1]-1 { - color = tcell.GetColor(colorTags[currentTag][1]) + style, overwriteAttr = styleFromTag(style, overwriteAttr, colorTags[currentTag]) currentTag++ } continue @@ -812,16 +810,27 @@ func (t *TextView) Draw(screen tcell.Screen) { } // Do we highlight this character? - style := tcell.StyleDefault.Background(t.backgroundColor).Foreground(color) + finalStyle := style if len(regionID) > 0 { if _, ok := t.highlights[regionID]; ok { - style = tcell.StyleDefault.Background(color).Foreground(t.backgroundColor) + fg, bg, _ := finalStyle.Decompose() + if bg == tcell.ColorDefault { + r, g, b := fg.RGB() + c := colorful.Color{R: float64(r) / 255, G: float64(g) / 255, B: float64(b) / 255} + _, _, li := c.Hcl() + if li < .5 { + bg = tcell.ColorWhite + } else { + bg = tcell.ColorBlack + } + } + finalStyle = style.Background(fg).Foreground(bg) } } // Draw the character. for offset := 0; offset < chWidth; offset++ { - screen.SetContent(x+posX+offset, y+line-t.lineOffset, ch, nil, style) + screen.SetContent(x+posX+offset, y+line-t.lineOffset, ch, nil, finalStyle) } // Advance. diff --git a/util.go b/util.go index 3d568a7..d65aaa5 100644 --- a/util.go +++ b/util.go @@ -104,13 +104,20 @@ var joints = map[string]rune{ // Common regular expressions. var ( - colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6})\]`) + colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6})?(:([a-zA-Z]+|#[0-9a-zA-Z]{6})?(:([lbdru]+))?)?\]`) regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) - escapePattern = regexp.MustCompile(`\[("[a-zA-Z0-9_,;: \-\.]*"|[a-zA-Z]+|#[0-9a-zA-Z]{6})\[(\[*)\]`) + escapePattern = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`) boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)") spacePattern = regexp.MustCompile(`\s+`) ) +// Positions of substrings in regular expressions. +const ( + colorForegroundPos = 1 + colorBackgroundPos = 3 + colorFlagPos = 5 +) + // Predefined InputField acceptance functions. var ( // InputFieldInteger accepts integers. @@ -150,40 +157,148 @@ func init() { } } +// styleFromTag takes the given style and modifies it based on the substrings +// extracted by the regular expression for color tags. The new style is returned +// as well as the flag indicating if any style attributes were explicitly +// specified (whose original value is also returned). +func styleFromTag(style tcell.Style, overwriteAttr bool, tagSubstrings []string) (tcell.Style, bool) { + // Colors. + if tagSubstrings[colorForegroundPos] != "" { + color := tagSubstrings[colorForegroundPos] + if color == "" { + style = style.Foreground(tcell.ColorDefault) + } else { + style = style.Foreground(tcell.GetColor(color)) + } + } + if tagSubstrings[colorBackgroundPos-1] != "" { + color := tagSubstrings[colorBackgroundPos] + if color == "" { + style = style.Background(tcell.ColorDefault) + } else { + style = style.Background(tcell.GetColor(color)) + } + } + + // Flags. + specified := tagSubstrings[colorFlagPos-1] != "" + if specified { + overwriteAttr = true + style = style.Normal() + for _, flag := range tagSubstrings[colorFlagPos] { + switch flag { + case 'l': + style = style.Blink(true) + case 'b': + style = style.Bold(true) + case 'd': + style = style.Dim(true) + case 'r': + style = style.Reverse(true) + case 'u': + style = style.Underline(true) + } + } + } + return style, overwriteAttr +} + +// overlayStyle mixes a bottom and a top style and returns the result. Top +// colors (other than tcell.ColorDefault) overwrite bottom colors. Top +// style attributes overwrite bottom style attributes only if overwriteAttr is +// true. +func overlayStyle(bottom, top tcell.Style, overwriteAttr bool) tcell.Style { + style := bottom + fg, bg, attr := top.Decompose() + if bg != tcell.ColorDefault { + style = style.Background(bg) + } + if fg != tcell.ColorDefault { + style = style.Foreground(fg) + } + if overwriteAttr { + style = style.Normal() + style |= tcell.Style(attr) + } + return style +} + +// decomposeString returns information about a string which may contain color +// tags. It returns the indices of the color tags (as returned by +// re.FindAllStringIndex()), the color tags themselves (as returned by +// re.FindAllStringSubmatch()), the indices of an escaped tags, the string +// stripped by any color tags and escaped, and the screen width of the stripped +// string. +func decomposeString(text string) (colorIndices [][]int, colors [][]string, escapeIndices [][]int, stripped string, width int) { + // Get positions of color and escape tags. + colorIndices = colorPattern.FindAllStringIndex(text, -1) + colors = colorPattern.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:]...) + } + } + + // Remove the color tags from the original string. + var from int + buf := make([]byte, 0, len(text)) + for _, indices := range colorIndices { + buf = append(buf, []byte(text[from:indices[0]])...) + from = indices[1] + } + buf = append(buf, text[from:]...) + + // Escape string. + stripped = string(escapePattern.ReplaceAll(buf, []byte("[$1$2]"))) + + // Get the width of the stripped string. + width = runewidth.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 text color mid-text by inserting a color tag. See the -// package description for details. +// You can change the colors and text styles 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) { + return printWithStyle(screen, text, x, y, maxWidth, align, tcell.StyleDefault.Foreground(color), false) +} + +// printWithStyle works like Print() but it takes a style instead of just a +// foreground color. The overwriteAttr indicates whether or not a style's +// additional attributes (see tcell.AttrMask) should be overwritten. +func printWithStyle(screen tcell.Screen, text string, x, y, maxWidth, align int, style tcell.Style, overwriteAttr bool) (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]") + // Decompose the text. + colorIndices, colors, escapeIndices, strippedText, _ := decomposeString(text) // 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) { + // style and returns the substring with the original tags and the new start + // style. + substring := func(from, to int, style tcell.Style, overwriteAttr bool) (string, tcell.Style, bool) { 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]) + style, overwriteAttr = styleFromTag(style, overwriteAttr, colors[colorPos]) } colorPos++ } @@ -203,13 +318,13 @@ func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tc if runePos == from { startPos = pos } else if runePos >= to { - return text[startPos:pos], color + return text[startPos:pos], style, overwriteAttr } runePos++ } - return text[startPos:], color + return text[startPos:], style, overwriteAttr } // We want to reduce everything to AlignLeft. @@ -224,17 +339,17 @@ func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tc width += w start = index } - text, color = substring(start, len(runes), color) - return Print(screen, text, x+maxWidth-width, y, width, AlignLeft, color) + text, style, overwriteAttr = substring(start, len(runes), style, overwriteAttr) + return printWithStyle(screen, text, x+maxWidth-width, y, width, AlignLeft, style, overwriteAttr) } else if align == AlignCenter { width := runewidth.StringWidth(strippedText) if width == maxWidth { // Use the exact space. - return Print(screen, text, x, y, maxWidth, AlignLeft, color) + return printWithStyle(screen, text, x, y, maxWidth, AlignLeft, style, overwriteAttr) } 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) + return printWithStyle(screen, text, x+half, y, maxWidth-half, AlignLeft, style, overwriteAttr) } else { // Chop off runes until we have a perfect fit. var choppedLeft, choppedRight, leftIndex, rightIndex int @@ -250,8 +365,8 @@ func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tc rightIndex-- } } - text, color = substring(leftIndex, rightIndex, color) - return Print(screen, text, x, y, maxWidth, AlignLeft, color) + text, style, overwriteAttr = substring(leftIndex, rightIndex, style, overwriteAttr) + return printWithStyle(screen, text, x, y, maxWidth, AlignLeft, style, overwriteAttr) } } @@ -263,7 +378,7 @@ func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tc // 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]) + style, overwriteAttr = styleFromTag(style, overwriteAttr, colors[colorPos]) colorPos++ } continue @@ -286,11 +401,11 @@ func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tc finalX := x + drawnWidth // Print the rune. - _, _, style, _ := screen.GetContent(finalX, y) - style = style.Foreground(color) + _, _, finalStyle, _ := screen.GetContent(finalX, y) + finalStyle = overlayStyle(finalStyle, style, overwriteAttr) 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) + screen.SetContent(finalX+offset, y, ch, nil, finalStyle) } drawn++ @@ -308,7 +423,8 @@ func PrintSimple(screen tcell.Screen, text string, x, y int) { // 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]")) + _, _, _, _, width := decomposeString(text) + return width } // WordWrap splits a text such that each resulting line does not exceed the @@ -319,13 +435,7 @@ func StringWidth(text string) int { // // Text is always split at newline characters ('\n'). func WordWrap(text string, width int) (lines []string) { - // Strip color tags. - strippedText := escapePattern.ReplaceAllString(colorPattern.ReplaceAllString(text, ""), "[$1$2]") - - // Keep track of color tags and escape patterns so we can restore the original - // indices. - colorTagIndices := colorPattern.FindAllStringIndex(text, -1) - escapeIndices := escapePattern.FindAllStringIndex(text, -1) + colorTagIndices, _, escapeIndices, strippedText, _ := decomposeString(text) // Find candidate breakpoints. breakPoints := boundaryPattern.FindAllStringIndex(strippedText, -1)