1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Text validation moved to the wrap package.

This commit is contained in:
Jakub Sobon 2019-03-02 17:46:03 -05:00
parent f102632bd4
commit c43e453038
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
3 changed files with 239 additions and 85 deletions

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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
}