Finish TextBuilder

This commit is contained in:
gizak 2015-05-03 21:02:38 -04:00
parent 62105f6883
commit 7f94c273e5
6 changed files with 275 additions and 987 deletions

View File

@ -1,533 +0,0 @@
// +build ignore
package termui
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// Minial interface
type TextBuilder interface {
Build(s string, fg, bg Attribute) []Cells
}
type MarkdownTxBuilder struct {
regex string
pattern *regexp.Regexp
baseFg Attribute
baseBg Attribute
}
var colorMap = map[string]Attribute{
"red": ColorRed,
"blue": ColorBlue,
"black": ColorBlack,
"cyan": ColorCyan,
"white": ColorWhite,
"default": ColorDefault,
"green": ColorGreen,
"magenta": ColorMagenta,
}
var attrMap = map[string]Attribute{
"bold": AttrBold,
"underline": AttrUnderline,
"reverse": AttrReverse,
}
func rmSpc(s string) string {
reg := regexp.MustCompile(`\s+`)
return reg.ReplaceAllString(s, "")
}
// readAttr translates strings like `fg-red,fg-bold,bg-white` to fg and bg Attribute
func (mtb MarkdownTxBuilder) readAttr(s string) (Attribute, Attribute) {
fg := mtb.baseFg
bg := mtb.baseBg
updateAttr := func(a Attribute, attrs []string) Attribute {
for _, s := range attrs {
if c, ok := colorMap[s]; ok {
a &= ^(1<<9 - 1) //erase clr 0 ~ 1<<9-1
a |= c // set clr
}
if c, ok := attrMap[s]; ok {
a |= c
}
}
return a
}
ss := strings.Split(s, ",")
fgs := []string{}
bgs := []string{}
for _, v := range ss {
subs := strings.Split(ss, "-")
if len(subs) > 1 {
if subs[0] == "fg" {
fgs := append(fgs, subs[1])
}
if subs[0] == "bg" {
bgs := append(bgs, subs[1])
}
}
}
fg = updateAttr(fg)
bg = updateAttr(bg)
return fg, bg
}
type EscCodeTxBuilder struct {
regex string
pattern *regexp.Regexp
}
// 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}
}

View File

@ -1,454 +0,0 @@
package termui
import (
"fmt"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTextRender_TestInterface(t *testing.T) {
var inter *TextRenderer
assert.Implements(t, inter, new(MarkdownTextRenderer))
assert.Implements(t, inter, new(EscapeCodeRenderer))
assert.Implements(t, inter, new(PlainRenderer))
}
func TestTextRendererFactory_TestInterface(t *testing.T) {
var inter *TextRendererFactory
assert.Implements(t, inter, new(MarkdownTextRendererFactory))
assert.Implements(t, inter, new(EscapeCodeRendererFactory))
assert.Implements(t, inter, new(PlainRendererFactory))
}
func TestMarkdownTextRenderer_normalizeText(t *testing.T) {
renderer := MarkdownTextRenderer{}
got := renderer.normalizeText("[ERROR](red,bold) Something went wrong")
assert.Equal(t, got, "ERROR Something went wrong")
got = renderer.normalizeText("[foo](red) hello [bar](green) world")
assert.Equal(t, got, "foo hello bar world")
got = renderer.normalizeText("[foo](g) hello [bar]green (world)")
assert.Equal(t, got, "foo hello [bar]green (world)")
got = "笀耔 [澉 灊灅甗](RED) 郔镺 笀耔 澉 [灊灅甗](yellow) 郔镺"
expected := "笀耔 澉 灊灅甗 郔镺 笀耔 澉 灊灅甗 郔镺"
assert.Equal(t, renderer.normalizeText(got), expected)
got = renderer.normalizeText("[(foo)](red,white) bar")
assert.Equal(t, renderer.normalizeText(got), "(foo) bar")
// TODO: make this regex work correctly:
// got = renderer.normalizeText("[[foo]](red,white) bar")
// assert.Equal(t, renderer.normalizeText(got), "[foo] bar")
// I had to comment it out because the unit tests keep failing and
// I don't know how to fix it. See more:
// https://github.com/gizak/termui/pull/22
}
func TestMarkdownTextRenderer_NormalizedText(t *testing.T) {
renderer := MarkdownTextRenderer{"[ERROR](red,bold) Something went wrong"}
assert.Equal(t, renderer.NormalizedText(), "ERROR Something went wrong")
}
func assertRenderSequence(t *testing.T, sequence RenderedSequence, last, background Attribute, text string, lenSequences int) bool {
msg := fmt.Sprintf("seq: %v", spew.Sdump(sequence))
assert.Equal(t, last, sequence.LastColor, msg)
assert.Equal(t, background, sequence.BackgroundColor, msg)
assert.Equal(t, text, sequence.NormalizedText, msg)
return assert.Equal(t, lenSequences, len(sequence.Sequences), msg)
}
func assertColorSubsequence(t *testing.T, s ColorSubsequence, color string, start, end int) {
assert.Equal(t, ColorSubsequence{StringToAttribute(color), start, end}, s)
}
func TestMarkdownTextRenderer_RenderSequence(t *testing.T) {
// Simple test.
renderer := MarkdownTextRenderer{"[ERROR](red,bold) something went wrong"}
got := renderer.RenderSequence(0, -1, 3, 5)
if assertRenderSequence(t, got, 3, 5, "ERROR something went wrong", 1) {
assertColorSubsequence(t, got.Sequences[0], "RED,BOLD", 0, 5)
}
got = renderer.RenderSequence(3, 8, 3, 5)
if assertRenderSequence(t, got, 3, 5, "OR so", 1) {
assertColorSubsequence(t, got.Sequences[0], "RED,BOLD", 0, 2)
}
// Test for mutiple colors.
renderer = MarkdownTextRenderer{"[foo](red) hello [bar](blue) world"}
got = renderer.RenderSequence(0, -1, 7, 2)
if assertRenderSequence(t, got, 7, 2, "foo hello bar world", 2) {
assertColorSubsequence(t, got.Sequences[0], "RED", 0, 3)
assertColorSubsequence(t, got.Sequences[1], "BLUE", 10, 13)
}
// Test that out-of-bound color sequences are not added.
got = renderer.RenderSequence(4, 6, 8, 1)
assertRenderSequence(t, got, 8, 1, "he", 0)
// Test Half-rendered text
got = renderer.RenderSequence(1, 12, 0, 0)
if assertRenderSequence(t, got, 0, 0, "oo hello ba", 2) {
assertColorSubsequence(t, got.Sequences[0], "RED", 0, 2)
assertColorSubsequence(t, got.Sequences[1], "BLUE", 9, 11)
}
// Test Half-rendered text (edges)
got = renderer.RenderSequence(2, 11, 0, 0)
if assertRenderSequence(t, got, 0, 0, "o hello b", 2) {
assertColorSubsequence(t, got.Sequences[0], "RED", 0, 1)
assertColorSubsequence(t, got.Sequences[1], "BLUE", 8, 9)
}
// TODO: test barkets
// Test with unicodes
text := "笀耔 [澉 灊灅甗](RED) 郔镺 笀耔 澉 [灊灅甗](yellow) 郔镺"
normalized := "笀耔 澉 灊灅甗 郔镺 笀耔 澉 灊灅甗 郔镺"
renderer = MarkdownTextRenderer{text}
got = renderer.RenderSequence(0, -1, 4, 7)
if assertRenderSequence(t, got, 4, 7, normalized, 2) {
assertColorSubsequence(t, got.Sequences[0], "RED", 3, 8)
assertColorSubsequence(t, got.Sequences[1], "YELLOW", 17, 20)
}
got = renderer.RenderSequence(6, 7, 0, 0)
if assertRenderSequence(t, got, 0, 0, "灅", 1) {
assertColorSubsequence(t, got.Sequences[0], "RED", 0, 1)
}
got = renderer.RenderSequence(7, 19, 0, 0)
if assertRenderSequence(t, got, 0, 0, "甗 郔镺 笀耔 澉 灊灅", 2) {
assertColorSubsequence(t, got.Sequences[0], "RED", 0, 1)
assertColorSubsequence(t, got.Sequences[1], "YELLOW", 10, 12)
}
// Test inside
renderer = MarkdownTextRenderer{"foo [foobar](red) bar"}
got = renderer.RenderSequence(4, 10, 0, 0)
if assertRenderSequence(t, got, 0, 0, "foobar", 1) {
assertColorSubsequence(t, got.Sequences[0], "RED", 0, 6)
}
}
func TestMarkdownTextRenderer_Render(t *testing.T) {
renderer := MarkdownTextRenderer{"[foo](red,bold) [bar](blue)"}
got := renderer.Render(6, 8)
if assertRenderSequence(t, got, 6, 8, "foo bar", 2) {
assertColorSubsequence(t, got.Sequences[0], "RED,BOLD", 0, 3)
assertColorSubsequence(t, got.Sequences[1], "blue", 4, 7)
}
}
func TestMarkdownTextRendererFactory(t *testing.T) {
factory := MarkdownTextRendererFactory{}
expected := MarkdownTextRenderer{"Hello world"}
assert.Equal(t, factory.TextRenderer("Hello world"), expected)
}
func TestColorSubsequencesToMap(t *testing.T) {
colorSubsequences := []ColorSubsequence{
{ColorRed, 1, 4},
{ColorBlue | AttrBold, 9, 10},
}
expected := make(map[int]Attribute)
expected[1] = ColorRed
expected[2] = ColorRed
expected[3] = ColorRed
expected[9] = ColorBlue | AttrBold
assert.Equal(t, expected, ColorSubsequencesToMap(colorSubsequences))
}
func getTestRenderedSequence() RenderedSequence {
cs := []ColorSubsequence{
{ColorRed, 3, 5},
{ColorBlue | AttrBold, 9, 10},
}
return RenderedSequence{"Hello world", ColorWhite, ColorBlack, cs, nil}
}
func newTestPoint(char rune, x, y int, colorA ...Attribute) Point {
var color Attribute
if colorA != nil && len(colorA) == 1 {
color = colorA[0]
} else {
color = ColorWhite
}
return Point{char, ColorBlack, color, x, y}
}
func TestRenderedSequence_Buffer(t *testing.T) {
sequence := getTestRenderedSequence()
expected := []Point{
newTestPoint('H', 5, 7),
newTestPoint('e', 6, 7),
newTestPoint('l', 7, 7),
newTestPoint('l', 7, 7, ColorRed),
newTestPoint('o', 8, 7, ColorRed),
newTestPoint(' ', 9, 7),
newTestPoint('w', 10, 7),
newTestPoint('o', 11, 7),
newTestPoint('r', 12, 7),
newTestPoint('l', 13, 7, ColorBlue|AttrBold),
newTestPoint('d', 14, 7),
}
buffer, lastColor := sequence.Buffer(5, 7)
assert.Equal(t, expected[:3], buffer[:3])
assert.Equal(t, ColorWhite, lastColor)
}
func AssertPoint(t *testing.T, got Point, char rune, x, y int, colorA ...Attribute) {
expected := newTestPoint(char, x, y, colorA...)
assert.Equal(t, expected, got)
}
func TestRenderedSequence_PointAt(t *testing.T) {
sequence := getTestRenderedSequence()
pointAt := func(n, x, y int) Point {
p, w := sequence.PointAt(n, x, y)
assert.Equal(t, w, 1)
return p
}
AssertPoint(t, pointAt(0, 3, 4), 'H', 3, 4)
AssertPoint(t, pointAt(1, 2, 1), 'e', 2, 1)
AssertPoint(t, pointAt(2, 6, 3), 'l', 6, 3)
AssertPoint(t, pointAt(3, 8, 8), 'l', 8, 8, ColorRed)
AssertPoint(t, pointAt(4, 1, 4), 'o', 1, 4, ColorRed)
AssertPoint(t, pointAt(5, 3, 6), ' ', 3, 6)
AssertPoint(t, pointAt(6, 4, 3), 'w', 4, 3)
AssertPoint(t, pointAt(7, 5, 2), 'o', 5, 2)
AssertPoint(t, pointAt(8, 0, 5), 'r', 0, 5)
AssertPoint(t, pointAt(9, 9, 0), 'l', 9, 0, ColorBlue|AttrBold)
AssertPoint(t, pointAt(10, 7, 1), 'd', 7, 1)
}
func getTestPlainRenderer() PlainRenderer {
return PlainRenderer{"[Hello](red) \x1b[31mworld"}
}
func TestPlainRenderer_NormalizedText(t *testing.T) {
r := getTestPlainRenderer()
assert.Equal(t, "[Hello](red) \x1b[31mworld", r.NormalizedText())
assert.Equal(t, "[Hello](red) \x1b[31mworld", r.Text)
}
func TestPlainRenderer_Render(t *testing.T) {
renderer := getTestPlainRenderer()
got := renderer.Render(5, 7)
assertRenderSequence(t, got, 5, 7, "[Hello](red) \x1b[31mworld", 0)
}
func TestPlainRenderer_RenderSequence(t *testing.T) {
renderer := getTestPlainRenderer()
got := renderer.RenderSequence(3, 5, 9, 1)
assertRenderSequence(t, got, 9, 1, "ll", 0)
}
func TestPlainRendererFactory(t *testing.T) {
factory := PlainRendererFactory{}
expected := PlainRenderer{"Hello world"}
assert.Equal(t, factory.TextRenderer("Hello world"), expected)
}
func TestPosUnicode(t *testing.T) {
// Every characters takes 3 bytes
text := "你好世界"
require.Equal(t, "你好", text[:6])
assert.Equal(t, 2, posUnicode(text, 6))
}
// Make `escapeCode` safe (i.e. replace \033 by \\033) so that it is not
// formatted.
// func makeEscapeCodeSafe(escapeCode string) string {
// return strings.Replace(escapeCode, "\033", "\\033", -1)
// }
func TestEscapeCode_Color(t *testing.T) {
codes := map[EscapeCode]Attribute{
"\033[30m": ColorBlack,
"\033[31m": ColorRed,
"\033[32m": ColorGreen,
"\033[33m": ColorYellow,
"\033[34m": ColorBlue,
"\033[35m": ColorMagenta,
"\033[36m": ColorCyan,
"\033[37m": ColorWhite,
"\033[1;31m": ColorRed | AttrBold,
"\033[1;4;31m": ColorRed | AttrBold | AttrUnderline,
"\033[0m": ColorDefault,
}
for code, color := range codes {
got, err := code.Color()
msg := fmt.Sprintf("Escape code: '%v'", code.MakeSafe())
if assert.NoError(t, err, msg) {
assert.Equal(t, color, got, msg)
}
}
invalidEscapeCodes := []EscapeCode{
"\03354m",
"[54m",
"\033[34",
"\033[34;m",
"\033[34m;",
"\033[34;",
"\033[5432m",
"t\033[30m",
"t\033[30ms",
"\033[30ms",
}
errMsg := "%v is not a valid ASCII escape code"
for _, invalidEscapeCode := range invalidEscapeCodes {
color, err := invalidEscapeCode.Color()
safeEscapeCode := invalidEscapeCode.MakeSafe()
expectedErr := fmt.Sprintf(errMsg, safeEscapeCode)
if assert.EqualError(t, err, expectedErr, "Expected: "+expectedErr) {
assert.Equal(t, color, Attribute(0))
}
}
outOfRangeCodes := []EscapeCode{
"\033[2m",
"\033[3m",
"\033[3m",
"\033[5m",
"\033[6m",
"\033[7m",
"\033[8m",
"\033[38m",
"\033[39m",
"\033[40m",
"\033[41m",
"\033[43m",
"\033[45m",
"\033[46m",
"\033[48m",
"\033[49m",
"\033[50m",
}
for _, code := range outOfRangeCodes {
color, err := code.Color()
safeCode := code.MakeSafe()
errMsg := fmt.Sprintf("Unkown/unsupported escape code: '%v'", safeCode)
if assert.EqualError(t, err, errMsg) {
assert.Equal(t, color, Attribute(0), "Escape Code: "+safeCode)
}
}
// Special case: check for out of slice panic on empty string
_, err := EscapeCode("").Color()
assert.EqualError(t, err, " is not a valid ASCII escape code")
}
func TestEscapeCode_String(t *testing.T) {
e := EscapeCode("\033[32m")
assert.Equal(t, "\\033[32m", e.String())
}
func TestEscapeCode_Raw(t *testing.T) {
e := EscapeCode("\033[32m")
assert.Equal(t, "\033[32m", e.Raw())
}
func TestEscapeCodeRenderer_NormalizedText(t *testing.T) {
renderer := EscapeCodeRenderer{"\033[33mtest \033[35mfoo \033[33;1mbar"}
assert.Equal(t, "test foo bar", renderer.NormalizedText())
renderer = EscapeCodeRenderer{"hello \033[38mtest"}
assert.Equal(t, "hello \033[38mtest", renderer.NormalizedText())
}
func TestEscapeCodeRenderer_RenderSequence(t *testing.T) {
black, white := ColorWhite, ColorBlack
renderer := EscapeCodeRenderer{"test \033[33mfoo \033[31mbar"}
sequence := renderer.RenderSequence(0, -1, black, white)
if assertRenderSequence(t, sequence, black, white, "test foo bar", 2) {
assertColorSubsequence(t, sequence.Sequences[0], "YELLOW", 5, 9)
assertColorSubsequence(t, sequence.Sequences[1], "RED", 9, 12)
getPoint := func(n int) Point {
point, width := sequence.PointAt(n, 10+n, 30)
assert.Equal(t, 1, width)
return point
}
// Also test the points at to make sure that
// I didn't make a counting mistake...
AssertPoint(t, getPoint(0), 't', 10, 30)
AssertPoint(t, getPoint(1), 'e', 11, 30)
AssertPoint(t, getPoint(2), 's', 12, 30)
AssertPoint(t, getPoint(3), 't', 13, 30)
AssertPoint(t, getPoint(4), ' ', 14, 30)
AssertPoint(t, getPoint(5), 'f', 15, 30, ColorYellow)
AssertPoint(t, getPoint(6), 'o', 16, 30, ColorYellow)
AssertPoint(t, getPoint(7), 'o', 17, 30, ColorYellow)
AssertPoint(t, getPoint(8), ' ', 18, 30, ColorYellow)
AssertPoint(t, getPoint(9), 'b', 19, 30, ColorRed)
AssertPoint(t, getPoint(10), 'a', 20, 30, ColorRed)
AssertPoint(t, getPoint(11), 'r', 21, 30, ColorRed)
}
renderer = EscapeCodeRenderer{"甗 郔\033[33m镺 笀耔 澉 灊\033[31m灅甗"}
sequence = renderer.RenderSequence(2, -1, black, white)
if assertRenderSequence(t, sequence, black, white, "郔镺 笀耔 澉 灊灅甗", 2) {
assertColorSubsequence(t, sequence.Sequences[0], "YELLOW", 1, 9)
assertColorSubsequence(t, sequence.Sequences[1], "RED", 9, 11)
}
renderer = EscapeCodeRenderer{"\033[33mHell\033[31mo world"}
sequence = renderer.RenderSequence(2, -1, black, white)
if assertRenderSequence(t, sequence, black, white, "llo world", 2) {
assertColorSubsequence(t, sequence.Sequences[0], "YELLOW", 0, 2)
assertColorSubsequence(t, sequence.Sequences[1], "RED", 2, 9)
}
sequence = renderer.RenderSequence(1, 7, black, white)
if assertRenderSequence(t, sequence, black, white, "ello w", 2) {
assertColorSubsequence(t, sequence.Sequences[0], "YELLOW", 0, 3)
assertColorSubsequence(t, sequence.Sequences[1], "RED", 3, 6)
}
sequence = renderer.RenderSequence(6, 10, black, white)
if assertRenderSequence(t, sequence, black, white, "worl", 1) {
assertColorSubsequence(t, sequence.Sequences[0], "RED", 0, 4)
}
// Test with out-of-range escape code
renderer = EscapeCodeRenderer{"hello \033[38mtest"}
sequence = renderer.RenderSequence(0, -1, black, white)
assertRenderSequence(t, sequence, black, white, "hello \033[38mtest", 0)
}
func TestEscapeCodeRenderer_Render(t *testing.T) {
renderer := EscapeCodeRenderer{"test \033[33mfoo \033[31mbar"}
sequence := renderer.Render(4, 6)
if assertRenderSequence(t, sequence, 4, 6, "test foo bar", 2) {
assertColorSubsequence(t, sequence.Sequences[0], "YELLOW", 5, 9)
assertColorSubsequence(t, sequence.Sequences[1], "RED", 9, 12)
}
}
func TestEscapeCodeRendererFactory_TextRenderer(t *testing.T) {
factory := EscapeCodeRendererFactory{}
assert.Equal(t, EscapeCodeRenderer{"foo"}, factory.TextRenderer("foo"))
assert.Equal(t, EscapeCodeRenderer{"bar"}, factory.TextRenderer("bar"))
}

209
textbuilder.go Normal file
View File

@ -0,0 +1,209 @@
package termui
import (
"fmt"
"regexp"
"strings"
)
// TextBuilder is a minial interface to produce text []Cell using sepcific syntax (markdown).
type TextBuilder interface {
Build(s string, fg, bg Attribute) []Cell
}
// MarkdownTxBuilder implements TextBuilder interface, using markdown syntax.
type MarkdownTxBuilder struct {
baseFg Attribute
baseBg Attribute
plainTx []rune
markers []marker
}
type marker struct {
st int
ed int
fg Attribute
bg Attribute
}
var colorMap = map[string]Attribute{
"red": ColorRed,
"blue": ColorBlue,
"black": ColorBlack,
"cyan": ColorCyan,
"white": ColorWhite,
"default": ColorDefault,
"green": ColorGreen,
"magenta": ColorMagenta,
}
var attrMap = map[string]Attribute{
"bold": AttrBold,
"underline": AttrUnderline,
"reverse": AttrReverse,
}
func rmSpc(s string) string {
reg := regexp.MustCompile(`\s+`)
return reg.ReplaceAllString(s, "")
}
// readAttr translates strings like `fg-red,fg-bold,bg-white` to fg and bg Attribute
func (mtb MarkdownTxBuilder) readAttr(s string) (Attribute, Attribute) {
fg := mtb.baseFg
bg := mtb.baseBg
updateAttr := func(a Attribute, attrs []string) Attribute {
for _, s := range attrs {
if c, ok := colorMap[s]; ok {
a &= 0xFF00 //erase clr 0 ~ 8 bits
a |= c // set clr
}
if c, ok := attrMap[s]; ok {
a |= c
}
}
return a
}
ss := strings.Split(s, ",")
fgs := []string{}
bgs := []string{}
for _, v := range ss {
subs := strings.Split(v, "-")
if len(subs) > 1 {
if subs[0] == "fg" {
fgs = append(fgs, subs[1])
}
if subs[0] == "bg" {
bgs = append(bgs, subs[1])
}
}
}
fg = updateAttr(fg, fgs)
bg = updateAttr(bg, bgs)
return fg, bg
}
func (mtb *MarkdownTxBuilder) reset() {
mtb.plainTx = []rune{}
mtb.markers = []marker{}
}
// parse
func (mtb *MarkdownTxBuilder) parse(str string) {
rs := str2runes(str)
normTx := []rune{}
square := []rune{}
brackt := []rune{}
accSquare := false
accBrackt := false
cntSquare := 0
reset := func() {
square = []rune{}
brackt = []rune{}
accSquare = false
accBrackt = false
cntSquare = 0
}
rollback := func() {
normTx = append(normTx, square...)
normTx = append(normTx, brackt...)
reset()
}
chop := func(s []rune) []rune {
defer func() {
if r := recover(); r != nil {
fmt.Println(string(s))
}
}()
return s[1 : len(s)-1]
}
for i, r := range rs {
switch {
// stacking brackt
case accBrackt:
brackt = append(brackt, r)
if ')' == r {
fg, bg := mtb.readAttr(string(chop(brackt)))
st := len(normTx)
ed := len(normTx) + len(square) - 2
mtb.markers = append(mtb.markers, marker{st, ed, fg, bg})
normTx = append(normTx, chop(square)...)
reset()
} else if i+1 == len(rs) {
rollback()
}
// stacking square
case accSquare:
switch {
// squares closed and followed by a '('
case cntSquare == 0 && '(' == r:
accBrackt = true
brackt = append(brackt, '(')
// squares closed but not followed by a '('
case cntSquare == 0:
rollback()
if '[' == r {
accSquare = true
cntSquare = 1
brackt = append(brackt, '[')
} else {
normTx = append(normTx, r)
}
// hit the end
case i+1 == len(rs):
square = append(square, r)
rollback()
case '[' == r:
cntSquare++
square = append(square, '[')
case ']' == r:
cntSquare--
square = append(square, ']')
// normal char
default:
square = append(square, r)
}
// stacking normTx
default:
if '[' == r {
accSquare = true
cntSquare = 1
square = append(square, '[')
} else {
normTx = append(normTx, r)
}
}
}
mtb.plainTx = normTx
}
func (mtb MarkdownTxBuilder) Build(s string, fg, bg Attribute) []Cell {
mtb.baseFg = fg
mtb.baseBg = bg
mtb.reset()
mtb.parse(s)
cs := make([]Cell, len(mtb.plainTx))
for i := range cs {
cs[i] = Cell{Ch: mtb.plainTx[i], Fg: fg, Bg: bg}
}
for _, mrk := range mtb.markers {
for i := mrk.st; i < mrk.ed; i++ {
cs[i].Fg = mrk.fg
cs[i].Bg = mrk.bg
}
}
return cs
}
func NewMarkdownTxBuilder() TextBuilder {
return MarkdownTxBuilder{}
}

66
textbuilder_test.go Normal file
View File

@ -0,0 +1,66 @@
package termui
import "testing"
func TestReadAttr(t *testing.T) {
m := MarkdownTxBuilder{}
m.baseFg = ColorCyan | AttrUnderline
m.baseBg = ColorBlue | AttrBold
fg, bg := m.readAttr("fg-red,bg-reverse")
if fg != ColorRed|AttrUnderline || bg != ColorBlue|AttrBold|AttrReverse {
t.Error("readAttr failed")
}
}
func TestMTBParse(t *testing.T) {
/*
str := func(cs []Cell) string {
rs := make([]rune, len(cs))
for i := range cs {
rs[i] = cs[i].Ch
}
return string(rs)
}
*/
tbls := [][]string{
{"hello world", "hello world"},
{"[hello](fg-red) world", "hello world"},
{"[[hello]](bg-red) world", "[hello] world"},
{"[1] hello world", "[1] hello world"},
{"[[1]](bg-white) [hello] world", "[1] [hello] world"},
{"[hello world]", "[hello world]"},
{"", ""},
{"[hello world)", "[hello world)"},
{"[0] [hello](bg-red)[ world](fg-blue)!", "[0] hello world!"},
}
m := MarkdownTxBuilder{}
m.baseFg = ColorWhite
m.baseBg = ColorDefault
for _, s := range tbls {
m.reset()
m.parse(s[0])
res := string(m.plainTx)
if s[1] != res {
t.Errorf("\ninput :%s\nshould:%s\noutput:%s", s[0], s[1], res)
}
}
m.reset()
m.parse("[0] [hello](bg-red)[ world](fg-blue)")
if len(m.markers) != 2 &&
m.markers[0].st == 4 &&
m.markers[0].ed == 11 &&
m.markers[0].fg == ColorWhite &&
m.markers[0].bg == ColorRed {
t.Error("markers dismatch")
}
m2 := NewMarkdownTxBuilder()
cs := m2.Build("[0] [hellob-e) wrd]fgblue)!", ColorWhite, ColorBlack)
cs = m2.Build("[0] [hello](bg-red) [world](fg-blue)!", ColorWhite, ColorBlack)
if cs[4].Ch != 'h' && cs[4].Bg != ColorRed && cs[4].Fg != ColorWhite {
t.Error("dismatch in Build")
}
}