From 7f94c273e57c8c5193ed1fafa9cd2161b1672efe Mon Sep 17 00:00:00 2001 From: gizak Date: Sun, 3 May 2015 21:02:38 -0400 Subject: [PATCH] Finish TextBuilder --- block_test.go => _block_test.go | 0 grid_test.go => _grid_test.go | 0 textRender.go | 533 -------------------------------- textRender_test.go | 454 --------------------------- textbuilder.go | 209 +++++++++++++ textbuilder_test.go | 66 ++++ 6 files changed, 275 insertions(+), 987 deletions(-) rename block_test.go => _block_test.go (100%) rename grid_test.go => _grid_test.go (100%) delete mode 100644 textRender.go delete mode 100644 textRender_test.go create mode 100644 textbuilder.go create mode 100644 textbuilder_test.go diff --git a/block_test.go b/_block_test.go similarity index 100% rename from block_test.go rename to _block_test.go diff --git a/grid_test.go b/_grid_test.go similarity index 100% rename from grid_test.go rename to _grid_test.go diff --git a/textRender.go b/textRender.go deleted file mode 100644 index 97a5aba..0000000 --- a/textRender.go +++ /dev/null @@ -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} -} diff --git a/textRender_test.go b/textRender_test.go deleted file mode 100644 index 7428655..0000000 --- a/textRender_test.go +++ /dev/null @@ -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")) -} diff --git a/textbuilder.go b/textbuilder.go new file mode 100644 index 0000000..4c7f2af --- /dev/null +++ b/textbuilder.go @@ -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{} +} diff --git a/textbuilder_test.go b/textbuilder_test.go new file mode 100644 index 0000000..93aa62e --- /dev/null +++ b/textbuilder_test.go @@ -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") + } +}