From c43e4530380b3a6a191ddd76224de85d540d8244 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Sat, 2 Mar 2019 17:46:03 -0500 Subject: [PATCH] Text validation moved to the wrap package. --- internal/wrap/wrap.go | 68 ++++++++--- internal/wrap/wrap_test.go | 226 +++++++++++++++++++++++++++++-------- widgets/text/text.go | 30 +---- 3 files changed, 239 insertions(+), 85 deletions(-) diff --git a/internal/wrap/wrap.go b/internal/wrap/wrap.go index 1139077..0fe3a1c 100644 --- a/internal/wrap/wrap.go +++ b/internal/wrap/wrap.go @@ -17,6 +17,8 @@ package wrap import ( "bytes" + "errors" + "fmt" "unicode" "github.com/mum4k/termdash/internal/canvas/buffer" @@ -56,11 +58,36 @@ const ( AtWords ) -// runeWrapNeeded returns true if wrapping is needed for the rune at the horizontal -// position on the canvas that has the specified width. -func runeWrapNeeded(r rune, posX, width int) bool { - rw := runewidth.RuneWidth(r) - return posX > width-rw +// ValidText validates the provided text for wrapping. +// The text must not contain any control or space characters other +// than '\n' and ' '. +func ValidText(text string) error { + if text == "" { + return errors.New("the text cannot be empty") + } + + for _, c := range text { + if c == ' ' || c == '\n' { // Allowed space and control runes. + continue + } + if unicode.IsControl(c) { + return fmt.Errorf("the provided text %q cannot contain control characters, found: %q", text, c) + } + if unicode.IsSpace(c) { + return fmt.Errorf("the provided text %q cannot contain space character %q", text, c) + } + } + return nil +} + +// ValidCells validates the provided cells for wrapping. +// The text in the cells must follow the same rules as described for ValidText. +func ValidCells(cells []*buffer.Cell) error { + var b bytes.Buffer + for _, c := range cells { + b.WriteRune(c.Rune) + } + return ValidText(b.String()) } // Cells returns the cells wrapped into individual lines according to the @@ -71,15 +98,25 @@ func runeWrapNeeded(r rune, posX, width int) bool { // // If the mode is AtWords, this function also drops cells with leading space // character before a word at which the wrap occurs. -func Cells(cells []*buffer.Cell, width int, m Mode) [][]*buffer.Cell { - if width <= 0 || len(cells) == 0 { - return nil +func Cells(cells []*buffer.Cell, width int, m Mode) ([][]*buffer.Cell, error) { + if err := ValidCells(cells); err != nil { + return nil, err + } + switch m { + case Never: + case AtRunes: + case AtWords: + default: + return nil, fmt.Errorf("unsupported wrapping mode %v(%d)", m, m) + } + if width <= 0 { + return nil, nil } cs := newCellScanner(cells, width, m) for state := scanCellRunes; state != nil; state = state(cs) { } - return cs.lines + return cs.lines, nil } // cellScannerState is a state in the FSM that scans the input text and identifies @@ -116,6 +153,7 @@ type cellScanner struct { // mode is the wrapping mode. mode Mode + // atRunesInWord overrides the mode back to AtRunes. atRunesInWord bool // lines are the identified lines. @@ -189,14 +227,9 @@ func (cs *cellScanner) isWordStart() bool { return false } - if cs.posX > 0 && current.Rune != ' ' { - return false - } - switch nr := next.Rune; { case nr == '\n': case nr == ' ': - case unicode.IsPunct(nr): default: return true } @@ -367,3 +400,10 @@ func isWordCell(c *buffer.Cell) bool { } return false } + +// runeWrapNeeded returns true if wrapping is needed for the rune at the horizontal +// position on the canvas that has the specified width. +func runeWrapNeeded(r rune, posX, width int) bool { + rw := runewidth.RuneWidth(r) + return posX > width-rw +} diff --git a/internal/wrap/wrap_test.go b/internal/wrap/wrap_test.go index b067385..4ac7d69 100644 --- a/internal/wrap/wrap_test.go +++ b/internal/wrap/wrap_test.go @@ -15,71 +15,111 @@ package wrap import ( + "bytes" "fmt" "testing" + "unicode" "github.com/kylelemons/godebug/pretty" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/internal/canvas/buffer" ) -func TestruneWrapNeeded(t *testing.T) { +func TestValidTextAndCells(t *testing.T) { tests := []struct { - desc string - r rune - posX int - width int - want bool + desc string + text string // All runes checked individually. + wantErr bool }{ { - desc: "half-width rune, falls within canvas", - r: 'a', - posX: 2, - width: 3, - want: false, + desc: "empty text is not valid", + wantErr: true, }, { - desc: "full-width rune, falls within canvas", - r: '世', - posX: 1, - width: 3, - want: false, + desc: "digits are allowed", + text: "0123456789", }, { - desc: "half-width rune, falls outside of canvas, wrapping configured", - r: 'a', - posX: 3, - width: 3, - want: true, + desc: "all printable ASCII characters are allowed", + text: func() string { + var b bytes.Buffer + for i := 0; i < unicode.MaxASCII; i++ { + r := rune(i) + if unicode.IsPrint(r) { + b.WriteRune(r) + } + } + return b.String() + }(), }, { - desc: "full-width rune, starts in and falls outside of canvas, wrapping configured", - r: '世', - posX: 3, - width: 3, - want: true, + desc: "all printable Unicode characters in the Latin-1 space are allowed", + text: func() string { + var b bytes.Buffer + for i := 0; i < unicode.MaxLatin1; i++ { + r := rune(i) + if unicode.IsPrint(r) { + b.WriteRune(r) + } + } + return b.String() + }(), }, { - desc: "full-width rune, starts outside of canvas, wrapping configured", - r: '世', - posX: 3, - width: 3, - want: true, + desc: "sample of half-width unicode runes that are allowed", + text: "セカイ☆", }, { - desc: "doesn't wrap for newline characters", - r: '\n', - posX: 3, - width: 3, - want: false, + desc: "sample of full-width unicode runes that are allowed", + text: "世界", + }, + { + desc: "spaces are allowed", + text: " ", + }, + { + desc: "no other space characters", + text: fmt.Sprintf("\t\v\f\r%c%c", 0x85, 0xA0), + wantErr: true, + }, + { + desc: "no control characters", + text: fmt.Sprintf("%c%c", 0x0000, 0x007f), + wantErr: true, + }, + + { + desc: "newlines are allowed", + text: " ", }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - got := runeWrapNeeded(tc.r, tc.posX, tc.width) - if got != tc.want { - t.Errorf("runeWrapNeeded => got %v, want %v", got, tc.want) + { + err := ValidText(tc.text) + if (err != nil) != tc.wantErr { + t.Errorf("ValidText => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + } + + // All individual runes must give the same result. + for _, r := range tc.text { + err := ValidText(string(r)) + if (err != nil) != tc.wantErr { + t.Errorf("ValidText => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + } + + var cells []*buffer.Cell + for _, r := range tc.text { + cells = append(cells, buffer.NewCell(r)) + } + { + err := ValidCells(cells) + if (err != nil) != tc.wantErr { + t.Errorf("ValidCells => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } } }) } @@ -90,13 +130,28 @@ func TestCells(t *testing.T) { desc string cells []*buffer.Cell // width is the width of the canvas. - width int - mode Mode - want [][]*buffer.Cell + width int + mode Mode + want [][]*buffer.Cell + wantErr bool }{ { - desc: "zero text", - width: 1, + desc: "fails with zero text", + width: 1, + wantErr: true, + }, + { + desc: "fails with invalid runes (tabs)", + cells: buffer.NewCells("hello\t"), + width: 1, + wantErr: true, + }, + { + desc: "fails with unsupported wrap mode", + cells: buffer.NewCells("hello"), + width: 1, + mode: Mode(-1), + wantErr: true, }, { desc: "zero canvas width", @@ -420,6 +475,17 @@ func TestCells(t *testing.T) { buffer.NewCells("cc?"), }, }, + { + desc: "wraps at words, quotes are wrapped with words", + cells: buffer.NewCells("'aa' 'bb' 'cc'"), + width: 4, + mode: AtWords, + want: [][]*buffer.Cell{ + buffer.NewCells("'aa'"), + buffer.NewCells("'bb'"), + buffer.NewCells("'cc'"), + }, + }, { desc: "wraps at words, begins with a word too long for one line", cells: buffer.NewCells("aabbcc"), @@ -594,14 +660,18 @@ func TestCells(t *testing.T) { buffer.NewCells("bc", cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)), }, }, - // Move text validation into this package. - // unsupported wrap mode } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { t.Logf(fmt.Sprintf("Mode: %v", tc.mode)) - got := Cells(tc.cells, tc.width, tc.mode) + got, err := Cells(tc.cells, tc.width, tc.mode) + if (err != nil) != tc.wantErr { + t.Errorf("Cells => unexpected error %v, wantErr %v", err, tc.wantErr) + } + if err != nil { + return + } if diff := pretty.Compare(tc.want, got); diff != "" { t.Errorf("Cells =>\n got:%v\nwant:%v\nunexpected diff (-want, +got):\n%s", got, tc.want, diff) } @@ -609,3 +679,65 @@ func TestCells(t *testing.T) { } } + +func TestRuneWrapNeeded(t *testing.T) { + tests := []struct { + desc string + r rune + posX int + width int + want bool + }{ + { + desc: "half-width rune, falls within canvas", + r: 'a', + posX: 2, + width: 3, + want: false, + }, + { + desc: "full-width rune, falls within canvas", + r: '世', + posX: 1, + width: 3, + want: false, + }, + { + desc: "half-width rune, falls outside of canvas, wrapping configured", + r: 'a', + posX: 3, + width: 3, + want: true, + }, + { + desc: "full-width rune, starts in and falls outside of canvas, wrapping configured", + r: '世', + posX: 3, + width: 3, + want: true, + }, + { + desc: "full-width rune, starts outside of canvas, wrapping configured", + r: '世', + posX: 3, + width: 3, + want: true, + }, + { + desc: "doesn't wrap for newline characters", + r: '\n', + posX: 3, + width: 3, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := runeWrapNeeded(tc.r, tc.posX, tc.width) + if got != tc.want { + t.Errorf("runeWrapNeeded => got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/widgets/text/text.go b/widgets/text/text.go index 0cedc12..8468f91 100644 --- a/widgets/text/text.go +++ b/widgets/text/text.go @@ -16,11 +16,9 @@ package text import ( - "errors" "fmt" "image" "sync" - "unicode" "github.com/mum4k/termdash/internal/canvas" "github.com/mum4k/termdash/internal/canvas/buffer" @@ -102,7 +100,7 @@ func (t *Text) Write(text string, wOpts ...WriteOption) error { t.mu.Lock() defer t.mu.Unlock() - if err := validText(text); err != nil { + if err := wrap.ValidText(text); err != nil { return err } @@ -216,7 +214,11 @@ func (t *Text) Draw(cvs *canvas.Canvas) error { if t.contentChanged || t.lastWidth != width { // The previous text preprocessing (line wrapping) is invalidated when // new text is added or the width of the canvas changed. - t.wrapped = wrap.Cells(t.content, width, t.opts.wrapMode) + wr, err := wrap.Cells(t.content, width, t.opts.wrapMode) + if err != nil { + return err + } + t.wrapped = wr } t.lastWidth = width @@ -282,23 +284,3 @@ func (t *Text) Options() widgetapi.Options { WantKeyboard: ks, } } - -// validText validates the provided text. -func validText(text string) error { - if text == "" { - return errors.New("the text cannot be empty") - } - - for _, c := range text { - if c == ' ' || c == '\n' { // Allowed space and control runes. - continue - } - if unicode.IsControl(c) { - return fmt.Errorf("the provided text %q cannot contain control characters, found: %q", text, c) - } - if unicode.IsSpace(c) { - return fmt.Errorf("the provided text %q cannot contain space character %q", text, c) - } - } - return nil -}