From 3f75a8369f63f2bd6ac3686fc5d88f2bc128e610 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Fri, 14 May 2021 11:43:32 +0900 Subject: [PATCH] Replace RuneWidth to StringWidth to handle grapheme clusters Fix #2482 --- go.mod | 2 +- src/options.go | 8 ++--- src/terminal.go | 58 +++++++++++++++----------------- src/tui/light.go | 31 ++++++++++------- src/tui/tcell.go | 86 +++++++++++++++++++++++------------------------- src/util/util.go | 33 +++++++++++-------- 6 files changed, 110 insertions(+), 108 deletions(-) diff --git a/go.mod b/go.mod index a5a3a6b..a28d6f8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/mattn/go-isatty v0.0.12 github.com/mattn/go-runewidth v0.0.12 github.com/mattn/go-shellwords v1.0.11 - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.2.0 github.com/saracen/walker v0.1.2 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 diff --git a/src/options.go b/src/options.go index a136b85..dc40fdc 100644 --- a/src/options.go +++ b/src/options.go @@ -1536,15 +1536,13 @@ func validateSign(sign string, signOptName string) error { if sign == "" { return fmt.Errorf("%v cannot be empty", signOptName) } - widthSum := 0 for _, r := range sign { if !unicode.IsGraphic(r) { return fmt.Errorf("invalid character in %v", signOptName) } - widthSum += runewidth.RuneWidth(r) - if widthSum > 2 { - return fmt.Errorf("%v display width should be up to 2", signOptName) - } + } + if runewidth.StringWidth(sign) > 2 { + return fmt.Errorf("%v display width should be up to 2", signOptName) } return nil } diff --git a/src/terminal.go b/src/terminal.go index 54f9667..aabeb07 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -2,7 +2,6 @@ package fzf import ( "bufio" - "bytes" "fmt" "io/ioutil" "os" @@ -15,6 +14,9 @@ import ( "syscall" "time" + "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" + "github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/util" ) @@ -673,11 +675,8 @@ func (t *Terminal) sortSelected() []selectedItem { } func (t *Terminal) displayWidth(runes []rune) int { - l := 0 - for _, r := range runes { - l += util.RuneWidth(r, l, t.tabstop) - } - return l + width, _ := util.RunesWidth(runes, 0, t.tabstop, 0) + return width } const ( @@ -1141,28 +1140,18 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool) { t.prevLines[i] = newLine } -func (t *Terminal) trimRight(runes []rune, width int) ([]rune, int) { +func (t *Terminal) trimRight(runes []rune, width int) ([]rune, bool) { // We start from the beginning to handle tab characters - l := 0 - for idx, r := range runes { - l += util.RuneWidth(r, l, t.tabstop) - if l > width { - return runes[:idx], len(runes) - idx - } + width, overflowIdx := util.RunesWidth(runes, 0, t.tabstop, width) + if overflowIdx >= 0 { + return runes[:overflowIdx], true } - return runes, 0 + return runes, false } func (t *Terminal) displayWidthWithLimit(runes []rune, prefixWidth int, limit int) int { - l := 0 - for _, r := range runes { - l += util.RuneWidth(r, l+prefixWidth, t.tabstop) - if l > limit { - // Early exit - return l - } - } - return l + width, _ := util.RunesWidth(runes, prefixWidth, t.tabstop, limit) + return width } func (t *Terminal) trimLeft(runes []rune, width int) ([]rune, int32) { @@ -1362,9 +1351,9 @@ func (t *Terminal) renderPreviewText(height int, lines []string, lineNo int, unc prefixWidth := 0 _, _, ansi = extractColor(line, ansi, func(str string, ansi *ansiState) bool { trimmed := []rune(str) - trimmedLen := 0 + isTrimmed := false if !t.previewOpts.wrap { - trimmed, trimmedLen = t.trimRight(trimmed, maxWidth-t.pwindow.X()) + trimmed, isTrimmed = t.trimRight(trimmed, maxWidth-t.pwindow.X()) } str, width := t.processTabs(trimmed, prefixWidth) prefixWidth += width @@ -1374,7 +1363,7 @@ func (t *Terminal) renderPreviewText(height int, lines []string, lineNo int, unc } else { fillRet = t.pwindow.CFill(tui.ColPreview.Fg(), tui.ColPreview.Bg(), tui.AttrRegular, str) } - return trimmedLen == 0 && + return !isTrimmed && (fillRet == tui.FillContinue || t.previewOpts.wrap && fillRet == tui.FillNextLine) }) t.previewer.scrollable = t.previewer.scrollable || t.pwindow.Y() == height-1 && t.pwindow.X() == t.pwindow.Width() @@ -1430,16 +1419,21 @@ func (t *Terminal) printPreviewDelayed() { } func (t *Terminal) processTabs(runes []rune, prefixWidth int) (string, int) { - var strbuf bytes.Buffer + var strbuf strings.Builder l := prefixWidth - for _, r := range runes { - w := util.RuneWidth(r, l, t.tabstop) - l += w - if r == '\t' { + gr := uniseg.NewGraphemes(string(runes)) + for gr.Next() { + rs := gr.Runes() + str := string(rs) + var w int + if len(rs) == 1 && rs[0] == '\t' { + w = t.tabstop - l%t.tabstop strbuf.WriteString(strings.Repeat(" ", w)) } else { - strbuf.WriteRune(r) + w = runewidth.StringWidth(str) + strbuf.WriteString(str) } + l += w } return strbuf.String(), l } diff --git a/src/tui/light.go b/src/tui/light.go index 91b4c18..d3e3fab 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -10,7 +10,8 @@ import ( "time" "unicode/utf8" - "github.com/junegunn/fzf/src/util" + "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" "golang.org/x/term" ) @@ -50,7 +51,7 @@ func (r *LightRenderer) stderrInternal(str string, allowNLCR bool) { } bytes = bytes[sz:] } - r.queued += string(runes) + r.queued.WriteString(string(runes)) } func (r *LightRenderer) csi(code string) { @@ -58,9 +59,9 @@ func (r *LightRenderer) csi(code string) { } func (r *LightRenderer) flush() { - if len(r.queued) > 0 { - fmt.Fprint(os.Stderr, r.queued) - r.queued = "" + if r.queued.Len() > 0 { + fmt.Fprint(os.Stderr, r.queued.String()) + r.queued.Reset() } } @@ -82,7 +83,7 @@ type LightRenderer struct { escDelay int fullscreen bool upOneLine bool - queued string + queued strings.Builder y int x int maxHeightFunc func(int) int @@ -889,20 +890,26 @@ func wrapLine(input string, prefixLength int, max int, tabstop int) []wrappedLin lines := []wrappedLine{} width := 0 line := "" - for _, r := range input { - w := util.RuneWidth(r, prefixLength+width, 8) - width += w - str := string(r) - if r == '\t' { + gr := uniseg.NewGraphemes(input) + for gr.Next() { + rs := gr.Runes() + str := string(rs) + var w int + if len(rs) == 1 && rs[0] == '\t' { + w = tabstop - (prefixLength+width)%tabstop str = repeat(' ', w) + } else { + w = runewidth.StringWidth(str) } + width += w + if prefixLength+width <= max { line += str } else { lines = append(lines, wrappedLine{string(line), width - w}) line = str prefixLength = 0 - width = util.RuneWidth(r, prefixLength, 8) + width = w } } lines = append(lines, wrappedLine{string(line), width}) diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 938c1ba..859e670 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -5,7 +5,6 @@ package tui import ( "os" "time" - "unicode/utf8" "runtime" @@ -13,6 +12,7 @@ import ( "github.com/gdamore/tcell/encoding" "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) func HasFullscreenRenderer() bool { @@ -482,7 +482,6 @@ func (w *TcellWindow) Print(text string) { } func (w *TcellWindow) printString(text string, pair ColorPair) { - t := text lx := 0 a := pair.Attr() @@ -496,33 +495,28 @@ func (w *TcellWindow) printString(text string, pair ColorPair) { Dim(a&Attr(tcell.AttrDim) != 0) } - for { - if len(t) == 0 { - break - } - r, size := utf8.DecodeRuneInString(t) - t = t[size:] + gr := uniseg.NewGraphemes(text) + for gr.Next() { + rs := gr.Runes() - if r < rune(' ') { // ignore control characters - continue - } - - if r == '\n' { - w.lastY++ - lx = 0 - } else { - - if r == '\u000D' { // skip carriage return + if len(rs) == 1 { + r := rs[0] + if r < rune(' ') { // ignore control characters + continue + } else if r == '\n' { + w.lastY++ + lx = 0 + continue + } else if r == '\u000D' { // skip carriage return continue } - - var xPos = w.left + w.lastX + lx - var yPos = w.top + w.lastY - if xPos < (w.left+w.width) && yPos < (w.top+w.height) { - _screen.SetContent(xPos, yPos, r, nil, style) - } - lx += runewidth.RuneWidth(r) } + var xPos = w.left + w.lastX + lx + var yPos = w.top + w.lastY + if xPos < (w.left+w.width) && yPos < (w.top+w.height) { + _screen.SetContent(xPos, yPos, rs[0], rs[1:], style) + } + lx += runewidth.StringWidth(string(rs)) } w.lastX += lx } @@ -549,30 +543,32 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn { Underline(a&Attr(tcell.AttrUnderline) != 0). Italic(a&Attr(tcell.AttrItalic) != 0) - for _, r := range text { - if r == '\n' { + gr := uniseg.NewGraphemes(text) + for gr.Next() { + rs := gr.Runes() + if len(rs) == 1 && rs[0] == '\n' { w.lastY++ w.lastX = 0 lx = 0 - } else { - var xPos = w.left + w.lastX + lx - - // word wrap: - if xPos >= (w.left + w.width) { - w.lastY++ - w.lastX = 0 - lx = 0 - xPos = w.left - } - var yPos = w.top + w.lastY - - if yPos >= (w.top + w.height) { - return FillSuspend - } - - _screen.SetContent(xPos, yPos, r, nil, style) - lx += runewidth.RuneWidth(r) + continue } + + // word wrap: + xPos := w.left + w.lastX + lx + if xPos >= (w.left + w.width) { + w.lastY++ + w.lastX = 0 + lx = 0 + xPos = w.left + } + + yPos := w.top + w.lastY + if yPos >= (w.top + w.height) { + return FillSuspend + } + + _screen.SetContent(xPos, yPos, rs[0], rs[1:], style) + lx += runewidth.StringWidth(string(rs)) } w.lastX += lx if w.lastX == w.width { diff --git a/src/util/util.go b/src/util/util.go index 0aa1d80..59fb570 100644 --- a/src/util/util.go +++ b/src/util/util.go @@ -7,22 +7,29 @@ import ( "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) -var _runeWidths = make(map[rune]int) - -// RuneWidth returns rune width -func RuneWidth(r rune, prefixWidth int, tabstop int) int { - if r == '\t' { - return tabstop - prefixWidth%tabstop - } else if w, found := _runeWidths[r]; found { - return w - } else if r == '\n' || r == '\r' { - return 1 +// RunesWidth returns runes width +func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) { + width := 0 + gr := uniseg.NewGraphemes(string(runes)) + idx := 0 + for gr.Next() { + rs := gr.Runes() + var w int + if len(rs) == 1 && rs[0] == '\t' { + w = tabstop - (prefixWidth+width)%tabstop + } else { + w = runewidth.StringWidth(string(rs)) + } + width += w + if limit > 0 && width > limit { + return width, idx + } + idx += len(rs) } - w := runewidth.RuneWidth(r) - _runeWidths[r] = w - return w + return width, -1 } // Max returns the largest integer