-
Notifications
You must be signed in to change notification settings - Fork 135
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
347 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
package text | ||
|
||
// line_scanner.go contains code that finds lines within text. | ||
|
||
import ( | ||
"strings" | ||
"text/scanner" | ||
) | ||
|
||
// wrapNeeded returns true if wrapping is needed for the rune at the horizontal | ||
// position on the canvas. | ||
func wrapNeeded(r rune, cvsPosX, cvsWidth int, opts *options) bool { | ||
if r == '\n' { | ||
// Don't wrap for newline characters as they aren't printed on the | ||
// canvas, i.e. they take no horizontal space. | ||
return false | ||
} | ||
return cvsPosX >= cvsWidth && opts.wrapAtRunes | ||
} | ||
|
||
// findLines finds the starting positions of all lines in the text when the | ||
// text is drawn on a canvas of the provided width with the specified options. | ||
func findLines(text string, cvsWidth int, opts *options) []int { | ||
if cvsWidth <= 0 || text == "" { | ||
return nil | ||
} | ||
|
||
ls := newLineScanner(text, cvsWidth, opts) | ||
for state := scanStart; state != nil; state = state(ls) { | ||
} | ||
return ls.lines | ||
} | ||
|
||
// lineScanner tracks the progress of scanning the input text when finding | ||
// lines. Lines are identified when newline characters are encountered in the | ||
// input text or when the canvas width and configuration requires line | ||
// wrapping. | ||
type lineScanner struct { | ||
// scanner lexes the input text. | ||
scanner *scanner.Scanner | ||
|
||
// cvsWidth is the width of the canvas the text will be drawn on. | ||
cvsWidth int | ||
|
||
// cvsPosX tracks the horizontal position of the current character on the | ||
// canvas. | ||
cvsPosX int | ||
|
||
// opts are the widget options. | ||
opts *options | ||
|
||
// lines are the starting points of the identified lines. | ||
lines []int | ||
} | ||
|
||
// newLineScanner returns a new line scanner of the provided text. | ||
func newLineScanner(text string, cvsWidth int, opts *options) *lineScanner { | ||
var s scanner.Scanner | ||
s.Init(strings.NewReader(text)) | ||
s.Whitespace = 0 // Don't ignore any whitespace. | ||
s.Mode = scanner.ScanIdents | ||
s.IsIdentRune = func(ch rune, i int) bool { | ||
return i == 0 && ch == '\n' | ||
} | ||
|
||
return &lineScanner{ | ||
scanner: &s, | ||
cvsWidth: cvsWidth, | ||
opts: opts, | ||
} | ||
} | ||
|
||
// scannerState is a state in the FSM that scans the input text and identifies | ||
// newlines. | ||
type scannerState func(*lineScanner) scannerState | ||
|
||
// scanStart records the starting location of the current line. | ||
func scanStart(ls *lineScanner) scannerState { | ||
switch tok := ls.scanner.Peek(); { | ||
case tok == scanner.EOF: | ||
return nil | ||
|
||
default: | ||
ls.lines = append(ls.lines, ls.scanner.Position.Offset) | ||
return scanLine | ||
} | ||
} | ||
|
||
// scanLine scans a line until it finds its end. | ||
func scanLine(ls *lineScanner) scannerState { | ||
for { | ||
tok := ls.scanner.Scan() | ||
//switch tok := ls.scanner.Scan(); { | ||
switch { | ||
case tok == scanner.EOF: | ||
return nil | ||
|
||
case tok == scanner.Ident: | ||
return scanLineBreak | ||
|
||
case wrapNeeded(tok, ls.cvsPosX, ls.cvsWidth, ls.opts): | ||
return scanLineWrap | ||
|
||
default: | ||
// Move horizontally within the line for each scanned character. | ||
ls.cvsPosX++ | ||
} | ||
} | ||
} | ||
|
||
// scanLineBreak processes a newline character in the input text. | ||
func scanLineBreak(ls *lineScanner) scannerState { | ||
// Newline characters aren't printed, the following character starts the line. | ||
if ls.scanner.Peek() != scanner.EOF { | ||
ls.cvsPosX = 0 | ||
ls.lines = append(ls.lines, ls.scanner.Position.Offset+1) | ||
} | ||
return scanLine | ||
} | ||
|
||
// scanLineWrap processes a line wrap due to canvas width. | ||
func scanLineWrap(ls *lineScanner) scannerState { | ||
// The character on which we wrapped will be printed and is the start of | ||
// new line. | ||
ls.cvsPosX = 1 | ||
ls.lines = append(ls.lines, ls.scanner.Position.Offset) | ||
return scanLine | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,219 @@ | ||
package text | ||
|
||
import ( | ||
"image" | ||
"testing" | ||
|
||
"github.com/kylelemons/godebug/pretty" | ||
) | ||
|
||
func TestWrapNeeded(t *testing.T) { | ||
tests := []struct { | ||
desc string | ||
r rune | ||
point image.Point | ||
width int | ||
opts *options | ||
want bool | ||
}{ | ||
{ | ||
desc: "point within canvas", | ||
r: 'a', | ||
point: image.Point{2, 0}, | ||
width: 3, | ||
opts: &options{}, | ||
want: false, | ||
}, | ||
{ | ||
desc: "point outside of canvas, wrapping not configured", | ||
r: 'a', | ||
point: image.Point{3, 0}, | ||
width: 3, | ||
opts: &options{}, | ||
want: false, | ||
}, | ||
{ | ||
desc: "point outside of canvas, wrapping configured", | ||
r: 'a', | ||
point: image.Point{3, 0}, | ||
width: 3, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: true, | ||
}, | ||
{ | ||
desc: "doesn't wrap for newline characters", | ||
r: '\n', | ||
point: image.Point{3, 0}, | ||
width: 3, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: false, | ||
}, | ||
} | ||
|
||
for _, tc := range tests { | ||
t.Run(tc.desc, func(t *testing.T) { | ||
got := wrapNeeded(tc.r, tc.point.X, tc.width, tc.opts) | ||
if got != tc.want { | ||
t.Errorf("wrapNeeded => got %v, want %v", got, tc.want) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestFindLines(t *testing.T) { | ||
tests := []struct { | ||
desc string | ||
text string | ||
width int | ||
opts *options | ||
want []int | ||
}{ | ||
{ | ||
desc: "zero text", | ||
text: "", | ||
width: 1, | ||
opts: &options{}, | ||
want: nil, | ||
}, | ||
{ | ||
desc: "zero width", | ||
text: "hello", | ||
width: 0, | ||
opts: &options{}, | ||
want: nil, | ||
}, | ||
{ | ||
desc: "wrapping disabled, no newlines, fits in width", | ||
text: "hello", | ||
width: 5, | ||
opts: &options{}, | ||
want: []int{0}, | ||
}, | ||
{ | ||
desc: "wrapping disabled, no newlines, doesn't fits in width", | ||
text: "hello", | ||
width: 4, | ||
opts: &options{}, | ||
want: []int{0}, | ||
}, | ||
{ | ||
desc: "wrapping disabled, newlines, fits in width", | ||
text: "hello\nworld", | ||
width: 5, | ||
opts: &options{}, | ||
want: []int{0, 6}, | ||
}, | ||
{ | ||
desc: "wrapping disabled, newlines, doesn't fit in width", | ||
text: "hello\nworld", | ||
width: 4, | ||
opts: &options{}, | ||
want: []int{0, 6}, | ||
}, | ||
{ | ||
desc: "wrapping enabled, no newlines, fits in width", | ||
text: "hello", | ||
width: 5, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0}, | ||
}, | ||
{ | ||
desc: "wrapping enabled, no newlines, doesn't fit in width", | ||
text: "hello", | ||
width: 4, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 4}, | ||
}, | ||
{ | ||
desc: "wrapping enabled, newlines, fits in width", | ||
text: "hello\nworld", | ||
width: 5, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 6}, | ||
}, | ||
{ | ||
desc: "wrapping enabled, newlines, doesn't fit in width", | ||
text: "hello\nworld", | ||
width: 4, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 4, 6, 10}, | ||
}, | ||
{ | ||
desc: "wrapping enabled, newlines, doesn't fit in width, unicode characters", | ||
text: "⇧\n…\n⇩", | ||
width: 1, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 4, 8}, | ||
}, | ||
{ | ||
desc: "wrapping enabled, newlines, doesn't fit in width, wide unicode characters", | ||
text: "你好\n世界", | ||
width: 1, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 3, 7, 10}, | ||
}, | ||
|
||
{ | ||
desc: "handles leading and trailing newlines", | ||
text: "\n\n\nhello\n\n\n", | ||
width: 4, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 1, 2, 3, 7, 9, 10}, | ||
}, | ||
{ | ||
desc: "handles multiple newlines in the middle", | ||
text: "hello\n\n\nworld", | ||
width: 5, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 6, 7, 8}, | ||
}, | ||
{ | ||
desc: "handles multiple newlines in the middle and wraps", | ||
text: "hello\n\n\nworld", | ||
width: 2, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 2, 4, 6, 7, 8, 10, 12}, | ||
}, | ||
{ | ||
desc: "contains only newlines", | ||
text: "\n\n\n", | ||
width: 4, | ||
opts: &options{ | ||
wrapAtRunes: true, | ||
}, | ||
want: []int{0, 1, 2}, | ||
}, | ||
} | ||
|
||
for _, tc := range tests { | ||
t.Run(tc.desc, func(t *testing.T) { | ||
got := findLines(tc.text, tc.width, tc.opts) | ||
if diff := pretty.Compare(tc.want, got); diff != "" { | ||
t.Errorf("findLines => unexpected diff (-want, +got):\n%s", diff) | ||
} | ||
}) | ||
} | ||
|
||
} |