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:
parent
f102632bd4
commit
c43e453038
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user