diff --git a/example/coloredList.go b/example/coloredList.go index 6fc6aa2..c356bee 100644 --- a/example/coloredList.go +++ b/example/coloredList.go @@ -5,7 +5,7 @@ package main import "github.com/gizak/termui" import "github.com/nsf/termbox-go" -func commonList() *termui.List { +func markdownList() *termui.List { strs := []string{ "[0] github.com/gizak/termui", "[1] 笀耔 [澉 灊灅甗](RED) 郔镺 笀耔 澉 [灊灅甗](yellow) 郔镺", @@ -26,18 +26,40 @@ func commonList() *termui.List { return list } -func listHidden() *termui.List { - list := commonList() +func hideList(list *termui.List) *termui.List { list.Border.Label = "List - Hidden" list.Overflow = "hidden" return list } -func listWrap() *termui.List { - list := commonList() +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 } @@ -49,11 +71,20 @@ func main() { } defer termui.Close() - hiddenList := listHidden() - wrappedList := listWrap() - wrappedList.X = 30 + hiddenMarkdownList := hideList(markdownList()) + wrappedMarkdownList := wrapList(markdownList()) + + hiddenEscapeList := hideList(escapeList()) + wrappedEscapeList := wrapList(escapeList()) + + lists := []termui.Bufferer{ + hiddenEscapeList, + hiddenMarkdownList, + wrappedMarkdownList, + wrappedEscapeList, + } termui.UseTheme("helloworld") - termui.Render(hiddenList, wrappedList) + termui.Render(lists...) termbox.PollEvent() } diff --git a/textRender.go b/textRender.go index dd6c96f..3cf5154 100644 --- a/textRender.go +++ b/textRender.go @@ -1,7 +1,9 @@ package termui import ( + "fmt" "regexp" + "strconv" "strings" ) @@ -243,3 +245,210 @@ type PlainRendererFactory struct{} 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 index e93f4bd..7428655 100644 --- a/textRender_test.go +++ b/textRender_test.go @@ -13,6 +13,7 @@ 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)) } @@ -20,6 +21,7 @@ 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)) } @@ -270,3 +272,183 @@ func TestPosUnicode(t *testing.T) { 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")) +}