From 61d8ea30f88542eb6c48d1b7fd0331a7f1411116 Mon Sep 17 00:00:00 2001 From: Oliver <480930+rivo@users.noreply.github.com> Date: Thu, 11 Jan 2018 15:45:52 +0100 Subject: [PATCH] Added support for wide unicode characters (e.g. Chinese). Resolves #9 --- box.go | 20 ++++++++++++---- button.go | 3 ++- checkbox.go | 3 ++- doc.go | 4 ++++ dropdown.go | 6 +++-- form.go | 10 ++++---- inputfield.go | 12 ++++++---- list.go | 3 ++- modal.go | 3 ++- table.go | 28 +++++++++++++++------- textview.go | 14 +++++++---- util.go | 66 ++++++++++++++++++++++++++++++++++++++------------- 12 files changed, 123 insertions(+), 49 deletions(-) diff --git a/box.go b/box.go index ad85568..e3718bb 100644 --- a/box.go +++ b/box.go @@ -2,6 +2,7 @@ package tview import ( "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // Box implements Primitive with a background and optional elements such as a @@ -188,11 +189,22 @@ func (b *Box) Draw(screen tcell.Screen) { // Draw title. if b.title != "" && b.width >= 4 { width := b.width - 2 - title := []rune(b.title) - if width < len(title) && width > 0 { - title = append(title[:width-1], GraphicsEllipsis) + title := b.title + titleWidth := runewidth.StringWidth(title) + if width < titleWidth && width > 0 { + // Grow title until we hit the end. + abbrWidth := runewidth.RuneWidth(GraphicsEllipsis) + abbrPos := 0 + for pos, ch := range title { + if abbrWidth >= width { + title = title[:abbrPos] + string(GraphicsEllipsis) + break + } + abbrWidth += runewidth.RuneWidth(ch) + abbrPos = pos + } } - Print(screen, string(title), b.x+1, b.y, width, b.titleAlign, b.titleColor) + Print(screen, title, b.x+1, b.y, width, b.titleAlign, b.titleColor) } } } diff --git a/button.go b/button.go index b7cf9c2..d83bad7 100644 --- a/button.go +++ b/button.go @@ -2,6 +2,7 @@ package tview import ( "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // Button is labeled box that triggers an action when selected. @@ -33,7 +34,7 @@ type Button struct { // NewButton returns a new input field. func NewButton(label string) *Button { box := NewBox().SetBackgroundColor(Styles.ContrastBackgroundColor) - box.SetRect(0, 0, len([]rune(label))+4, 1) + box.SetRect(0, 0, runewidth.StringWidth(label)+4, 1) return &Button{ Box: box, label: label, diff --git a/checkbox.go b/checkbox.go index 9b179d9..aa37359 100644 --- a/checkbox.go +++ b/checkbox.go @@ -133,7 +133,8 @@ func (c *Checkbox) Draw(screen tcell.Screen) { } // Draw label. - x += Print(screen, c.label, x, y, rightLimit-x, AlignLeft, c.labelColor) + _, drawnWidth := Print(screen, c.label, x, y, rightLimit-x, AlignLeft, c.labelColor) + x += drawnWidth // Draw checkbox. fieldStyle := tcell.StyleDefault.Background(c.fieldBackgroundColor).Foreground(c.fieldTextColor) diff --git a/doc.go b/doc.go index c537d62..886b442 100644 --- a/doc.go +++ b/doc.go @@ -65,6 +65,10 @@ When primitives are instantiated, they are initialized with colors taken from the global Styles variable. You may change this variable to adapt the look and feel of the primitives to your preferred style. +Unicode Support + +This package supports unicode characters including wide characters. + Type Hierarchy All widgets listed above contain the Box type. All of Box's functions are diff --git a/dropdown.go b/dropdown.go index f834c1f..9c360a6 100644 --- a/dropdown.go +++ b/dropdown.go @@ -2,6 +2,7 @@ package tview import ( "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // dropDownOption is one option that can be selected in a drop-down primitive. @@ -193,12 +194,13 @@ func (d *DropDown) Draw(screen tcell.Screen) { } // Draw label. - x += Print(screen, d.label, x, y, rightLimit-x, AlignLeft, d.labelColor) + _, drawnWidth := Print(screen, d.label, x, y, rightLimit-x, AlignLeft, d.labelColor) + x += drawnWidth // What's the longest option text? maxLength := 0 for _, option := range d.options { - length := len([]rune(option.Text)) + length := runewidth.StringWidth(option.Text) if length > maxLength { maxLength = length } diff --git a/form.go b/form.go index 6e7c863..a3e30d6 100644 --- a/form.go +++ b/form.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // FormItem is the interface all form items must implement to be able to be @@ -197,8 +198,9 @@ func (f *Form) Draw(screen tcell.Screen) { var labelLength int for _, item := range f.items { label := strings.TrimSpace(item.GetLabel()) - if len([]rune(label)) > labelLength { - labelLength = len([]rune(label)) + labelWidth := runewidth.StringWidth(label) + if labelWidth > labelLength { + labelLength = labelWidth } } labelLength++ // Add one space. @@ -210,7 +212,7 @@ func (f *Form) Draw(screen tcell.Screen) { } label := strings.TrimSpace(item.GetLabel()) item.SetFormAttributes( - label+strings.Repeat(" ", labelLength-len([]rune(label))), + label+strings.Repeat(" ", labelLength-runewidth.StringWidth(label)), f.labelColor, f.backgroundColor, f.fieldTextColor, @@ -228,7 +230,7 @@ func (f *Form) Draw(screen tcell.Screen) { buttonWidths := make([]int, len(f.buttons)) buttonsWidth := 0 for index, button := range f.buttons { - width := len([]rune(button.GetLabel())) + 4 + width := runewidth.StringWidth(button.GetLabel()) + 4 buttonWidths[index] = width buttonsWidth += width + 2 } diff --git a/inputfield.go b/inputfield.go index f0f4c9b..2403973 100644 --- a/inputfield.go +++ b/inputfield.go @@ -5,6 +5,7 @@ import ( "regexp" "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // InputField is a one-line box (three lines if there is a title) where the @@ -162,7 +163,8 @@ func (i *InputField) Draw(screen tcell.Screen) { } // Draw label. - x += Print(screen, i.label, x, y, rightLimit-x, AlignLeft, i.labelColor) + _, drawnWidth := Print(screen, i.label, x, y, rightLimit-x, AlignLeft, i.labelColor) + x += drawnWidth // Draw input area. fieldLength := i.fieldLength @@ -179,7 +181,7 @@ func (i *InputField) Draw(screen tcell.Screen) { // Draw entered text. fieldLength-- // We need one cell for the cursor. - if fieldLength < len([]rune(i.text)) { + if fieldLength < runewidth.StringWidth(i.text) { Print(screen, i.text, x, y, fieldLength, AlignRight, i.fieldTextColor) } else { Print(screen, i.text, x, y, fieldLength, AlignLeft, i.fieldTextColor) @@ -201,11 +203,11 @@ func (i *InputField) setCursor(screen tcell.Screen) { y++ rightLimit -= 2 } - fieldLength := len([]rune(i.text)) + fieldLength := runewidth.StringWidth(i.text) if i.fieldLength > 0 && fieldLength > i.fieldLength-1 { fieldLength = i.fieldLength - 1 } - x += len([]rune(i.label)) + fieldLength + x += runewidth.StringWidth(i.label) + fieldLength if x >= rightLimit { x = rightLimit - 1 } @@ -239,7 +241,7 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p lastWord := regexp.MustCompile(`\s*\S+\s*$`) i.text = lastWord.ReplaceAllString(i.text, "") case tcell.KeyBackspace, tcell.KeyBackspace2: // Delete last character. - if len([]rune(i.text)) == 0 { + if len(i.text) == 0 { break } runes := []rune(i.text) diff --git a/list.go b/list.go index 29de84c..6f56977 100644 --- a/list.go +++ b/list.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // listItem represents one item in a List. @@ -207,7 +208,7 @@ func (l *List) Draw(screen tcell.Screen) { // Main text. color := l.mainTextColor if index == l.currentItem { - textLength := len([]rune(item.MainText)) + textLength := runewidth.StringWidth(item.MainText) style := tcell.StyleDefault.Background(l.selectedBackgroundColor) for bx := 0; bx < textLength && bx < width; bx++ { screen.SetContent(x+bx, y, ' ', nil, style) diff --git a/modal.go b/modal.go index ec91feb..07fa74a 100644 --- a/modal.go +++ b/modal.go @@ -2,6 +2,7 @@ package tview import ( "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // Modal is a centered message window used to inform the user or prompt them @@ -101,7 +102,7 @@ func (m *Modal) Draw(screen tcell.Screen) { // Calculate the width of this modal. buttonsWidth := 0 for _, button := range m.form.buttons { - buttonsWidth += len([]rune(button.label)) + 4 + 2 + buttonsWidth += runewidth.StringWidth(button.label) + 4 + 2 } buttonsWidth -= 2 screenWidth, screenHeight := screen.Size() diff --git a/table.go b/table.go index 9523a59..33df390 100644 --- a/table.go +++ b/table.go @@ -2,6 +2,7 @@ package tview import ( "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // TableCell represents one cell inside a Table. @@ -13,9 +14,9 @@ type TableCell struct { // or AlignRight. Align int - // The maximum width of the cell. This is used to give a column a maximum - // width. Any cell text whose length exceeds this width is cut off. Set to - // 0 if there is no maximum width. + // The maximum width of the cell in screen space. This is used to give a + // column a maximum width. Any cell text whose screen width exceeds this width + // is cut off. Set to 0 if there is no maximum width. MaxWidth int // The color of the cell text. @@ -464,7 +465,7 @@ ColumnLoop: maxWidth := -1 for _, row := range rows { if cell := getCell(row, column); cell != nil { - cellWidth := len(cell.Text) + cellWidth := runewidth.StringWidth(cell.Text) if cell.MaxWidth > 0 && cell.MaxWidth < cellWidth { cellWidth = cell.MaxWidth } @@ -559,11 +560,22 @@ ColumnLoop: cell.x, cell.y, cell.width = x+columnX+1, y+rowY, finalWidth // Draw text. - text := []rune(cell.Text) - if finalWidth < len(text) && finalWidth > 0 { - text = append(text[:finalWidth-1], GraphicsEllipsis) + text := cell.Text + textWidth := runewidth.StringWidth(text) + if finalWidth < textWidth && finalWidth > 0 { + // Grow title until we hit the end. + abbrWidth := runewidth.RuneWidth(GraphicsEllipsis) + abbrPos := 0 + for pos, ch := range text { + if abbrWidth >= finalWidth { + text = text[:abbrPos] + string(GraphicsEllipsis) + break + } + abbrWidth += runewidth.RuneWidth(ch) + abbrPos = pos + } } - Print(screen, string(text), x+columnX+1, y+rowY, finalWidth, cell.Align, textColor) + Print(screen, text, x+columnX+1, y+rowY, finalWidth, cell.Align, textColor) } // Draw bottom border. diff --git a/textview.go b/textview.go index 620a960..8e6b346 100644 --- a/textview.go +++ b/textview.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // textColors maps color strings which may be embedded in text sent to a @@ -484,7 +485,7 @@ func (t *TextView) reindexBuffer(width int) { // Break down the line. var currentTag, currentRegion, currentWidth int - for pos := range str { + for pos, ch := range str { // Skip any color tags. if currentTag < len(colorTags) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { if pos == colorTagIndices[currentTag][1]-1 { @@ -529,7 +530,7 @@ func (t *TextView) reindexBuffer(width int) { } // Proceed. - currentWidth++ + currentWidth += runewidth.RuneWidth(ch) // Have we crossed the width? if t.wrap && currentWidth >= width { @@ -652,7 +653,8 @@ func (t *TextView) Draw(screen tcell.Screen) { } // Stop at the right border. - if posX >= width { + chWidth := runewidth.RuneWidth(ch) + if posX+chWidth > width { break } @@ -665,10 +667,12 @@ func (t *TextView) Draw(screen tcell.Screen) { } // Draw the character. - screen.SetContent(x+posX, y+line-t.lineOffset, ch, nil, style) + for offset := 0; offset < chWidth; offset++ { + screen.SetContent(x+posX+offset, y+line-t.lineOffset, ch, nil, style) + } // Advance. - posX++ + posX += chWidth } } diff --git a/util.go b/util.go index 63778b2..a938187 100644 --- a/util.go +++ b/util.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) // Text alignment within a box. @@ -90,40 +91,69 @@ func init() { // not exceeding that box. "align" is one of AlignLeft, AlignCenter, or // AlignRight. The screen's background color will not be changed. // -// Returns the number of actual runes printed. -func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tcell.Color) int { +// Returns the number of actual runes printed and the actual width used for the +// printed runes. +func Print(screen tcell.Screen, text string, x, y, maxWidth, align int, color tcell.Color) (int, int) { // We deal with runes, not with bytes. runes := []rune(text) if maxWidth < 0 { - return 0 + return 0, 0 } - // AlignCenter is split into two parts. + // AlignCenter is a special case. if align == AlignCenter { - half := len(runes) / 2 - halfWidth := maxWidth / 2 - return Print(screen, string(runes[:half]), x, y, halfWidth, AlignRight, color) + - Print(screen, string(runes[half:]), x+halfWidth, y, maxWidth-halfWidth, AlignLeft, color) + width := runewidth.StringWidth(text) + if width == maxWidth { + // Use the exact space. + return Print(screen, text, x, y, maxWidth, AlignLeft, color) + } else if width < maxWidth { + // We have more space than we need. + half := (maxWidth - width) / 2 + return Print(screen, text, x+half, y, maxWidth-half, AlignLeft, color) + } else { + // Chop off runes until we have a perfect fit. + var start, choppedLeft, choppedRight int + ru := runes + for len(ru) > 0 && width-choppedLeft-choppedRight > maxWidth { + leftWidth := runewidth.RuneWidth(ru[0]) + rightWidth := runewidth.RuneWidth(ru[len(ru)-1]) + if choppedLeft < choppedRight { + start++ + choppedLeft += leftWidth + ru = ru[1:] + } else { + choppedRight += rightWidth + ru = ru[:len(ru)-1] + } + } + return Print(screen, string(ru), x, y, maxWidth, AlignLeft, color) + } } // Draw text. drawn := 0 + drawnWidth := 0 for pos, ch := range runes { - if pos >= maxWidth { + chWidth := runewidth.RuneWidth(ch) + if drawnWidth+chWidth > maxWidth { break } - finalX := x + pos + finalX := x + drawnWidth if align == AlignRight { ch = runes[len(runes)-1-pos] - finalX = x + maxWidth - 1 - pos + finalX = x + maxWidth - chWidth - drawnWidth } _, _, style, _ := screen.GetContent(finalX, y) style = style.Foreground(color) - screen.SetContent(finalX, y, ch, nil, style) + for offset := 0; offset < chWidth; offset++ { + // To avoid undesired effects, we place the same character in all cells. + screen.SetContent(finalX+offset, y, ch, nil, style) + } drawn++ + drawnWidth += chWidth } - return drawn + return drawn, drawnWidth } // PrintSimple prints white text to the screen at the given position. @@ -132,8 +162,8 @@ func PrintSimple(screen tcell.Screen, text string, x, y int) { } // WordWrap splits a text such that each resulting line does not exceed the -// given width. Possible split points are after commas, dots, dashes, and any -// whitespace. Whitespace at split points will be dropped. +// given screen width. Possible split points are after commas, dots, dashes, +// and any whitespace. Whitespace at split points will be dropped. // // Text is always split at newline characters ('\n'). func WordWrap(text string, width int) (lines []string) { @@ -146,6 +176,8 @@ func WordWrap(text string, width int) (lines []string) { text = strings.TrimSpace(text) for pos, ch := range text { + chWidth := runewidth.RuneWidth(ch) + if !evaluatingCandidate && x >= width { // We've exceeded the width, we must split. if candidate >= 0 { @@ -190,8 +222,8 @@ func WordWrap(text string, width int) (lines []string) { countAfterCandidate = 0 } } - x++ - countAfterCandidate++ + x += chWidth + countAfterCandidate += chWidth } // Process remaining text.