From be6863e1fd33e2421430e366a6ac316aba7ca6cd Mon Sep 17 00:00:00 2001 From: Marcus Olsson Date: Thu, 16 Nov 2017 11:50:13 +0100 Subject: [PATCH] Implement whitespace preserving wordwrap --- entry.go | 13 ++-- entry_test.go | 8 +- runebuf.go | 51 +++++++----- runebuf_test.go | 160 +++++++++++++++++++++++++------------- text_edit.go | 15 ++-- wordwrap/wordwrap.go | 54 +++++++++++++ wordwrap/wordwrap_test.go | 34 ++++++++ 7 files changed, 246 insertions(+), 89 deletions(-) create mode 100644 wordwrap/wordwrap.go create mode 100644 wordwrap/wordwrap_test.go diff --git a/entry.go b/entry.go index c2b3506..d0f3da1 100644 --- a/entry.go +++ b/entry.go @@ -32,6 +32,7 @@ func (e *Entry) Draw(p *Painter) { } p.WithStyle(style, func(p *Painter) { s := e.Size() + e.text.SetMaxWidth(s.X) text := e.visibleText() @@ -39,7 +40,7 @@ func (e *Entry) Draw(p *Painter) { p.DrawText(0, 0, text) if e.IsFocused() { - pos := e.text.CursorPos(s.X) + pos := e.text.CursorPos() p.DrawCursor(pos.X-e.offset, 0) } }) @@ -56,6 +57,9 @@ func (e *Entry) OnKeyEvent(ev KeyEvent) { return } + screenWidth := e.Size().X + e.text.SetMaxWidth(screenWidth) + if ev.Key != KeyRune { switch ev.Key { case KeyEnter: @@ -83,8 +87,7 @@ func (e *Entry) OnKeyEvent(ev KeyEvent) { case KeyRight, KeyCtrlF: e.text.MoveForward() - screenWidth := e.Size().X - isCursorTooFar := e.text.CursorPos(screenWidth).X >= screenWidth + isCursorTooFar := e.text.CursorPos().X >= screenWidth isTextLeft := (e.text.Width() - e.offset) > (screenWidth - 1) if isCursorTooFar && isTextLeft { @@ -95,7 +98,7 @@ func (e *Entry) OnKeyEvent(ev KeyEvent) { e.offset = 0 case KeyEnd, KeyCtrlE: e.text.MoveToLineEnd() - left := e.text.Width() - (e.Size().X - 1) + left := e.text.Width() - (screenWidth - 1) if left >= 0 { e.offset = left } @@ -106,7 +109,7 @@ func (e *Entry) OnKeyEvent(ev KeyEvent) { } e.text.WriteRune(ev.Rune) - if e.text.CursorPos(e.Size().X).X >= e.Size().X { + if e.text.CursorPos().X >= screenWidth { e.offset++ } if e.onTextChange != nil { diff --git a/entry_test.go b/entry_test.go index a2ba39a..e702e7c 100644 --- a/entry_test.go +++ b/entry_test.go @@ -386,6 +386,7 @@ func TestEntry_MoveToStartAndEnd(t *testing.T) { e := NewEntry() e.SetText("Lorem ipsum") e.SetFocused(true) + e.text.SetMaxWidth(5) e.offset = 6 surface := newTestSurface(5, 1) @@ -397,7 +398,7 @@ func TestEntry_MoveToStartAndEnd(t *testing.T) { want := "\nLorem\n" - if got := e.text.CursorPos(5); got.X != 0 { + if got := e.text.CursorPos(); got.X != 0 { t.Errorf("cursor position should be %d, but was %d", 0, got.X) } if e.offset != 0 { @@ -413,7 +414,7 @@ func TestEntry_MoveToStartAndEnd(t *testing.T) { want := "\npsum \n" - if got := e.text.CursorPos(5); got.X != 11 { + if got := e.text.CursorPos(); got.X != 11 { t.Errorf("cursor position should be %d, but was %d", 11, got.X) } if e.offset != 7 { @@ -430,6 +431,7 @@ func TestEntry_OnKeyBackspaceEvent(t *testing.T) { e := NewEntry() e.SetText("Lorem ipsum") e.SetFocused(true) + e.text.SetMaxWidth(5) e.offset = 6 surface := newTestSurface(5, 1) @@ -441,7 +443,7 @@ func TestEntry_OnKeyBackspaceEvent(t *testing.T) { want := "\nm ips\n" - if got := e.text.CursorPos(5); got.X != 9 { + if got := e.text.CursorPos(); got.X != 9 { t.Errorf("cursor position should be %d, but was %d", 9, got.X) } if e.offset != 4 { diff --git a/runebuf.go b/runebuf.go index 953a56e..f33289e 100644 --- a/runebuf.go +++ b/runebuf.go @@ -4,8 +4,8 @@ import ( "image" "strings" + "github.com/marcusolsson/tui-go/wordwrap" runewidth "github.com/mattn/go-runewidth" - wordwrap "github.com/mitchellh/go-wordwrap" ) // RuneBuffer provides readline functionality for text widgets. @@ -14,6 +14,13 @@ type RuneBuffer struct { idx int wordwrap bool + + width int +} + +// SetMaxWidth sets the maximum text width. +func (r *RuneBuffer) SetMaxWidth(w int) { + r.width = w } // Width returns the width of the rune buffer, taking into account for CJK. @@ -55,35 +62,39 @@ func (r *RuneBuffer) Len() int { } // SplitByLine returns the lines for a given width. -func (r *RuneBuffer) SplitByLine(width int) []string { - var text string - if r.wordwrap { - text = wordwrap.WrapString(r.String(), uint(width)) - } else { - text = r.String() - } - return strings.Split(text, "\n") +func (r *RuneBuffer) SplitByLine() []string { + return r.getSplitByLine(r.width) } -func getSplitByLine(rs []rune, width int, wrap bool) []string { +func (r *RuneBuffer) getSplitByLine(w int) []string { var text string - if wrap { - text = wordwrap.WrapString(string(rs), uint(width)) + if r.wordwrap { + text = wordwrap.WrapString(r.String(), w) } else { - text = string(rs) + text = r.String() } return strings.Split(text, "\n") } // CursorPos returns the coordinate for the cursor for a given width. -func (r *RuneBuffer) CursorPos(width int) image.Point { - if width == 0 { +func (r *RuneBuffer) CursorPos() image.Point { + if r.width == 0 { return image.ZP } - sp := getSplitByLine(r.buf[:r.idx], width, r.wordwrap) - - return image.Pt(stringWidth(sp[len(sp)-1]), len(sp)-1) + sp := r.SplitByLine() + var x, y int + remaining := r.idx + for _, l := range sp { + if len(l) < remaining { + y++ + remaining -= len(l) + 1 + } else { + x = remaining + break + } + } + return image.Pt(x, y) } func (r *RuneBuffer) String() string { @@ -120,7 +131,7 @@ func (r *RuneBuffer) MoveToLineStart() { // MoveToLineEnd moves the cursor to the end of the current line. func (r *RuneBuffer) MoveToLineEnd() { for i := r.idx; i < len(r.buf)-1; i++ { - if r.buf[i+1] == '\n' { + if r.buf[i] == '\n' { r.idx = i return } @@ -156,5 +167,5 @@ func (r *RuneBuffer) Kill() { } func (r *RuneBuffer) heightForWidth(w int) int { - return len(r.SplitByLine(w)) + return len(r.getSplitByLine(w)) } diff --git a/runebuf_test.go b/runebuf_test.go index bdd6ccd..6a78052 100644 --- a/runebuf_test.go +++ b/runebuf_test.go @@ -6,43 +6,104 @@ import ( "testing" ) -func TestRuneBuffer_MoveToLineStart(t *testing.T) { +func TestRuneBuffer_MoveForward(t *testing.T) { for _, tt := range []struct { - curr RuneBuffer - want RuneBuffer + text string + in, out int }{ - {RuneBuffer{idx: 3, buf: []rune("foo")}, RuneBuffer{idx: 0, buf: []rune("foo")}}, - {RuneBuffer{idx: 0, buf: []rune("foo")}, RuneBuffer{idx: 0, buf: []rune("foo")}}, + {"foo", 0, 1}, + {"foo", 2, 3}, + {"foo", 3, 3}, + {"Lorem ipsum dolor \nsit amet.", 17, 18}, } { t.Run("", func(t *testing.T) { - tt.curr.MoveToLineStart() + var buf RuneBuffer + buf.SetWithIdx(tt.in, []rune(tt.text)) - if tt.want.idx != tt.curr.idx { - t.Fatalf("want = %v; got = %v", tt.want.idx, tt.curr.idx) + buf.MoveForward() + + if tt.out != buf.idx { + t.Fatalf("want = %v; got = %v", tt.out, buf.idx) } - if !reflect.DeepEqual(tt.want.buf, tt.curr.buf) { - t.Fatalf("want = %v; got = %v", tt.want.buf, tt.curr.buf) + }) + } +} + +func TestRuneBuffer_MoveBackward(t *testing.T) { + for _, tt := range []struct { + text string + in, out int + }{ + {"foo", 0, 0}, + {"foo", 2, 1}, + {"foo", 3, 2}, + {"Lorem ipsum dolor \nsit amet.", 18, 17}, + } { + t.Run("", func(t *testing.T) { + var buf RuneBuffer + buf.SetWithIdx(tt.in, []rune(tt.text)) + + buf.MoveBackward() + + if tt.out != buf.idx { + t.Fatalf("want = %v; got = %v", tt.out, buf.idx) } }) } } -func TestRuneBuffer_MoveToLineEnd(t *testing.T) { +func TestRuneBuffer_MoveToLineStart(t *testing.T) { for _, tt := range []struct { - curr RuneBuffer - want RuneBuffer + text string + in, out int }{ - {RuneBuffer{idx: 3, buf: []rune("foo")}, RuneBuffer{idx: 3, buf: []rune("foo")}}, - {RuneBuffer{idx: 0, buf: []rune("foo")}, RuneBuffer{idx: 3, buf: []rune("foo")}}, + {"foo", 3, 0}, + {"foo", 0, 0}, + {"Lorem ipsum dolor \nsit amet.", 21, 19}, + {"Lorem ipsum dolor \n\nsit amet.", 21, 20}, + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 40, 33}, + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 90, 79}, + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 79, 79}, + // On a empty line. + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 78, 78}, + // On newline character. + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 77, 33}, } { t.Run("", func(t *testing.T) { - tt.curr.MoveToLineEnd() + var buf RuneBuffer + buf.SetWithIdx(tt.in, []rune(tt.text)) - if tt.want.idx != tt.curr.idx { - t.Fatalf("want = %v; got = %v", tt.want.idx, tt.curr.idx) + buf.MoveToLineStart() + + if tt.out != buf.idx { + t.Fatalf("want = %v; got = %v", tt.out, buf.idx) } - if !reflect.DeepEqual(tt.want.buf, tt.curr.buf) { - t.Fatalf("want = %v; got = %v", tt.want.buf, tt.curr.buf) + }) + } +} + +func TestRuneBuffer_MoveToLineEnd(t *testing.T) { + for _, tt := range []struct { + text string + in, out int + }{ + {"foo", 0, 3}, + {"foo", 3, 3}, + {"Lorem ipsum dolor \nsit amet.", 0, 18}, + {"Lorem ipsum dolor \n\nsit amet.", 20, 29}, + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 0, 31}, + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 33, 77}, + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 79, 117}, + {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 77, 77}, + } { + t.Run("", func(t *testing.T) { + var buf RuneBuffer + buf.SetWithIdx(tt.in, []rune(tt.text)) + + buf.MoveToLineEnd() + + if tt.out != buf.idx { + t.Fatalf("want = %v; got = %v", tt.out, buf.idx) } }) } @@ -98,30 +159,6 @@ func TestRuneBuffer_Kill(t *testing.T) { } } -func TestRuneBuffer_CursorPos(t *testing.T) { - for _, tt := range []struct { - text string - screenWidth int - idx int - out image.Point - }{ - {"Lorem ipsum dolor sit amet.", 12, 27, image.Pt(5, 2)}, - {"Lorem ipsum dolor sit amet.", 16, 27, image.Pt(15, 1)}, - {"Lorem ipsum dolor sit amet.", 27, 20, image.Pt(20, 0)}, - } { - t.Run("", func(t *testing.T) { - var r RuneBuffer - r.wordwrap = true - r.SetWithIdx(tt.idx, []rune(tt.text)) - - if got := r.CursorPos(tt.screenWidth); tt.out != got { - t.Fatalf("want = %s; got = %s", tt.out, got) - } - }) - - } -} - func TestRuneBuffer_SplitByLines(t *testing.T) { for _, tt := range []struct { text string @@ -129,18 +166,23 @@ func TestRuneBuffer_SplitByLines(t *testing.T) { wrap bool want []string }{ - {"Lorem ipsum dolor sit amet.", 12, true, []string{"Lorem ipsum", "dolor sit", "amet."}}, + {"Lorem ipsum dolor sit amet.", 12, true, []string{"Lorem ipsum ", "dolor sit ", "amet."}}, {"Lorem ipsum dolor sit amet.", 27, true, []string{"Lorem ipsum dolor sit amet."}}, {"Lorem ipsum dolor sit amet.", 12, false, []string{"Lorem ipsum dolor sit amet."}}, } { - got := getSplitByLine([]rune(tt.text), tt.width, tt.wrap) + var buf RuneBuffer + buf.Set([]rune(tt.text)) + buf.SetMaxWidth(tt.width) + buf.wordwrap = tt.wrap + + got := buf.SplitByLine() if !reflect.DeepEqual(tt.want, got) { t.Fatalf("want = %#v; got = %#v", tt.want, got) } } } -func TestRuneBuffer_CursorPosWithWordWrap(t *testing.T) { +func TestRuneBuffer_CursorPos(t *testing.T) { for _, tt := range []struct { text string screenWidth int @@ -151,24 +193,32 @@ func TestRuneBuffer_CursorPosWithWordWrap(t *testing.T) { // Lorem ipsum // dolor sit amet. {"Lorem ipsum dolor sit amet.", 12, 11, true, image.Pt(11, 0)}, - {"Lorem ipsum dolor sit amet.", 12, 12, true, image.Pt(0, 1)}, - {"Lorem ipsum dolor sit amet.", 12, 13, true, image.Pt(1, 1)}, + {"Lorem ipsum dolor sit amet.", 12, 12, true, image.Pt(12, 0)}, + {"Lorem ipsum dolor sit amet.", 12, 13, true, image.Pt(0, 1)}, // Lorem ipsum dolor // sit amet. {"Lorem ipsum dolor sit amet.", 19, 17, true, image.Pt(17, 0)}, - {"Lorem ipsum dolor sit amet.", 19, 18, true, image.Pt(0, 1)}, - {"Lorem ipsum dolor sit amet.", 19, 19, true, image.Pt(1, 1)}, - {"Lorem ipsum dolor sit amet.", 19, 20, true, image.Pt(2, 1)}, - {"Lorem ipsum dolor sit amet.", 19, 21, true, image.Pt(3, 1)}, + {"Lorem ipsum dolor sit amet.", 19, 18, true, image.Pt(18, 0)}, + {"Lorem ipsum dolor sit amet.", 19, 19, true, image.Pt(0, 1)}, + {"Lorem ipsum dolor sit amet.", 19, 20, true, image.Pt(1, 1)}, + {"Lorem ipsum dolor sit amet.", 19, 21, true, image.Pt(2, 1)}, + + // aa bb + // + // cc dd + {"aa bb\n\ncc dd", 10, 4, true, image.Pt(4, 0)}, + {"aa bb\n\ncc dd", 10, 5, true, image.Pt(5, 0)}, + {"aa bb\n\ncc dd", 10, 6, true, image.Pt(0, 1)}, + {"aa bb\n\ncc dd", 10, 7, true, image.Pt(0, 2)}, } { - t.Skip("Skip until a more sophisticated word wrap has been implemented.") t.Run("", func(t *testing.T) { var r RuneBuffer r.wordwrap = tt.wrap r.SetWithIdx(tt.idx, []rune(tt.text)) + r.SetMaxWidth(tt.screenWidth) - if got := r.CursorPos(tt.screenWidth); tt.want != got { + if got := r.CursorPos(); tt.want != got { t.Fatalf("want = %s; got = %s", tt.want, got) } }) diff --git a/text_edit.go b/text_edit.go index 142c6bd..20498c9 100644 --- a/text_edit.go +++ b/text_edit.go @@ -30,14 +30,15 @@ func (e *TextEdit) Draw(p *Painter) { } p.WithStyle(style, func(p *Painter) { s := e.Size() + e.text.SetMaxWidth(s.X) - lines := e.text.SplitByLine(s.X) + lines := e.text.SplitByLine() for i, line := range lines { p.FillRect(0, i, s.X, 1) p.DrawText(0, i, line) } if e.IsFocused() { - pos := e.text.CursorPos(s.X) + pos := e.text.CursorPos() p.DrawCursor(pos.X, pos.Y) } }) @@ -61,6 +62,9 @@ func (e *TextEdit) OnKeyEvent(ev KeyEvent) { return } + screenWidth := e.Size().X + e.text.SetMaxWidth(screenWidth) + if ev.Key != KeyRune { switch ev.Key { case KeyEnter: @@ -86,8 +90,7 @@ func (e *TextEdit) OnKeyEvent(ev KeyEvent) { case KeyRight, KeyCtrlF: e.text.MoveForward() - screenWidth := e.Size().X - isCursorTooFar := e.text.CursorPos(screenWidth).X >= screenWidth + isCursorTooFar := e.text.CursorPos().X >= screenWidth isTextLeft := (e.text.Width() - e.offset) > (screenWidth - 1) if isCursorTooFar && isTextLeft { @@ -98,7 +101,7 @@ func (e *TextEdit) OnKeyEvent(ev KeyEvent) { e.offset = 0 case KeyEnd, KeyCtrlE: e.text.MoveToLineEnd() - left := e.text.Width() - (e.Size().X - 1) + left := e.text.Width() - (screenWidth - 1) if left >= 0 { e.offset = left } @@ -109,7 +112,7 @@ func (e *TextEdit) OnKeyEvent(ev KeyEvent) { } e.text.WriteRune(ev.Rune) - if e.text.CursorPos(e.Size().X).X >= e.Size().X { + if e.text.CursorPos().X >= screenWidth { e.offset++ } if e.onTextChange != nil { diff --git a/wordwrap/wordwrap.go b/wordwrap/wordwrap.go new file mode 100644 index 0000000..8a92306 --- /dev/null +++ b/wordwrap/wordwrap.go @@ -0,0 +1,54 @@ +package wordwrap + +import ( + "bytes" + "unicode" +) + +// WrapString wraps the input string by inserting newline characters. It does +// not remove whitespace, but preserves the original text. +func WrapString(s string, width int) string { + if len(s) <= 1 { + return s + } + + var buf bytes.Buffer + var word bytes.Buffer + + word.WriteByte(s[0]) + + spaceLeft := width + for i := 1; i < len(s); i++ { + curr := s[i] + prev := s[i-1] + + if curr == '\n' { + if word.Len() > spaceLeft { + spaceLeft = width + buf.WriteRune('\n') + } else { + spaceLeft = width + } + // fmt.Printf("33: writing %q with %d spaces remaining out of %d\n", word.String(), spaceLeft, width) + word.WriteTo(&buf) + } else if unicode.IsSpace(rune(prev)) && !unicode.IsSpace(rune(curr)) { + if word.Len() > spaceLeft { + spaceLeft = width - word.Len() + buf.WriteRune('\n') + } else { + spaceLeft -= word.Len() + } + // fmt.Printf("42: writing %q with %d spaces remaining out of %d\n", word.String(), spaceLeft, width) + word.WriteTo(&buf) + } + word.WriteByte(curr) + } + + if word.Len() > spaceLeft { + buf.WriteRune('\n') + } + // fmt.Printf("51: writing %q with %d spaces remaining out of %d\n", word.String(), spaceLeft, width) + word.WriteTo(&buf) + + return buf.String() +} diff --git a/wordwrap/wordwrap_test.go b/wordwrap/wordwrap_test.go new file mode 100644 index 0000000..9385fc6 --- /dev/null +++ b/wordwrap/wordwrap_test.go @@ -0,0 +1,34 @@ +package wordwrap + +import ( + "strings" + "testing" +) + +func TestSimple(t *testing.T) { + for _, tt := range []struct { + In string + Width int + Out string + }{ + {"", 3, ""}, + {"a", 3, "a"}, + {"aa", 3, "aa"}, + {"aa bb", 3, "aa \nbb"}, + {"aa bb ddd", 7, "aa bb \nddd"}, + {"aaa bb cc ddddd", 7, "aaa bb \ncc \nddddd"}, + {"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas nisl urna, vel accumsan libero bibendum id. In consectetur facilisis iaculis. Vivamus cursus hendrerit neque, et bibendum leo accumsan ac.", 20, "Lorem ipsum dolor \nsit amet, \nconsectetur \nadipiscing elit. \nDuis egestas nisl \nurna, vel accumsan \nlibero bibendum id. \nIn consectetur \nfacilisis iaculis. \nVivamus cursus \nhendrerit neque, et \nbibendum leo \naccumsan ac."}, + {"aaa bb\n\ncc ddddd", 7, "aaa bb\n\ncc \nddddd"}, + {"Nulla lorem magna, efficitur interdum ante at, convallis sodales nulla. Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, sit amet fringilla nisl pharetra quis.", 30, "Nulla lorem magna, efficitur \ninterdum ante at, convallis \nsodales nulla. Sed maximus \ntempor condimentum.\n\nNam et risus est. Cras \nornare iaculis orci, sit amet \nfringilla nisl pharetra quis."}, + {"Nulla lorem magna, efficitur interdum ante at, convallis sodales nulla. Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, sit amet fringilla nisl pharetra quis.", 35, "Nulla lorem magna, efficitur \ninterdum ante at, convallis \nsodales nulla. Sed maximus tempor \ncondimentum.\n\nNam et risus est. Cras ornare \niaculis orci, sit amet fringilla \nnisl pharetra quis."}, + {"\n\nNam et risus est.", 30, "\n\nNam et risus est."}, + {"a\n\na\n\n", 6, "a\n\na\n\n"}, + } { + t.Run("", func(t *testing.T) { + if got := WrapString(tt.In, tt.Width); got != tt.Out { + padding := strings.Repeat(".", tt.Width) + t.Fatalf("\n\ngot = \n\n%s\n%s\n%s\n\nwant = \n\n%s\n%s\n%s", padding, got, padding, padding, tt.Out, padding) + } + }) + } +}