diff --git a/README.md b/README.md index 87e7a90..55c12f1 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ The `helloworld` color scheme drops in some colors! list +#### Colored List +[demo code](https://github.com/gizak/termui/blob/master/example/coloredList.go) + +TODO: Image (let's wait until the implementation is finished). + #### Gauge [demo code](https://github.com/gizak/termui/blob/master/example/gauge.go) diff --git a/example/coloredList.go b/example/coloredList.go new file mode 100644 index 0000000..c356bee --- /dev/null +++ b/example/coloredList.go @@ -0,0 +1,90 @@ +// +build ignore + +package main + +import "github.com/gizak/termui" +import "github.com/nsf/termbox-go" + +func markdownList() *termui.List { + strs := []string{ + "[0] github.com/gizak/termui", + "[1] 笀耔 [澉 灊灅甗](RED) 郔镺 笀耔 澉 [灊灅甗](yellow) 郔镺", + "[2] こんにちは世界", + "[3] keyboard.go", + "[4] [output](RED).go", + "[5] random_out.go", + "[6] [dashboard](BOLD).go", + "[7] nsf/termbox-go", + "[8] OVERFLOW!!!!!!![!!!!!!!!!!!!](red,bold)!!!"} + + list := termui.NewList() + list.Items = strs + list.Height = 15 + list.Width = 26 + list.RendererFactory = termui.MarkdownTextRendererFactory{} + + return list +} + +func hideList(list *termui.List) *termui.List { + list.Border.Label = "List - Hidden" + list.Overflow = "hidden" + + return list +} + +func wrapList(list *termui.List) *termui.List { + list.Border.Label = "List - Wrapped" + list.Overflow = "wrap" + list.X = 30 + + return list +} + +func escapeList() *termui.List { + strs := []string{ + "[0] github.com/gizak/termui", + "[1] 笀耔 \033[31m澉 灊灅甗 \033[0m郔镺 笀耔 澉 \033[33m灊灅甗 郔镺", + "[2] こんにちは世界", + "[3] keyboard.go", + "[4] \033[31moutput\033[0m.go", + "[5] random_out.go", + "[6] \033[1mdashboard\033[0m.go", + "[7] nsf/termbox-go", + "[8] OVERFLOW!!!!!!!\033[31;1m!!!!!!!!!!!!\033[0m!!!", + } + + list := termui.NewList() + list.RendererFactory = termui.EscapeCodeRendererFactory{} + list.Items = strs + list.Height = 15 + list.Width = 26 + list.Y = 15 + + return list +} + +func main() { + err := termui.Init() + if err != nil { + panic(err) + } + defer termui.Close() + + hiddenMarkdownList := hideList(markdownList()) + wrappedMarkdownList := wrapList(markdownList()) + + hiddenEscapeList := hideList(escapeList()) + wrappedEscapeList := wrapList(escapeList()) + + lists := []termui.Bufferer{ + hiddenEscapeList, + hiddenMarkdownList, + wrappedMarkdownList, + wrappedEscapeList, + } + + termui.UseTheme("helloworld") + termui.Render(lists...) + termbox.PollEvent() +} diff --git a/example/par.go b/example/par.go index ffbc60a..6c13340 100644 --- a/example/par.go +++ b/example/par.go @@ -29,7 +29,8 @@ func main() { par1.X = 20 par1.Border.Label = "标签" - par2 := termui.NewPar("Simple text\nwith label. It can be multilined with \\n or break automatically") + par2 := termui.NewPar("Simple colored text\nwith label. It [can be](RED) multilined with \\n or [break automatically](GREEN, BOLD)") + par2.RendererFactory = termui.MarkdownTextRendererFactory{} par2.Height = 5 par2.Width = 37 par2.Y = 4 diff --git a/helper.go b/helper.go index dec705e..86906c8 100644 --- a/helper.go +++ b/helper.go @@ -4,7 +4,12 @@ package termui -import tm "github.com/nsf/termbox-go" +import ( + "regexp" + "strings" + + tm "github.com/nsf/termbox-go" +) import rw "github.com/mattn/go-runewidth" /* ---------------Port from termbox-go --------------------- */ @@ -45,15 +50,39 @@ func str2runes(s string) []rune { return []rune(s) } +// Here for backwards-compatibility. func trimStr2Runes(s string, w int) []rune { + return TrimStr2Runes(s, w) +} + +// TrimStr2Runes trims string to w[-1 rune], appends …, and returns the runes +// of that string if string is grather then n. If string is small then w, +// return the runes. +func TrimStr2Runes(s string, w int) []rune { if w <= 0 { return []rune{} } + sw := rw.StringWidth(s) if sw > w { return []rune(rw.Truncate(s, w, dot)) } - return str2runes(s) //[]rune(rw.Truncate(s, w, "")) + return str2runes(s) +} + +// TrimStrIfAppropriate trim string to "s[:-1] + …" +// if string > width otherwise return string +func TrimStrIfAppropriate(s string, w int) string { + if w <= 0 { + return "" + } + + sw := rw.StringWidth(s) + if sw > w { + return rw.Truncate(s, w, dot) + } + + return s } func strWidth(s string) int { @@ -63,3 +92,59 @@ func strWidth(s string) int { func charWidth(ch rune) int { return rw.RuneWidth(ch) } + +var whiteSpaceRegex = regexp.MustCompile(`\s`) + +// StringToAttribute converts text to a termui attribute. You may specifiy more +// then one attribute like that: "BLACK, BOLD, ...". All whitespaces +// are ignored. +func StringToAttribute(text string) Attribute { + text = whiteSpaceRegex.ReplaceAllString(strings.ToLower(text), "") + attributes := strings.Split(text, ",") + result := Attribute(0) + + for _, theAttribute := range attributes { + var match Attribute + switch theAttribute { + case "reset", "default": + match = ColorDefault + + case "black": + match = ColorBlack + + case "red": + match = ColorRed + + case "green": + match = ColorGreen + + case "yellow": + match = ColorYellow + + case "blue": + match = ColorBlue + + case "magenta": + match = ColorMagenta + + case "cyan": + match = ColorCyan + + case "white": + match = ColorWhite + + case "bold": + match = AttrBold + + case "underline": + match = AttrUnderline + + case "reverse": + match = AttrReverse + } + + result |= match + } + + return result +} diff --git a/helper_test.go b/helper_test.go index 6d1a561..5d277de 100644 --- a/helper_test.go +++ b/helper_test.go @@ -7,22 +7,20 @@ package termui import ( "testing" - "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" ) func TestStr2Rune(t *testing.T) { s := "你好,世界." rs := str2runes(s) if len(rs) != 6 { - t.Error() + t.Error(t) } } func TestWidth(t *testing.T) { s0 := "つのだ☆HIRO" s1 := "11111111111" - spew.Dump(s0) - spew.Dump(s1) // above not align for setting East Asian Ambiguous to wide!! if strWidth(s0) != strWidth(s1) { @@ -56,3 +54,17 @@ func TestTrim(t *testing.T) { t.Error("avoid trim failed") } } + +func TestTrimStrIfAppropriate_NoTrim(t *testing.T) { + assert.Equal(t, "hello", TrimStrIfAppropriate("hello", 5)) +} + +func TestTrimStrIfAppropriate(t *testing.T) { + assert.Equal(t, "hel…", TrimStrIfAppropriate("hello", 4)) + assert.Equal(t, "h…", TrimStrIfAppropriate("hello", 2)) +} + +func TestStringToAttribute(t *testing.T) { + assert.Equal(t, ColorRed, StringToAttribute("ReD")) + assert.Equal(t, ColorRed|AttrBold, StringToAttribute("RED, bold")) +} diff --git a/list.go b/list.go index 0640932..e6e5f11 100644 --- a/list.go +++ b/list.go @@ -4,8 +4,6 @@ package termui -import "strings" - // List displays []string as its items, // it has a Overflow option (default is "hidden"), when set to "hidden", // the item exceeding List's width is truncated, but when set to "wrap", @@ -31,10 +29,11 @@ import "strings" */ type List struct { Block - Items []string - Overflow string - ItemFgColor Attribute - ItemBgColor Attribute + Items []string + Overflow string + ItemFgColor Attribute + ItemBgColor Attribute + RendererFactory TextRendererFactory } // NewList returns a new *List with current theme. @@ -43,62 +42,54 @@ func NewList() *List { l.Overflow = "hidden" l.ItemFgColor = theme.ListItemFg l.ItemBgColor = theme.ListItemBg + l.RendererFactory = PlainRendererFactory{} return l } // Buffer implements Bufferer interface. func (l *List) Buffer() []Point { - ps := l.Block.Buffer() - switch l.Overflow { - case "wrap": - rs := str2runes(strings.Join(l.Items, "\n")) - i, j, k := 0, 0, 0 - for i < l.innerHeight && k < len(rs) { - w := charWidth(rs[k]) - if rs[k] == '\n' || j+w > l.innerWidth { - i++ - j = 0 - if rs[k] == '\n' { - k++ + buffer := l.Block.Buffer() + + breakLoop := func(y int) bool { + return y+1 > l.innerHeight + } + y := 0 + +MainLoop: + for _, item := range l.Items { + x := 0 + bg, fg := l.ItemFgColor, l.ItemBgColor + renderer := l.RendererFactory.TextRenderer(item) + sequence := renderer.Render(bg, fg) + + for n := range []rune(sequence.NormalizedText) { + point, width := sequence.PointAt(n, x+l.innerX, y+l.innerY) + + if width+x <= l.innerWidth { + buffer = append(buffer, point) + x += width + } else { + if l.Overflow == "wrap" { + y++ + if breakLoop(y) { + break MainLoop + } + x = 0 + } else { + dotR := []rune(dot)[0] + dotX := l.innerWidth + l.innerX - charWidth(dotR) + p := newPointWithAttrs(dotR, dotX, y+l.innerY, bg, fg) + buffer = append(buffer, p) + break } - continue } - pi := Point{} - pi.X = l.innerX + j - pi.Y = l.innerY + i - - pi.Ch = rs[k] - pi.Bg = l.ItemBgColor - pi.Fg = l.ItemFgColor - - ps = append(ps, pi) - k++ - j++ } - case "hidden": - trimItems := l.Items - if len(trimItems) > l.innerHeight { - trimItems = trimItems[:l.innerHeight] - } - for i, v := range trimItems { - rs := trimStr2Runes(v, l.innerWidth) - - j := 0 - for _, vv := range rs { - w := charWidth(vv) - p := Point{} - p.X = l.innerX + j - p.Y = l.innerY + i - - p.Ch = vv - p.Bg = l.ItemBgColor - p.Fg = l.ItemFgColor - - ps = append(ps, p) - j += w - } + y++ + if breakLoop(y) { + break MainLoop } } - return l.Block.chopOverflow(ps) + + return l.Block.chopOverflow(buffer) } diff --git a/p.go b/p.go index e327d74..b6237fb 100644 --- a/p.go +++ b/p.go @@ -13,38 +13,43 @@ package termui */ type Par struct { Block - Text string - TextFgColor Attribute - TextBgColor Attribute + Text string + TextFgColor Attribute + TextBgColor Attribute + RendererFactory TextRendererFactory } // NewPar returns a new *Par with given text as its content. func NewPar(s string) *Par { return &Par{ - Block: *NewBlock(), - Text: s, - TextFgColor: theme.ParTextFg, - TextBgColor: theme.ParTextBg} + Block: *NewBlock(), + Text: s, + TextFgColor: theme.ParTextFg, + TextBgColor: theme.ParTextBg, + RendererFactory: PlainRendererFactory{}, + } } // Buffer implements Bufferer interface. func (p *Par) Buffer() []Point { ps := p.Block.Buffer() - rs := str2runes(p.Text) - i, j, k := 0, 0, 0 - for i < p.innerHeight && k < len(rs) { - // the width of char is about to print - w := charWidth(rs[k]) + fg, bg := p.TextFgColor, p.TextBgColor + sequence := p.RendererFactory.TextRenderer(p.Text).Render(fg, bg) + runes := []rune(sequence.NormalizedText) - if rs[k] == '\n' || j+w > p.innerWidth { - i++ - j = 0 // set x = 0 - if rs[k] == '\n' { - k++ + y, x, n := 0, 0, 0 + for y < p.innerHeight && n < len(runes) { + point, width := sequence.PointAt(n, x+p.innerX, y+p.innerY) + + if runes[n] == '\n' || x+width > p.innerWidth { + y++ + x = 0 // set x = 0 + if runes[n] == '\n' { + n++ } - if i >= p.innerHeight { + if y >= p.innerHeight { ps = append(ps, newPointWithAttrs('…', p.innerX+p.innerWidth-1, p.innerY+p.innerHeight-1, @@ -54,18 +59,11 @@ func (p *Par) Buffer() []Point { continue } - pi := Point{} - pi.X = p.innerX + j - pi.Y = p.innerY + i - pi.Ch = rs[k] - pi.Bg = p.TextBgColor - pi.Fg = p.TextFgColor - - ps = append(ps, pi) - - k++ - j += w + ps = append(ps, point) + n++ + x += width } + return p.Block.chopOverflow(ps) } diff --git a/textRender.go b/textRender.go new file mode 100644 index 0000000..3cf5154 --- /dev/null +++ b/textRender.go @@ -0,0 +1,454 @@ +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} +} diff --git a/textRender_test.go b/textRender_test.go new file mode 100644 index 0000000..7428655 --- /dev/null +++ b/textRender_test.go @@ -0,0 +1,454 @@ +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")) +}