From 3f75a8369f63f2bd6ac3686fc5d88f2bc128e610 Mon Sep 17 00:00:00 2001
From: Junegunn Choi <junegunn.c@gmail.com>
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 a5a3a6ba..a28d6f8e 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 a136b859..dc40fdc7 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 54f96675..aabeb07c 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 91b4c18e..d3e3faba 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 938c1ba0..859e6709 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 0aa1d804..59fb5708 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