Implement almost full CJK support.

Yang Zhang created

Display of CJK contents are supported. Adding CJK tags are problematic.

Change summary

Gopkg.toml                  |   4 
misc/zsh_completion/git-bug |  12 +-
termui/bug_table.go         |  10 
termui/input_popup.go       |   4 
termui/label_select.go      |   8 
termui/msg_popup.go         |   4 
termui/show_bug.go          |  12 +-
termui/termui.go            |   4 
util/text/left_padded.go    |  19 +-
util/text/text.go           | 207 ++++++++++++++------------------------
util/text/text_test.go      |  70 -------------
11 files changed, 118 insertions(+), 236 deletions(-)

Detailed changes

Gopkg.toml 🔗

@@ -61,5 +61,5 @@
   version = "0.7.1"
 
 [[constraint]]
-  name = "github.com/MichaelMure/gocui"
-  branch = "master"
+  name = "github.com/jesseduffield/gocui"
+  branch = "master"

misc/zsh_completion/git-bug 🔗

@@ -17,6 +17,12 @@ case $state in
   ;;
   level2)
     case $words[2] in
+      status)
+        _arguments '2: :(close open)'
+      ;;
+      title)
+        _arguments '2: :(edit)'
+      ;;
       bridge)
         _arguments '2: :(configure pull rm)'
       ;;
@@ -26,12 +32,6 @@ case $state in
       label)
         _arguments '2: :(add rm)'
       ;;
-      status)
-        _arguments '2: :(close open)'
-      ;;
-      title)
-        _arguments '2: :(edit)'
-      ;;
       *)
         _arguments '*: :_files'
       ;;

termui/bug_table.go 🔗

@@ -8,8 +8,8 @@ import (
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/util/colors"
 	"github.com/MichaelMure/git-bug/util/text"
-	"github.com/MichaelMure/gocui"
 	"github.com/dustin/go-humanize"
+	"github.com/jesseduffield/gocui"
 )
 
 const bugTableView = "bugTableView"
@@ -53,7 +53,7 @@ func (bt *bugTable) layout(g *gocui.Gui) error {
 		return nil
 	}
 
-	v, err := g.SetView(bugTableHeaderView, -1, -1, maxX, 3)
+	v, err := g.SetView(bugTableHeaderView, -1, -1, maxX, 3, 0)
 
 	if err != nil {
 		if err != gocui.ErrUnknownView {
@@ -66,7 +66,7 @@ func (bt *bugTable) layout(g *gocui.Gui) error {
 	v.Clear()
 	bt.renderHeader(v, maxX)
 
-	v, err = g.SetView(bugTableView, -1, 1, maxX, maxY-3)
+	v, err = g.SetView(bugTableView, -1, 1, maxX, maxY-3, 0)
 
 	if err != nil {
 		if err != gocui.ErrUnknownView {
@@ -97,7 +97,7 @@ func (bt *bugTable) layout(g *gocui.Gui) error {
 	v.Clear()
 	bt.render(v, maxX)
 
-	v, err = g.SetView(bugTableFooterView, -1, maxY-4, maxX, maxY)
+	v, err = g.SetView(bugTableFooterView, -1, maxY-4, maxX, maxY, 0)
 
 	if err != nil {
 		if err != gocui.ErrUnknownView {
@@ -110,7 +110,7 @@ func (bt *bugTable) layout(g *gocui.Gui) error {
 	v.Clear()
 	bt.renderFooter(v, maxX)
 
-	v, err = g.SetView(bugTableInstructionView, -1, maxY-2, maxX, maxY)
+	v, err = g.SetView(bugTableInstructionView, -1, maxY-2, maxX, maxY, 0)
 
 	if err != nil {
 		if err != gocui.ErrUnknownView {

termui/input_popup.go 🔗

@@ -3,7 +3,7 @@ package termui
 import (
 	"io/ioutil"
 
-	"github.com/MichaelMure/gocui"
+	"github.com/jesseduffield/gocui"
 )
 
 const inputPopupView = "inputPopupView"
@@ -46,7 +46,7 @@ func (ip *inputPopup) layout(g *gocui.Gui) error {
 	x0 := (maxX - width) / 2
 	y0 := (maxY - height) / 2
 
-	v, err := g.SetView(inputPopupView, x0, y0, x0+width, y0+height)
+	v, err := g.SetView(inputPopupView, x0, y0, x0+width, y0+height, 0)
 	if err != nil {
 		if err != gocui.ErrUnknownView {
 			return err

termui/label_select.go 🔗

@@ -6,7 +6,7 @@ import (
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/gocui"
+	"github.com/jesseduffield/gocui"
 )
 
 const labelSelectView = "labelSelectView"
@@ -105,7 +105,7 @@ func (ls *labelSelect) layout(g *gocui.Gui) error {
 	x0 := 1
 	y0 := 0 - ls.scroll
 
-	v, err := g.SetView(labelSelectView, x0, 0, x0+width, maxY-2)
+	v, err := g.SetView(labelSelectView, x0, 0, x0+width, maxY-2, 0)
 	if err != nil {
 		if err != gocui.ErrUnknownView {
 			return err
@@ -116,7 +116,7 @@ func (ls *labelSelect) layout(g *gocui.Gui) error {
 
 	for i, label := range ls.labels {
 		viewname := fmt.Sprintf("view%d", i)
-		v, err := g.SetView(viewname, x0+2, y0, x0+width-2, y0+2)
+		v, err := g.SetView(viewname, x0+2, y0, x0+width-2, y0+2, 0)
 		if err != nil && err != gocui.ErrUnknownView {
 			return err
 		}
@@ -131,7 +131,7 @@ func (ls *labelSelect) layout(g *gocui.Gui) error {
 		y0 += 2
 	}
 
-	v, err = g.SetView(labelSelectInstructionsView, -1, maxY-2, maxX, maxY)
+	v, err = g.SetView(labelSelectInstructionsView, -1, maxY-2, maxX, maxY, 0)
 	ls.childViews = append(ls.childViews, labelSelectInstructionsView)
 	if err != nil {
 		if err != gocui.ErrUnknownView {

termui/msg_popup.go 🔗

@@ -4,7 +4,7 @@ import (
 	"fmt"
 
 	"github.com/MichaelMure/git-bug/util/text"
-	"github.com/MichaelMure/gocui"
+	"github.com/jesseduffield/gocui"
 )
 
 const msgPopupView = "msgPopupView"
@@ -50,7 +50,7 @@ func (ep *msgPopup) layout(g *gocui.Gui) error {
 	x0 := (maxX - width) / 2
 	y0 := (maxY - height) / 2
 
-	v, err := g.SetView(msgPopupView, x0, y0, x0+width, y0+height)
+	v, err := g.SetView(msgPopupView, x0, y0, x0+width, y0+height, 0)
 	if err != nil {
 		if err != gocui.ErrUnknownView {
 			return err

termui/show_bug.go 🔗

@@ -10,7 +10,7 @@ import (
 	"github.com/MichaelMure/git-bug/util/colors"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/text"
-	"github.com/MichaelMure/gocui"
+	"github.com/jesseduffield/gocui"
 )
 
 const showBugView = "showBugView"
@@ -48,7 +48,7 @@ func (sb *showBug) layout(g *gocui.Gui) error {
 	maxX, maxY := g.Size()
 	sb.childViews = nil
 
-	v, err := g.SetView(showBugView, 0, 0, maxX*2/3, maxY-2)
+	v, err := g.SetView(showBugView, 0, 0, maxX*2/3, maxY-2, 0)
 
 	if err != nil {
 		if err != gocui.ErrUnknownView {
@@ -65,7 +65,7 @@ func (sb *showBug) layout(g *gocui.Gui) error {
 		return err
 	}
 
-	v, err = g.SetView(showBugSidebarView, maxX*2/3+1, 0, maxX-1, maxY-2)
+	v, err = g.SetView(showBugSidebarView, maxX*2/3+1, 0, maxX-1, maxY-2, 0)
 
 	if err != nil {
 		if err != gocui.ErrUnknownView {
@@ -82,7 +82,7 @@ func (sb *showBug) layout(g *gocui.Gui) error {
 		return err
 	}
 
-	v, err = g.SetView(showBugInstructionView, -1, maxY-2, maxX, maxY)
+	v, err = g.SetView(showBugInstructionView, -1, maxY-2, maxX, maxY, 0)
 
 	if err != nil {
 		if err != gocui.ErrUnknownView {
@@ -382,7 +382,7 @@ func emptyMessagePlaceholder() string {
 }
 
 func (sb *showBug) createOpView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int, selectable bool) (*gocui.View, error) {
-	v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
+	v, err := g.SetView(name, x0, y0, maxX, y0+height+1, 0)
 
 	if err != nil && err != gocui.ErrUnknownView {
 		return nil, err
@@ -402,7 +402,7 @@ func (sb *showBug) createOpView(g *gocui.Gui, name string, x0 int, y0 int, maxX
 }
 
 func (sb *showBug) createSideView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int) (*gocui.View, error) {
-	v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
+	v, err := g.SetView(name, x0, y0, maxX, y0+height+1, 0)
 
 	if err != nil && err != gocui.ErrUnknownView {
 		return nil, err

termui/termui.go 🔗

@@ -5,7 +5,7 @@ import (
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/input"
 	"github.com/MichaelMure/git-bug/util/git"
-	"github.com/MichaelMure/gocui"
+	"github.com/jesseduffield/gocui"
 	"github.com/pkg/errors"
 )
 
@@ -69,7 +69,7 @@ func Run(cache *cache.RepoCache) error {
 }
 
 func initGui(action func(ui *termUI) error) {
-	g, err := gocui.NewGui(gocui.OutputNormal)
+	g, err := gocui.NewGui(gocui.OutputNormal, false)
 
 	if err != nil {
 		ui.gError <- err

util/text/left_padded.go 🔗

@@ -3,25 +3,26 @@ package text
 import (
 	"bytes"
 	"fmt"
+	"github.com/mattn/go-runewidth"
 	"strings"
 )
 
-// LeftPadMaxLine pads a string on the left by a specified amount and pads the string on the right to fill the maxLength
+// LeftPadMaxLine pads a string on the left by a specified amount and pads the
+// string on the right to fill the maxLength
 func LeftPadMaxLine(text string, length, leftPad int) string {
-	runes := []rune(text)
+	rightPart := text
 
+	scrWidth := runewidth.StringWidth(text)
 	// truncate and ellipse if needed
-	if len(runes)+leftPad > length {
-		runes = append(runes[:(length-leftPad-1)], '…')
-	}
-
-	if len(runes)+leftPad < length {
-		runes = append(runes, []rune(strings.Repeat(" ", length-len(runes)-leftPad))...)
+	if scrWidth+leftPad > length {
+		rightPart = runewidth.Truncate(text, length-leftPad, "…")
+	} else if scrWidth+leftPad < length {
+		rightPart = runewidth.FillRight(text, length-leftPad)
 	}
 
 	return fmt.Sprintf("%s%s",
 		strings.Repeat(" ", leftPad),
-		string(runes),
+		rightPart,
 	)
 }
 

util/text/text.go 🔗

@@ -2,9 +2,8 @@ package text
 
 import (
 	"bytes"
-	"strings"
-
 	"github.com/mattn/go-runewidth"
+	"strings"
 )
 
 // Wrap a text for an exact line size
@@ -17,97 +16,99 @@ func Wrap(text string, lineWidth int) (string, int) {
 // Handle properly terminal color escape code
 func WrapLeftPadded(text string, lineWidth int, leftPad int) (string, int) {
 	var textBuffer bytes.Buffer
-	var lineBuffer bytes.Buffer
-	nbLine := 1
-	firstLine := true
+	nbLine := 0
 	pad := strings.Repeat(" ", leftPad)
 
 	// tabs are formatted as 4 spaces
 	text = strings.Replace(text, "\t", "    ", 4)
+	wrapped := wrapText(text, lineWidth-leftPad)
+	for _, line := range strings.Split(wrapped, "\n") {
+		textBuffer.WriteString(pad + line)
+		textBuffer.WriteString("\n")
+		nbLine++
+	}
+	return textBuffer.String(), nbLine
+}
 
-	for _, line := range strings.Split(text, "\n") {
-		spaceLeft := lineWidth - leftPad
-
-		if !firstLine {
-			textBuffer.WriteString("\n")
-			nbLine++
-		}
-
-		firstWord := true
-
-		for _, word := range strings.Split(line, " ") {
-			wordLength := wordLen(word)
-
-			if !firstWord {
-				lineBuffer.WriteString(" ")
-				spaceLeft -= 1
-
-				if spaceLeft <= 0 {
-					textBuffer.WriteString(pad + strings.TrimRight(lineBuffer.String(), " "))
-					textBuffer.WriteString("\n")
-					lineBuffer.Reset()
-					spaceLeft = lineWidth - leftPad
-					nbLine++
-					firstLine = false
-				}
+// Wrap text so that each line fills at most w cells. Lines break at word
+// boundary or multibyte chars.
+//
+// Wrapping Algorithm: Treat the text as a sequence of words, with each word be
+// an alphanumeric word, or a multibyte char. We scan through the text and
+// construct the word, and flush the word into the paragraph once a word is
+// ready. A word is ready when a word boundary is detected: a boundary char such
+// as '\n', '\t', and ' ' is encountered; a multibyte char is found; or a
+// multibyte to single-byte switch is encountered. '\n' is handled in a special
+// manner.
+func wrapText(s string, w int) string {
+	word := ""
+	out := ""
+
+	width := 0
+	firstWord := true
+	isMultibyteWord := false
+
+	flushWord := func() {
+		wl := wordLen(word)
+		if isMultibyteWord {
+			if width+wl > w {
+				out += "\n" + word
+				width = wl
+			} else {
+				out += word
+				width += wl
 			}
-
-			// Word fit in the current line
-			if spaceLeft >= wordLength {
-				lineBuffer.WriteString(word)
-				spaceLeft -= wordLength
-				firstWord = false
+		} else {
+			if width == 0 {
+				out += word
+				width += wl
+			} else if width+wl+1 > w {
+				out += "\n" + word
+				width = wl
 			} else {
-				// Break a word longer than a line
-				if wordLength > lineWidth {
-					for wordLength > 0 && wordLen(word) > 0 {
-						l := minInt(spaceLeft, wordLength)
-						part, leftover := splitWord(word, l)
-						word = leftover
-						wordLength = wordLen(word)
-
-						lineBuffer.WriteString(part)
-						textBuffer.WriteString(pad)
-						textBuffer.Write(lineBuffer.Bytes())
-						lineBuffer.Reset()
-
-						spaceLeft -= l
-
-						if spaceLeft <= 0 {
-							textBuffer.WriteString("\n")
-							nbLine++
-							spaceLeft = lineWidth - leftPad
-						}
-
-						if wordLength <= 0 {
-							break
-						}
-					}
-				} else {
-					// Normal break
-					textBuffer.WriteString(pad + strings.TrimRight(lineBuffer.String(), " "))
-					textBuffer.WriteString("\n")
-					lineBuffer.Reset()
-					lineBuffer.WriteString(word)
-					firstWord = false
-					spaceLeft = lineWidth - leftPad - wordLength
-					nbLine++
-				}
+				out += " " + word
+				width += wl + 1
 			}
 		}
+		word = ""
+	}
 
-		if lineBuffer.Len() > 0 {
-			textBuffer.WriteString(pad + strings.TrimRight(lineBuffer.String(), " "))
-			lineBuffer.Reset()
+	for _, r := range []rune(s) {
+		cw := runewidth.RuneWidth(r)
+		if firstWord {
+			word = string(r)
+			isMultibyteWord = cw > 1
+			firstWord = false
+			continue
+		}
+		if r == '\n' {
+			flushWord()
+			out += "\n"
+			width = 0
+		} else if r == ' ' || r == '\t' {
+			flushWord()
+		} else if cw > 1 {
+			flushWord()
+			word = string(r)
+			isMultibyteWord = true
+			word = string(r)
+		} else if cw == 1 && isMultibyteWord {
+			flushWord()
+			word = string(r)
+			isMultibyteWord = false
+		} else {
+			word += string(r)
 		}
-
-		firstLine = false
 	}
+	// The text may end without newlines, ensure flushing it or we can lose the
+	// last word.
+	flushWord()
 
-	return textBuffer.String(), nbLine
+	return out
 }
 
-// wordLen return the length of a word, while ignoring the terminal escape sequences
+// wordLen return the length of a word, while ignoring the terminal escape
+// sequences
 func wordLen(word string) int {
 	length := 0
 	escape := false
@@ -116,11 +117,9 @@ func wordLen(word string) int {
 		if char == '\x1b' {
 			escape = true
 		}
-
 		if !escape {
 			length += runewidth.RuneWidth(rune(char))
 		}
-
 		if char == 'm' {
 			escape = false
 		}
@@ -128,51 +127,3 @@ func wordLen(word string) int {
 
 	return length
 }
-
-// splitWord split a word at the given length, while ignoring the terminal escape sequences
-func splitWord(word string, length int) (string, string) {
-	runes := []rune(word)
-	var result []rune
-	added := 0
-	escape := false
-
-	if length == 0 {
-		return "", word
-	}
-
-	for _, r := range runes {
-		if r == '\x1b' {
-			escape = true
-		}
-
-		width := runewidth.RuneWidth(r)
-		if width+added > length {
-			// wide character made the length overflow
-			break
-		}
-
-		result = append(result, r)
-
-		if !escape {
-			added += width
-			if added >= length {
-				break
-			}
-		}
-
-		if r == 'm' {
-			escape = false
-		}
-	}
-
-	leftover := runes[len(result):]
-
-	return string(result), string(leftover)
-}
-
-func minInt(a, b int) int {
-	if a > b {
-		return b
-	}
-	return a
-}

util/text/text_test.go 🔗

@@ -203,73 +203,3 @@ func TestWordLen(t *testing.T) {
 		}
 	}
 }
-
-func TestSplitWord(t *testing.T) {
-	cases := []struct {
-		Input            string
-		Length           int
-		Result, Leftover string
-	}{
-		// A simple word passes through.
-		{
-			"foo",
-			4,
-			"foo", "",
-		},
-		// Cut at the right place
-		{
-			"foobarHoy",
-			4,
-			"foob", "arHoy",
-		},
-		// A simple word passes through with colors
-		{
-			"\x1b[31mbar\x1b[0m",
-			4,
-			"\x1b[31mbar\x1b[0m", "",
-		},
-		// Cut at the right place with colors
-		{
-			"\x1b[31mfoobarHoy\x1b[0m",
-			4,
-			"\x1b[31mfoob", "arHoy\x1b[0m",
-		},
-		// Handle prefix and suffix properly
-		{
-			"foo\x1b[31mfoobarHoy\x1b[0mbaaar",
-			4,
-			"foo\x1b[31mf", "oobarHoy\x1b[0mbaaar",
-		},
-		// Cut properly with length = 0
-		{
-			"foo",
-			0,
-			"", "foo",
-		},
-		// Handle chinese
-		{
-			"快檢什麼望對",
-			4,
-			"快檢", "什麼望對",
-		},
-		{
-			"快檢什麼望對",
-			5,
-			"快檢", "什麼望對",
-		},
-		// Handle chinese with colors
-		{
-			"快\x1b[31m檢什麼\x1b[0m望對",
-			4,
-			"快\x1b[31m檢", "什麼\x1b[0m望對",
-		},
-	}
-
-	for i, tc := range cases {
-		result, leftover := splitWord(tc.Input, tc.Length)
-		if result != tc.Result || leftover != tc.Leftover {
-			t.Fatalf("Case %d Input:\n\n`%s`\n\nExpected Output:\n\n`%s` - `%s`\n\nActual Output:\n\n`%s` - `%s`",
-				i, tc.Input, tc.Result, tc.Leftover, result, leftover)
-		}
-	}
-}