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

The text widget now supports full-width runes.

Refactoring the draw() implementation to make it more readable:
- taking out the line trimming logic.
- taking out the drawing of the scroll up/down markers.
This commit is contained in:
Jakub Sobon 2018-05-20 22:51:38 +01:00
parent c2dab55b50
commit 929bf2b8fc
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
6 changed files with 566 additions and 66 deletions

View File

@ -19,6 +19,8 @@ package text
import (
"strings"
"text/scanner"
runewidth "github.com/mattn/go-runewidth"
)
// wrapNeeded returns true if wrapping is needed for the rune at the horizontal
@ -29,7 +31,8 @@ func wrapNeeded(r rune, cvsPosX, cvsWidth int, opts *options) bool {
// canvas, i.e. they take no horizontal space.
return false
}
return cvsPosX >= cvsWidth && opts.wrapAtRunes
rw := runewidth.RuneWidth(r)
return cvsPosX > cvsWidth-rw && opts.wrapAtRunes
}
// findLines finds the starting positions of all lines in the text when the
@ -103,9 +106,7 @@ func scanStart(ls *lineScanner) scannerState {
// 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 {
switch tok := ls.scanner.Scan(); {
case tok == scanner.EOF:
return nil
@ -117,7 +118,7 @@ func scanLine(ls *lineScanner) scannerState {
default:
// Move horizontally within the line for each scanned character.
ls.cvsPosX++
ls.cvsPosX += runewidth.RuneWidth(tok)
}
}
}
@ -136,7 +137,7 @@ func scanLineBreak(ls *lineScanner) scannerState {
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.cvsPosX = runewidth.StringWidth(ls.scanner.TokenText())
ls.lines = append(ls.lines, ls.scanner.Position.Offset)
return scanLine
}

View File

@ -31,7 +31,7 @@ func TestWrapNeeded(t *testing.T) {
want bool
}{
{
desc: "point within canvas",
desc: "half-width rune, falls within canvas",
r: 'a',
point: image.Point{2, 0},
width: 3,
@ -39,7 +39,15 @@ func TestWrapNeeded(t *testing.T) {
want: false,
},
{
desc: "point outside of canvas, wrapping not configured",
desc: "full-width rune, falls within canvas",
r: '世',
point: image.Point{1, 0},
width: 3,
opts: &options{},
want: false,
},
{
desc: "half-width rune, falls outside of canvas, wrapping not configured",
r: 'a',
point: image.Point{3, 0},
width: 3,
@ -47,7 +55,15 @@ func TestWrapNeeded(t *testing.T) {
want: false,
},
{
desc: "point outside of canvas, wrapping configured",
desc: "full-width rune, starts outside of canvas, wrapping not configured",
r: '世',
point: image.Point{3, 0},
width: 3,
opts: &options{},
want: false,
},
{
desc: "half-width rune, falls outside of canvas, wrapping configured",
r: 'a',
point: image.Point{3, 0},
width: 3,
@ -56,6 +72,26 @@ func TestWrapNeeded(t *testing.T) {
},
want: true,
},
{
desc: "full-width rune, starts in and falls outside of canvas, wrapping configured",
r: '世',
point: image.Point{2, 0},
width: 3,
opts: &options{
wrapAtRunes: true,
},
want: true,
},
{
desc: "full-width rune, starts outside of canvas, wrapping configured",
r: '世',
point: image.Point{3, 0},
width: 3,
opts: &options{
wrapAtRunes: true,
},
want: true,
},
{
desc: "doesn't wrap for newline characters",
r: '\n',
@ -174,15 +210,32 @@ func TestFindLines(t *testing.T) {
want: []int{0, 4, 8},
},
{
desc: "wrapping enabled, newlines, doesn't fit in width, wide unicode characters",
desc: "wrapping enabled, newlines, doesn't fit in width, full-width unicode characters",
text: "你好\n世界",
width: 1,
width: 2,
opts: &options{
wrapAtRunes: true,
},
want: []int{0, 3, 7, 10},
},
{
desc: "wraps before a full-width character that starts in and falls out",
text: "a你b",
width: 2,
opts: &options{
wrapAtRunes: true,
},
want: []int{0, 1, 4},
},
{
desc: "wraps before a full-width character that falls out",
text: "ab你b",
width: 2,
opts: &options{
wrapAtRunes: true,
},
want: []int{0, 2, 5},
},
{
desc: "handles leading and trailing newlines",
text: "\n\n\nhello\n\n\n",

102
widgets/text/line_trim.go Normal file
View File

@ -0,0 +1,102 @@
package text
import (
"fmt"
"image"
runewidth "github.com/mattn/go-runewidth"
"github.com/mum4k/termdash/canvas"
)
// line_trim.go contains code that trims lines that are too long.
type trimResult struct {
// trimmed is set to true if the current and the following runes on this
// line are trimmed.
trimmed bool
// curPoint is the updated current point the drawing should continue on.
curPoint image.Point
}
// drawTrimChar draws the horizontal ellipsis '…' character as the last
// character in the canvas on the specified line.
func drawTrimChar(cvs *canvas.Canvas, line int) error {
lastPoint := image.Point{cvs.Area().Dx() - 1, line}
// If the penultimate cell contains a full-width rune, we need to clear it
// first. Otherwise the trim char would cover just half of it.
if width := cvs.Area().Dx(); width > 1 {
penUlt := image.Point{width - 2, line}
prev, err := cvs.Cell(penUlt)
if err != nil {
return err
}
if runewidth.RuneWidth(prev.Rune) == 2 {
if _, err := cvs.SetCell(penUlt, 0); err != nil {
return err
}
}
}
cells, err := cvs.SetCell(lastPoint, '…')
if err != nil {
return err
}
if cells != 1 {
panic(fmt.Errorf("invalid trim character, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells))
}
return nil
}
// lineTrim determines if the current line needs to be trimmed. The cvs is the
// canvas assigned to the widget, the curPoint is the current point the widget
// is going to place the curRune at. If line trimming is needed, this function
// replaces the last character with the horizontal ellipsis '…' character.
func lineTrim(cvs *canvas.Canvas, curPoint image.Point, curRune rune, opts *options) (*trimResult, error) {
if opts.wrapAtRunes {
// Don't trim if the widget is configured to wrap lines.
return &trimResult{
trimmed: false,
curPoint: curPoint,
}, nil
}
// Newline characters are never trimmed, they start the next line.
if curRune == '\n' {
return &trimResult{
trimmed: false,
curPoint: curPoint,
}, nil
}
width := cvs.Area().Dx()
rw := runewidth.RuneWidth(curRune)
switch {
case rw == 1:
if curPoint.X == width {
if err := drawTrimChar(cvs, curPoint.Y); err != nil {
return nil, err
}
}
case rw == 2:
if curPoint.X == width || curPoint.X == width-1 {
if err := drawTrimChar(cvs, curPoint.Y); err != nil {
return nil, err
}
}
default:
return nil, fmt.Errorf("unable to decide line trimming at position %v for rune %q which has an unsupported width %d", curPoint, curRune, rw)
}
trimmed := curPoint.X > width-rw
if trimmed {
curPoint = image.Point{curPoint.X + rw, curPoint.Y}
}
return &trimResult{
trimmed: trimmed,
curPoint: curPoint,
}, nil
}

View File

@ -0,0 +1,268 @@
package text
import (
"image"
"testing"
"github.com/kylelemons/godebug/pretty"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/canvas/testcanvas"
"github.com/mum4k/termdash/draw/testdraw"
"github.com/mum4k/termdash/terminal/faketerm"
)
func TestLineTrim(t *testing.T) {
cvsArea := image.Rect(0, 0, 10, 1)
tests := []struct {
desc string
cvs *canvas.Canvas
curPoint image.Point
curRune rune
opts *options
wantRes *trimResult
want func(size image.Point) *faketerm.Terminal
wantErr bool
}{
{
desc: "half-width rune, beginning of the canvas",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{0, 0},
curRune: 'A',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: false,
curPoint: image.Point{0, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "half-width rune, end of the canvas, fits",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{9, 0},
curRune: 'A',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: false,
curPoint: image.Point{9, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "full-width rune, end of the canvas, fits",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{8, 0},
curRune: '世',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: false,
curPoint: image.Point{8, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "half-width rune, falls out of the canvas, not configured to trim",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{10, 0},
curRune: 'A',
opts: &options{
wrapAtRunes: true,
},
wantRes: &trimResult{
trimmed: false,
curPoint: image.Point{10, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "half-width rune, first that falls out of the canvas, trimmed and marked",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{10, 0},
curRune: 'A',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: true,
curPoint: image.Point{11, 0},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "…", image.Point{9, 0})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "full-width rune, starts in and falls out, trimmed and marked",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{9, 0},
curRune: '世',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: true,
curPoint: image.Point{11, 0},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "…", image.Point{9, 0})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "full-width rune, starts out, trimmed and marked",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{10, 0},
curRune: '世',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: true,
curPoint: image.Point{12, 0},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "…", image.Point{9, 0})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "newline rune, first that falls out of the canvas, not trimmed or marked",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{10, 0},
curRune: '\n',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: false,
curPoint: image.Point{10, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "half-width rune, n-th that falls out of the canvas, trimmed and not marked",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{11, 0},
curRune: 'A',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: true,
curPoint: image.Point{12, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "full-width rune, n-th that falls out of the canvas, trimmed and not marked",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{11, 0},
curRune: '世',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: true,
curPoint: image.Point{13, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "newline rune, n-th that falls out of the canvas, not trimmed or marked",
cvs: testcanvas.MustNew(cvsArea),
curPoint: image.Point{11, 0},
curRune: '\n',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: false,
curPoint: image.Point{11, 0},
},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
},
{
desc: "full-width rune, starts out, previous is also full, trimmed and marked",
cvs: func() *canvas.Canvas {
cvs := testcanvas.MustNew(cvsArea)
testcanvas.MustSetCell(cvs, image.Point{8, 0}, '世')
return cvs
}(),
curPoint: image.Point{10, 0},
curRune: '世',
opts: &options{
wrapAtRunes: false,
},
wantRes: &trimResult{
trimmed: true,
curPoint: image.Point{12, 0},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "…", image.Point{9, 0})
testcanvas.MustApply(c, ft)
return ft
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
gotRes, err := lineTrim(tc.cvs, tc.curPoint, tc.curRune, tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("lineTrim => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
if diff := pretty.Compare(tc.wantRes, gotRes); diff != "" {
t.Errorf("lineTrim => unexpected result, diff (-want, +got):\n%s", diff)
}
got, err := faketerm.New(tc.cvs.Size())
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
if err := tc.cvs.Apply(got); err != nil {
t.Fatalf("Apply => unexpected error: %v", err)
}
if diff := faketerm.Diff(tc.want(tc.cvs.Size()), got); diff != "" {
t.Errorf("lineTrim => %v", diff)
}
})
}
}

View File

@ -110,84 +110,105 @@ func (t *Text) Write(text string, wOpts ...WriteOption) error {
return nil
}
// lineTrimNeeded returns true if the text on this line needs to be trimmed.
// I.e. if the Text gadget was configured to trim lines, the current point falls
// outside of the canvas and the current rune doesn't start a new line.
func (t *Text) lineTrimNeeded(cur image.Point, cvs *canvas.Canvas, r rune) bool {
if cur.X < cvs.Area().Dx() || r == '\n' {
return false
}
return !t.opts.wrapAtRunes
}
// minLinesForMarkers are the minimum amount of lines required on the canvas in
// order to draw the scroll markers ('⇧' and '⇩').
const minLinesForMarkers = 3
// draw draws the text context on the canvas starting at the specified line.
// Argument starts are the starting positions of all the lines in the text.
func (t *Text) draw(text string, cvs *canvas.Canvas, starts []int, fromLine int) error {
var cur image.Point // Tracks the current drawing position on the canvas.
lines := len(starts)
// drawScrollUp draws the scroll up marker on the first line if there is more
// text "above" the canvas due to the scrolling position. Returns true if the
// marker was drawn.
func (t *Text) drawScrollUp(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) {
height := cvs.Area().Dy()
if cur.Y == 0 && height >= minLinesForMarkers && fromLine > 0 {
cells, err := cvs.SetCell(cur, '⇧')
if err != nil {
return false, err
}
if cells != 1 {
panic(fmt.Errorf("invalid scroll up marker, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells))
}
return true, nil
}
return false, nil
}
// drawScrollDown draws the scroll down marker on the last line if there is
// more text "below" the canvas due to the scrolling position. Returns true if
// the marker was drawn.
func (t *Text) drawScrollDown(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) {
height := cvs.Area().Dy()
lines := len(t.lines)
if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine {
cells, err := cvs.SetCell(cur, '⇩')
if err != nil {
return false, err
}
if cells != 1 {
panic(fmt.Errorf("invalid scroll down marker, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells))
}
return true, nil
}
return false, nil
}
// draw draws the text context on the canvas starting at the specified line.
func (t *Text) draw(text string, cvs *canvas.Canvas) error {
var cur image.Point // Tracks the current drawing position on the canvas.
height := cvs.Area().Dy()
fromLine := t.scroll.firstLine(len(t.lines), height)
optRange := t.givenWOpts.forPosition(0) // Text options for the current byte.
startPos := starts[fromLine]
startPos := t.lines[fromLine]
for i, r := range text {
if i < startPos {
continue
}
// Draw the scroll up marker on the first line if there is more text
// above the canvas.
if cur.Y == 0 && height >= minLinesForMarkers && fromLine > 0 {
if err := cvs.SetCell(cur, '⇧'); err != nil {
return err
}
// Scroll up marker.
scrlUp, err := t.drawScrollUp(cvs, cur, fromLine)
if err != nil {
return err
}
if scrlUp {
cur = image.Point{0, cur.Y + 1} // Move to the next line.
startPos = starts[fromLine+1] // Skip one line of text, the marker replaced it.
startPos = t.lines[fromLine+1] // Skip one line of text, the marker replaced it.
continue
}
// Line wrapping.
if r == '\n' || wrapNeeded(r, cur.X, cvs.Area().Dx(), t.opts) {
cur = image.Point{0, cur.Y + 1} // Move to the next line.
}
// Draw the scroll down marker on the last line if there is more text
// below the canvas.
if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine {
if height >= minLinesForMarkers {
if err := cvs.SetCell(cur, '⇩'); err != nil {
return err
}
}
break
// Scroll down marker.
scrlDown, err := t.drawScrollDown(cvs, cur, fromLine)
if err != nil {
return err
}
if scrlDown || cur.Y >= height {
break // Trim all lines falling after the canvas.
}
tr, err := lineTrim(cvs, cur, r, t.opts)
if err != nil {
return err
}
cur = tr.curPoint
if tr.trimmed {
continue // Skip over any characters trimmed on the current line.
}
if r == '\n' {
continue // Don't print the newline runes, just interpret them above.
}
if t.lineTrimNeeded(cur, cvs, r) {
// Trim by replacing the last printed rune.
prev := image.Point{cur.X - 1, cur.Y}
if prev.In(cvs.Area()) {
if err := cvs.SetCell(prev, '…'); err != nil {
return err
}
}
}
if !cur.In(cvs.Area()) {
continue // Skip any runes belonging to the trimmed area on a line.
}
if i >= optRange.high { // Get the next write options.
optRange = t.givenWOpts.forPosition(i)
}
if err := cvs.SetCell(cur, r, optRange.opts.cellOpts); err != nil {
cells, err := cvs.SetCell(cur, r, optRange.opts.cellOpts)
if err != nil {
return err
}
cur = image.Point{cur.X + 1, cur.Y} // Move within the same line.
cur = image.Point{cur.X + cells, cur.Y} // Move within the same line.
}
return nil
}
@ -211,9 +232,7 @@ func (t *Text) Draw(cvs *canvas.Canvas) error {
return nil // Nothing to draw if there's no text.
}
height := cvs.Area().Dy()
fromLine := t.scroll.firstLine(len(t.lines), height)
if err := t.draw(text, cvs, t.lines, fromLine); err != nil {
if err := t.draw(text, cvs); err != nil {
return err
}
t.newText = false
@ -254,6 +273,7 @@ func (t *Text) Mouse(m *terminalapi.Mouse) error {
func (t *Text) Options() widgetapi.Options {
return widgetapi.Options{
// At least one line with at least one full-width rune.
MinimumSize: image.Point{1, 1},
WantMouse: !t.opts.disableScrolling,
WantKeyboard: !t.opts.disableScrolling,

View File

@ -74,6 +74,21 @@ func TestTextDraws(t *testing.T) {
return ft
},
},
{
desc: "draws line of full-width runes",
canvas: image.Rect(0, 0, 10, 1),
writes: func(widget *Text) error {
return widget.Write("你好,世界")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "你好,世界", image.Point{0, 0})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "multiple writes append",
canvas: image.Rect(0, 0, 12, 1),
@ -167,9 +182,9 @@ func TestTextDraws(t *testing.T) {
},
{
desc: "trims long lines",
canvas: image.Rect(0, 0, 10, 3),
canvas: image.Rect(0, 0, 10, 4),
writes: func(widget *Text) error {
return widget.Write("hello world\nshort\nand long again")
return widget.Write("hello world\nshort\nexactly 10\nand long again")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@ -177,6 +192,24 @@ func TestTextDraws(t *testing.T) {
testdraw.MustText(c, "hello wor…", image.Point{0, 0})
testdraw.MustText(c, "short", image.Point{0, 1})
testdraw.MustText(c, "exactly 10", image.Point{0, 2})
testdraw.MustText(c, "and long …", image.Point{0, 3})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "trims long lines with full-width runes",
canvas: image.Rect(0, 0, 10, 3),
writes: func(widget *Text) error {
return widget.Write("hello wor你\nhello wor你d\nand long 世")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "hello wor…", image.Point{0, 0})
testdraw.MustText(c, "hello wor…", image.Point{0, 1})
testdraw.MustText(c, "and long …", image.Point{0, 2})
testcanvas.MustApply(c, ft)
return ft
@ -378,7 +411,7 @@ func TestTextDraws(t *testing.T) {
},
},
{
desc: "wraps lines at rune boundaries",
desc: "wraps lines at half-width rune boundaries",
canvas: image.Rect(0, 0, 10, 5),
opts: []Option{
WrapAtRunes(),
@ -399,6 +432,29 @@ func TestTextDraws(t *testing.T) {
return ft
},
},
{
desc: "wraps lines at full-width rune boundaries",
canvas: image.Rect(0, 0, 10, 6),
opts: []Option{
WrapAtRunes(),
},
writes: func(widget *Text) error {
return widget.Write("hello wor你\nhello wor你d\nand long 世")
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustText(c, "hello wor", image.Point{0, 0})
testdraw.MustText(c, "你", image.Point{0, 1})
testdraw.MustText(c, "hello wor", image.Point{0, 2})
testdraw.MustText(c, "你d", image.Point{0, 3})
testdraw.MustText(c, "and long ", image.Point{0, 4})
testdraw.MustText(c, "世", image.Point{0, 5})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "rolls content upwards and trims lines",
canvas: image.Rect(0, 0, 10, 2),