Fix coloring of items with zero-width characters
Some checks failed
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled

This commit fixes incorrect coloring for items that contain zero-width
characters. It also makes ellipsis coloring consistent when text is
trimmed from either the left or the right.

Fix #4620
Close #4646
This commit is contained in:
Junegunn Choi
2026-01-31 13:25:50 +09:00
parent e653628458
commit 6360c9261c
3 changed files with 93 additions and 33 deletions

View File

@@ -3475,20 +3475,74 @@ func (t *Terminal) displayWidthWithLimit(runes []rune, prefixWidth int, limit in
func (t *Terminal) trimLeft(runes []rune, width int, ellipsisWidth int) ([]rune, int32) {
width = max(0, width)
var trimmed int32
// Assume that each rune takes at least one column on screen
if len(runes) > width {
diff := len(runes) - width
trimmed = int32(diff)
runes = runes[diff:]
str := string(runes)
runningSum := 0
runningSumAdjusted := 0
// We can't just subtract the width on each segment because there might be
// a tab character afterwards. For example, with the tabstop = 8:
// 1234____5678
// 234_____5678
// 34______5678
// 4_______5678
// ________5678
// 5678
// 678
// 78
// 8
// We need to look ahead, but not to the end to avoid performance hit.
type queuedSegment struct {
rs []rune
w int
}
allQueue := []queuedSegment{}
queuedWidth := 0
limit := width - ellipsisWidth
processQueue := func() {
for idx, item := range allQueue {
if runningSumAdjusted <= limit {
allQueue = allQueue[idx:]
return
}
runningSumAdjusted -= item.w
runes = runes[len(item.rs):]
trimmed += int32(len(item.rs))
}
allQueue = []queuedSegment{}
}
currentWidth := t.displayWidth(runes)
gr := uniseg.NewGraphemes(str)
queue := []queuedSegment{}
for gr.Next() {
s := gr.Str()
rs := gr.Runes()
for currentWidth > width-ellipsisWidth && len(runes) > 0 {
runes = runes[1:]
trimmed++
currentWidth = t.displayWidthWithLimit(runes, ellipsisWidth, width)
var w int
if len(rs) == 1 && rs[0] == '\t' {
w = t.tabstop - runningSum%t.tabstop
} else {
w = util.StringWidth(string(rs))
}
runningSum += w
runningSumAdjusted += w
queue = append(queue, queuedSegment{rs: rs, w: w})
queuedWidth += w
if queuedWidth >= t.tabstop || s == "\t" {
queuedWidth = 0
if s == "\t" {
queue[len(queue)-1].w = t.tabstop
for idx := range queue[:len(queue)-1] {
queue[idx].w = 0
}
}
allQueue = append(allQueue, queue...)
queue = []queuedSegment{}
processQueue()
}
}
allQueue = append(allQueue, queue...)
processQueue()
return runes, trimmed
}
@@ -3506,9 +3560,16 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
}
charOffsets := matchOffsets
if pos != nil {
runes := item.text.ToRunes()
charOffsets = make([]Offset, len(*pos))
for idx, p := range *pos {
offset := Offset{int32(p), int32(p + 1)}
gr := uniseg.NewGraphemes(string(runes[p:]))
w := 1
for gr.Next() {
w = len(gr.Runes())
break
}
offset := Offset{int32(p), int32(p + w)}
charOffsets[idx] = offset
}
sort.Sort(ByOrder(charOffsets))
@@ -3767,24 +3828,16 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
displayWidth = t.displayWidthWithLimit(runes, 0, adjustedMaxWidth)
if !t.wrap && displayWidth > adjustedMaxWidth {
maxe = util.Constrain(maxe+min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(runes))
transformOffsets := func(diff int32, rightTrim bool) {
for idx, offset := range offs {
b, e := offset.offset[0], offset.offset[1]
el := int32(len(ellipsis))
b += el - diff
e += el - diff
b = max(b, el)
if rightTrim {
e = min(e, int32(maxWidth-ellipsisWidth))
}
offs[idx].offset[0] = b
offs[idx].offset[1] = max(b, e)
transformOffsets := func(diff int32) {
for idx := range offs {
offs[idx].offset[0] -= diff
offs[idx].offset[1] -= diff
}
}
if t.hscroll {
if fidx == 1 || fidx == 2 && t.keepRight && pos == nil {
trimmed, diff := t.trimLeft(runes, maxWidth, ellipsisWidth)
transformOffsets(diff, false)
transformOffsets(diff - int32(len(ellipsis)))
runes = append(ellipsis, trimmed...)
} else if fidx == 0 || !t.overflow(runes[:maxe], maxWidth-ellipsisWidth) {
// Stri..
@@ -3792,27 +3845,20 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
runes = append(runes, ellipsis...)
} else {
// Stri..
rightTrim := false
if t.overflow(runes[maxe:], ellipsisWidth) {
runes = append(runes[:maxe], ellipsis...)
rightTrim = true
}
// ..ri..
var diff int32
runes, diff = t.trimLeft(runes, maxWidth, ellipsisWidth)
// Transform offsets
transformOffsets(diff, rightTrim)
transformOffsets(diff - int32(len(ellipsis)))
runes = append(ellipsis, runes...)
}
} else {
runes, _ = t.trimRight(runes, maxWidth-ellipsisWidth)
runes = append(runes, ellipsis...)
for idx, offset := range offs {
offs[idx].offset[0] = min(offset.offset[0], int32(maxWidth-len(ellipsis)))
offs[idx].offset[1] = min(offset.offset[1], int32(maxWidth))
}
}
displayWidth = t.displayWidthWithLimit(runes, 0, maxWidth)
}

View File

@@ -18,8 +18,13 @@ func StringWidth(s string) int {
// RunesWidth returns runes width
func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) {
return StringsWidth(string(runes), prefixWidth, tabstop, limit)
}
// StringsWidth returns the width of the string
func StringsWidth(str string, prefixWidth int, tabstop int, limit int) (int, int) {
width := 0
gr := uniseg.NewGraphemes(string(runes))
gr := uniseg.NewGraphemes(str)
idx := 0
for gr.Next() {
rs := gr.Runes()

View File

@@ -2175,4 +2175,13 @@ class TestCore < TestInteractive
assert_equal 1, it.match_count
end
end
def test_zero_width_characters
tmux.send_keys %(for i in {1..1000}; do string+="a̱$i"; printf '\\e[43m%s\\e[0m\\n' "$string"; done | #{FZF} --ansi --query a500 --ellipsis XX), :Enter
tmux.until do |lines|
assert_equal 981, lines.match_count
assert_match(/^> XX.*a̱500/, lines[-3])
assert(lines.reverse.drop(5).all? { it.match?(/^ XX.*a̱500.*XX/) })
end
end
end