termui/textRender.go
Matteo Kloiber a3f1384a3b Added EscapeCodeRenderer
- Added `EscapeCode`-type
 - Implemented EscapeCode.String()
 - Implemented EscapeCode.Raw()
 - Implemented EscapeCode.MakeSafe()
 - Implemented EscapeCode.IsValid()

 - Added `EscapeCodeRenderer`
 - Implemented EscapeCodeRenderer.RenderSequence()
 - Implemented EscapeCodeRenderer.Render()
 - Implemented `EscapeCodeRenderer.NormalizedText`.

 - Added EscapeCodeRendererFactory
 - Implemented EscapeCodeRendererFactory.TextRenderer()
 - Added escape code examples to examples/coloredList.go
2015-04-12 17:41:34 +02:00

455 lines
12 KiB
Go

package termui
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// TextRenderer adds common methods for rendering a text on screeen.
type TextRenderer interface {
NormalizedText() string
Render(lastColor, background Attribute) RenderedSequence
RenderSequence(start, end int, lastColor, background Attribute) RenderedSequence
}
// TextRendererFactory is factory for creating text renderers.
type TextRendererFactory interface {
TextRenderer(text string) TextRenderer
}
// MarkdownRegex is used by MarkdownTextRenderer to determine how to format the
// text.
const MarkdownRegex = `(?:\[([^]]+)\])\(([a-z\s,]+)\)`
// unexported because a pattern can't be a constant and we don't want anyone
// messing with the regex.
var markdownPattern = regexp.MustCompile(MarkdownRegex)
// MarkdownTextRenderer is used for rendering the text with colors using
// markdown-like syntax.
// See: https://github.com/gizak/termui/issues/4#issuecomment-87270635
type MarkdownTextRenderer struct {
Text string
}
// NormalizedText returns the text the user will see (without colors).
// It strips out all formatting option and only preserves plain text.
func (r MarkdownTextRenderer) NormalizedText() string {
return r.normalizeText(r.Text)
}
func (r MarkdownTextRenderer) normalizeText(text string) string {
lText := strings.ToLower(text)
indexes := markdownPattern.FindAllStringSubmatchIndex(lText, -1)
// Interate through indexes in reverse order.
for i := len(indexes) - 1; i >= 0; i-- {
theIndex := indexes[i]
start, end := theIndex[0], theIndex[1]
contentStart, contentEnd := theIndex[2], theIndex[3]
text = text[:start] + text[contentStart:contentEnd] + text[end:]
}
return text
}
// Returns the position considering unicode characters.
func posUnicode(text string, pos int) int {
return len([]rune(text[:pos]))
}
/*
Render renders the sequence `text` using a markdown-like syntax:
`[hello](red) world` will become: `hello world` where hello is red.
You may also specify other attributes such as bold text:
`[foo](YELLOW, BOLD)` will become `foo` in yellow, bold text.
For all available combinations, colors, and attribute, see: `StringToAttribute`.
This method returns a RenderedSequence
*/
func (r MarkdownTextRenderer) Render(lastColor, background Attribute) RenderedSequence {
return r.RenderSequence(0, -1, lastColor, background)
}
// RenderSequence renders the text just like Render but the start and end can
// be specified.
func (r MarkdownTextRenderer) RenderSequence(start, end int, lastColor, background Attribute) RenderedSequence {
text := r.Text
if end == -1 {
end = len([]rune(r.NormalizedText()))
}
getMatch := func(s string) []int {
return markdownPattern.FindStringSubmatchIndex(strings.ToLower(s))
}
var sequences []ColorSubsequence
for match := getMatch(text); match != nil; match = getMatch(text) {
// Check if match is in the start/end range.
matchStart, matchEnd := match[0], match[1]
colorStart, colorEnd := match[4], match[5]
contentStart, contentEnd := match[2], match[3]
color := StringToAttribute(text[colorStart:colorEnd])
content := text[contentStart:contentEnd]
theSequence := ColorSubsequence{color, contentStart - 1, contentEnd - 1}
theSequence.Start = posUnicode(text, contentStart) - 1
theSequence.End = posUnicode(text, contentEnd) - 1
if start < theSequence.End && end > theSequence.Start {
// Make the sequence relative and append.
theSequence.Start -= start
if theSequence.Start < 0 {
theSequence.Start = 0
}
theSequence.End -= start
if theSequence.End < 0 {
theSequence.End = 0
} else if theSequence.End > end-start {
theSequence.End = end - start
}
sequences = append(sequences, theSequence)
}
text = text[:matchStart] + content + text[matchEnd:]
}
if end == -1 {
end = len(text)
}
runes := []rune(text)[start:end]
return RenderedSequence{string(runes), lastColor, background, sequences, nil}
}
// MarkdownTextRendererFactory is a TextRendererFactory for
// the MarkdownTextRenderer.
type MarkdownTextRendererFactory struct{}
// TextRenderer returns a MarkdownTextRenderer instance.
func (f MarkdownTextRendererFactory) TextRenderer(text string) TextRenderer {
return MarkdownTextRenderer{text}
}
// RenderedSequence is a string sequence that is capable of returning the
// Buffer used by termui for displaying the colorful string.
type RenderedSequence struct {
NormalizedText string
LastColor Attribute
BackgroundColor Attribute
Sequences []ColorSubsequence
// Use the color() method for getting the correct value.
mapSequences map[int]Attribute
}
// A ColorSubsequence represents a color for the given text span.
type ColorSubsequence struct {
Color Attribute
Start int
End int
}
// ColorSubsequencesToMap creates a map with all colors that from the
// subsequences.
func ColorSubsequencesToMap(sequences []ColorSubsequence) map[int]Attribute {
result := make(map[int]Attribute)
for _, theSequence := range sequences {
for i := theSequence.Start; i < theSequence.End; i++ {
result[i] = theSequence.Color
}
}
return result
}
func (s *RenderedSequence) colors() map[int]Attribute {
if s.mapSequences == nil {
s.mapSequences = ColorSubsequencesToMap(s.Sequences)
}
return s.mapSequences
}
// Buffer returns the colorful formatted buffer and the last color that was
// used.
func (s *RenderedSequence) Buffer(x, y int) ([]Point, Attribute) {
buffer := make([]Point, 0, len(s.NormalizedText)) // This is just an assumtion
for i := range []rune(s.NormalizedText) {
p, width := s.PointAt(i, x, y)
buffer = append(buffer, p)
x += width
}
return buffer, s.LastColor
}
// PointAt returns the point at the position of n. The x, and y coordinates
// are used for placing the point at the right position.
// Since some charaters are wider (like `一`), this method also returns the
// width the point actually took.
// This is important for increasing the x property properly.
func (s *RenderedSequence) PointAt(n, x, y int) (Point, int) {
color, ok := s.colors()[n]
if !ok {
color = s.LastColor
}
char := []rune(s.NormalizedText)[n]
return Point{char, s.BackgroundColor, color, x, y}, charWidth(char)
}
// A PlainRenderer does not render the text at all.
type PlainRenderer struct {
Text string
}
// NormalizedText returns the text given in
func (r PlainRenderer) NormalizedText() string {
return r.Text
}
// RenderSequence returns a RenderedSequence that does not have any color
// sequences.
func (r PlainRenderer) RenderSequence(start, end int, lastColor, background Attribute) RenderedSequence {
runes := []rune(r.Text)
if end < 0 {
end = len(runes)
}
runes = runes[start:end]
var s []ColorSubsequence
return RenderedSequence{string(runes), lastColor, background, s, nil}
}
// Render just like RenderSequence
func (r PlainRenderer) Render(lastColor, background Attribute) RenderedSequence {
return r.RenderSequence(0, -1, lastColor, background)
}
// PlainRendererFactory is a TextRendererFactory for
// the PlainRenderer.
type PlainRendererFactory struct{}
// TextRenderer returns a PlainRenderer instance.
func (f PlainRendererFactory) TextRenderer(text string) TextRenderer {
return PlainRenderer{text}
}
// We can't use a raw string here because \033 must not be escaped.
// I'd like to append (?<=m; i.e. lookbehind), but unfortunately,
// it is not supported. So we will need to do that manually.
var escapeRegex = "\033\\[(([0-9]{1,2}[;m])+)"
var colorEscapeCodeRegex = regexp.MustCompile(escapeRegex)
var colorEscapeCodeRegexMatchAll = regexp.MustCompile("^" + escapeRegex + "$")
// An EscapeCode is a unix ASCII Escape code.
type EscapeCode string
func (e EscapeCode) escapeNumberToColor(colorID int) (Attribute, error) {
var color Attribute
switch colorID {
case 0:
color = ColorDefault
case 1:
color = AttrBold
case 4:
color = AttrUnderline
case 30:
color = ColorBlack
case 31:
color = ColorRed
case 32:
color = ColorGreen
case 33:
color = ColorYellow
case 34:
color = ColorBlue
case 35:
color = ColorMagenta
case 36:
color = ColorCyan
case 37:
color = ColorWhite
default:
safeCode := e.MakeSafe()
return 0, fmt.Errorf("Unkown/unsupported escape code: '%v'", safeCode)
}
return color, nil
}
// Color converts the escape code to an `Attribute` (color).
// The EscapeCode must be formatted like this:
// - ASCII-Escape chacter (\033) + [ + Number + (;Number...) + m
// The second number is optimal. The semicolon (;) is used
// to seperate the colors.
// For example: `\033[1;31m` means: the following text is red and bold.
func (e EscapeCode) Color() (Attribute, error) {
escapeCode := string(e)
matches := colorEscapeCodeRegexMatchAll.FindStringSubmatch(escapeCode)
invalidEscapeCode := func() error {
safeCode := e.MakeSafe()
return fmt.Errorf("%v is not a valid ASCII escape code", safeCode)
}
if matches == nil || escapeCode[len(escapeCode)-1] != 'm' {
return 0, invalidEscapeCode()
}
color := Attribute(0)
for _, id := range strings.Split(matches[1][:len(matches[1])-1], ";") {
colorID, err := strconv.Atoi(id)
if err != nil {
return 0, invalidEscapeCode()
}
newColor, err := e.escapeNumberToColor(colorID)
if err != nil {
return 0, err
}
color |= newColor
}
return color, nil
}
// MakeSafe replace the invisible escape code chacacter (\0333)
// with \\0333 so that it will not mess up the terminal when an error
// is shown.
func (e EscapeCode) MakeSafe() string {
return strings.Replace(string(e), "\033", "\\033", -1)
}
// Alias to `EscapeCode.MakeSafe()`
func (e EscapeCode) String() string {
return e.MakeSafe()
}
// Raw returns the raw value of the escape code.
// Alias to string(EscapeCode)
func (e EscapeCode) Raw() string {
return string(e)
}
// IsValid returns whether or not the syntax of the escape code is
// valid and the code is supported.
func (e EscapeCode) IsValid() bool {
_, err := e.Color()
return err == nil
}
// A EscapeCodeRenderer does not render the text at all.
type EscapeCodeRenderer struct {
Text string
}
// NormalizedText strips all escape code outs (even the unkown/unsupported)
// ones.
func (r EscapeCodeRenderer) NormalizedText() string {
matches := colorEscapeCodeRegex.FindAllStringIndex(r.Text, -1)
text := []byte(r.Text)
// Iterate through matches in reverse order
for i := len(matches) - 1; i >= 0; i-- {
start, end := matches[i][0], matches[i][1]
if EscapeCode(text[start:end]).IsValid() {
text = append(text[:start], text[end:]...)
}
}
return string(text)
}
// RenderSequence renders the text just like Render but the start and end may
// be set. If end is -1, the end of the string will be used.
func (r EscapeCodeRenderer) RenderSequence(start, end int, lastColor, background Attribute) RenderedSequence {
normalizedRunes := []rune(r.NormalizedText())
if end < 0 {
end = len(normalizedRunes)
}
text := []byte(r.Text)
matches := colorEscapeCodeRegex.FindAllSubmatchIndex(text, -1)
removed := 0
var sequences []ColorSubsequence
runeLength := func(length int) int {
return len([]rune(string(text[:length])))
}
runes := []rune(r.Text)
for _, theMatch := range matches {
// Escapde code start, escape code end
eStart := runeLength(theMatch[0]) - removed
eEnd := runeLength(theMatch[1]) - removed
escapeCode := EscapeCode(runes[eStart:eEnd])
// If an error occurs (e.g. unkown escape code), we will just ignore it :)
color, err := escapeCode.Color()
if err != nil {
continue
}
// Patch old color sequence
if len(sequences) > 0 {
last := &sequences[len(sequences)-1]
last.End = eStart - start
}
// eEnd < 0 means the the sequence is withing the range.
if eEnd-start >= 0 {
// The sequence starts when the escape code ends and ends when the text
// end. If there is another escape code, this will be patched in the
// previous line.
colorSeq := ColorSubsequence{color, eStart - start, end - start}
if colorSeq.Start < 0 {
colorSeq.Start = 0
}
sequences = append(sequences, colorSeq)
}
runes = append(runes[:eStart], runes[eEnd:]...)
removed += eEnd - eStart
}
runes = runes[start:end]
return RenderedSequence{string(runes), lastColor, background, sequences, nil}
}
// Render just like RenderSequence
func (r EscapeCodeRenderer) Render(lastColor, background Attribute) RenderedSequence {
return r.RenderSequence(0, -1, lastColor, background)
}
// EscapeCodeRendererFactory is a TextRendererFactory for
// the EscapeCodeRenderer.
type EscapeCodeRendererFactory struct{}
// TextRenderer returns a EscapeCodeRenderer instance.
func (f EscapeCodeRendererFactory) TextRenderer(text string) TextRenderer {
return EscapeCodeRenderer{text}
}