Implement multi-line display of multi-line items

This commit is contained in:
Junegunn Choi 2024-05-20 01:33:33 +09:00
parent 5b204c54f9
commit 04db44067d
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
6 changed files with 411 additions and 187 deletions

View File

@ -162,6 +162,9 @@ the details.
.B "--cycle" .B "--cycle"
Enable cyclic scroll Enable cyclic scroll
.TP .TP
.B "--no-multi-line"
Disable multi-line display of items when using \fB--read0\fR
.TP
.B "--keep-right" .B "--keep-right"
Keep the right end of the line visible when it's too long. Effective only when Keep the right end of the line visible when it's too long. Effective only when
the query string is empty. the query string is empty.
@ -204,13 +207,19 @@ height minus the given value.
fzf --height=-1 fzf --height=-1
When prefixed with \fB~\fR, fzf will automatically determine the height in the When prefixed with \fB~\fR, fzf will automatically determine the height in the
range according to the input size. Note that adaptive height is not compatible range according to the input size.
with top/bottom margin and padding given in percent size. It is also not
compatible with a negative height value.
# Will not take up 100% of the screen # Will not take up 100% of the screen
seq 5 | fzf --height=~100% seq 5 | fzf --height=~100%
Adaptive height has the following limitations:
.br
* Cannot be used with top/bottom margin and padding given in percent size
.br
* Negative value is not allowed
.br
* It will not find the right size when there are multi-line items
.TP .TP
.BI "--min-height=" "HEIGHT" .BI "--min-height=" "HEIGHT"
Minimum height when \fB--height\fR is given in percent (default: 10). Minimum height when \fB--height\fR is given in percent (default: 10).

View File

@ -45,6 +45,7 @@ const Usage = `usage: fzf [options]
--no-mouse Disable mouse --no-mouse Disable mouse
--bind=KEYBINDS Custom key bindings. Refer to the man page. --bind=KEYBINDS Custom key bindings. Refer to the man page.
--cycle Enable cyclic scroll --cycle Enable cyclic scroll
--no-multi-line Disable multi-line display of items when using --read0
--keep-right Keep the right end of the line visible on overflow --keep-right Keep the right end of the line visible on overflow
--scroll-off=LINES Number of screen lines to keep above or below when --scroll-off=LINES Number of screen lines to keep above or below when
scrolling to the top or to the bottom (default: 0) scrolling to the top or to the bottom (default: 0)
@ -409,6 +410,7 @@ type Options struct {
MinHeight int MinHeight int
Layout layoutType Layout layoutType
Cycle bool Cycle bool
MultiLine bool
CursorLine bool CursorLine bool
KeepRight bool KeepRight bool
Hscroll bool Hscroll bool
@ -506,6 +508,7 @@ func defaultOptions() *Options {
MinHeight: 10, MinHeight: 10,
Layout: layoutDefault, Layout: layoutDefault,
Cycle: false, Cycle: false,
MultiLine: true,
KeepRight: false, KeepRight: false,
Hscroll: true, Hscroll: true,
HscrollOff: 10, HscrollOff: 10,
@ -2062,6 +2065,10 @@ func parseOptions(opts *Options, allArgs []string) error {
opts.CursorLine = false opts.CursorLine = false
case "--no-cycle": case "--no-cycle":
opts.Cycle = false opts.Cycle = false
case "--multi-line":
opts.MultiLine = true
case "--no-multi-line":
opts.MultiLine = false
case "--keep-right": case "--keep-right":
opts.KeepRight = true opts.KeepRight = true
case "--no-keep-right": case "--no-keep-right":

View File

@ -15,6 +15,7 @@ type Offset [2]int32
type colorOffset struct { type colorOffset struct {
offset [2]int32 offset [2]int32
color tui.ColorPair color tui.ColorPair
match bool
} }
type Result struct { type Result struct {
@ -109,7 +110,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, theme *tui.ColorTheme,
if len(itemColors) == 0 { if len(itemColors) == 0 {
var offsets []colorOffset var offsets []colorOffset
for _, off := range matchOffsets { for _, off := range matchOffsets {
offsets = append(offsets, colorOffset{offset: [2]int32{off[0], off[1]}, color: colMatch}) offsets = append(offsets, colorOffset{offset: [2]int32{off[0], off[1]}, color: colMatch, match: true})
} }
return offsets return offsets
} }
@ -193,12 +194,13 @@ func (result *Result) colorOffsets(matchOffsets []Offset, theme *tui.ColorTheme,
} }
} }
colors = append(colors, colorOffset{ colors = append(colors, colorOffset{
offset: [2]int32{int32(start), int32(idx)}, color: color}) offset: [2]int32{int32(start), int32(idx)}, color: color, match: true})
} else { } else {
ansi := itemColors[curr-1] ansi := itemColors[curr-1]
colors = append(colors, colorOffset{ colors = append(colors, colorOffset{
offset: [2]int32{int32(start), int32(idx)}, offset: [2]int32{int32(start), int32(idx)},
color: ansiToColorPair(ansi, colBase)}) color: ansiToColorPair(ansi, colBase),
match: false})
} }
} }
} }

View File

@ -148,14 +148,25 @@ type eachLine struct {
} }
type itemLine struct { type itemLine struct {
offset int firstLine int
current bool cy int
selected bool current bool
label string selected bool
queryLen int label string
width int queryLen int
bar bool width int
result Result hasBar bool
result Result
empty bool
other bool
}
func (t *Terminal) markEmptyLine(line int) {
t.prevLines[line] = itemLine{firstLine: line, empty: true}
}
func (t *Terminal) markOtherLine(line int) {
t.prevLines[line] = itemLine{firstLine: line, other: true}
} }
type fitpad struct { type fitpad struct {
@ -163,8 +174,6 @@ type fitpad struct {
pad int pad int
} }
var emptyLine = itemLine{}
type labelPrinter func(tui.Window, int) type labelPrinter func(tui.Window, int)
type StatusItem struct { type StatusItem struct {
@ -224,6 +233,7 @@ type Terminal struct {
yanked []rune yanked []rune
input []rune input []rune
multi int multi int
multiLine bool
sort bool sort bool
toggleSort bool toggleSort bool
track trackOption track trackOption
@ -739,6 +749,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
yanked: []rune{}, yanked: []rune{},
input: input, input: input,
multi: opts.Multi, multi: opts.Multi,
multiLine: opts.ReadZero && opts.MultiLine,
sort: opts.Sort > 0, sort: opts.Sort > 0,
toggleSort: opts.ToggleSort, toggleSort: opts.ToggleSort,
track: opts.Track, track: opts.Track,
@ -1009,8 +1020,9 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
} }
} }
output := func() { output := func() {
line := t.promptLine()
t.printHighlighted( t.printHighlighted(
Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false) Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, line, line, true, nil, nil)
} }
_, promptLen := t.processTabs([]rune(trimmed), 0) _, promptLen := t.processTabs([]rune(trimmed), 0)
@ -1031,22 +1043,45 @@ func (t *Terminal) noSeparatorLine() bool {
return noSeparatorLine(t.infoStyle, t.separatorLen > 0) return noSeparatorLine(t.infoStyle, t.separatorLen > 0)
} }
func getScrollbar(total int, height int, offset int) (int, int) { func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
if total == 0 || total <= height { if total == 0 || total*perLine <= height {
return 0, 0 return 0, 0
} }
barLength := util.Max(1, height*height/total) barLength := util.Max(1, height*height/(total*perLine))
var barStart int var barStart int
if total == height { if total == height {
barStart = 0 barStart = 0
} else { } else {
barStart = (height - barLength) * offset / (total - height) barStart = util.Min(height-barLength, (height*perLine-barLength)*offset/(total*perLine-height))
} }
return barLength, barStart return barLength, barStart
} }
// Estimate the average number of lines per item. Instead of going through all
// items, we only check a few items around the current cursor position.
func (t *Terminal) avgNumLines() int {
if !t.multiLine {
return 1
}
maxItems := t.maxItems()
numLines := 0
count := 0
total := t.merger.Length()
offset := util.Max(0, util.Min(t.offset, total-maxItems-1))
for idx := 0; idx < maxItems && idx+offset < total; idx++ {
item := t.merger.Get(idx + offset)
numLines += item.item.text.NumLines(maxItems)
count++
}
if count == 0 {
return 1
}
return numLines / count
}
func (t *Terminal) getScrollbar() (int, int) { func (t *Terminal) getScrollbar() (int, int) {
return getScrollbar(t.merger.Length(), t.maxItems(), t.offset) return getScrollbar(t.avgNumLines(), t.merger.Length(), t.maxItems(), t.offset)
} }
// Input returns current query string // Input returns current query string
@ -1622,7 +1657,6 @@ func (t *Terminal) placeCursor() {
} }
func (t *Terminal) printPrompt() { func (t *Terminal) printPrompt() {
t.move(t.promptLine(), 0, true)
t.prompt() t.prompt()
before, after := t.updatePromptOffset() before, after := t.updatePromptOffset()
@ -1645,6 +1679,10 @@ func (t *Terminal) trimMessage(message string, maxWidth int) string {
func (t *Terminal) printInfo() { func (t *Terminal) printInfo() {
pos := 0 pos := 0
line := t.promptLine() line := t.promptLine()
move := func(y int, x int, clear bool) {
t.move(y, x, clear)
t.markOtherLine(y)
}
printSpinner := func() { printSpinner := func() {
if t.reading { if t.reading {
duration := int64(spinnerDuration) duration := int64(spinnerDuration)
@ -1663,7 +1701,7 @@ func (t *Terminal) printInfo() {
str = string(trimmed) str = string(trimmed)
width = maxWidth width = maxWidth
} }
t.move(line, pos, t.separatorLen == 0) move(line, pos, t.separatorLen == 0)
if t.reading { if t.reading {
t.window.CPrint(tui.ColSpinner, str) t.window.CPrint(tui.ColSpinner, str)
} else { } else {
@ -1672,7 +1710,6 @@ func (t *Terminal) printInfo() {
pos += width pos += width
} }
printSeparator := func(fillLength int, pad bool) { printSeparator := func(fillLength int, pad bool) {
// --------_
if t.separatorLen > 0 { if t.separatorLen > 0 {
t.separator(t.window, fillLength) t.separator(t.window, fillLength)
t.window.Print(" ") t.window.Print(" ")
@ -1682,12 +1719,12 @@ func (t *Terminal) printInfo() {
} }
switch t.infoStyle { switch t.infoStyle {
case infoDefault: case infoDefault:
t.move(line+1, 0, t.separatorLen == 0) move(line+1, 0, t.separatorLen == 0)
printSpinner() printSpinner()
t.window.Print(" ") // Margin t.window.Print(" ") // Margin
pos = 2 pos = 2
case infoRight: case infoRight:
t.move(line+1, 0, false) move(line+1, 0, false)
case infoInlineRight: case infoInlineRight:
pos = t.promptLen + t.queryLen[0] + t.queryLen[1] + 1 pos = t.promptLen + t.queryLen[0] + t.queryLen[1] + 1
case infoInline: case infoInline:
@ -1695,7 +1732,7 @@ func (t *Terminal) printInfo() {
printInfoPrefix() printInfoPrefix()
case infoHidden: case infoHidden:
if t.separatorLen > 0 { if t.separatorLen > 0 {
t.move(line+1, 0, false) move(line+1, 0, false)
printSeparator(t.window.Width()-1, false) printSeparator(t.window.Width()-1, false)
} }
return return
@ -1755,7 +1792,7 @@ func (t *Terminal) printInfo() {
if t.infoStyle == infoInlineRight { if t.infoStyle == infoInlineRight {
if len(t.infoPrefix) == 0 { if len(t.infoPrefix) == 0 {
t.move(line, pos, false) move(line, pos, false)
newPos := util.Max(pos, t.window.Width()-util.StringWidth(output)-3) newPos := util.Max(pos, t.window.Width()-util.StringWidth(output)-3)
t.window.Print(strings.Repeat(" ", newPos-pos)) t.window.Print(strings.Repeat(" ", newPos-pos))
pos = newPos pos = newPos
@ -1779,7 +1816,7 @@ func (t *Terminal) printInfo() {
if t.infoStyle == infoInlineRight { if t.infoStyle == infoInlineRight {
if t.separatorLen > 0 { if t.separatorLen > 0 {
t.move(line+1, 0, false) move(line+1, 0, false)
printSeparator(t.window.Width()-1, false) printSeparator(t.window.Width()-1, false)
} }
return return
@ -1829,10 +1866,9 @@ func (t *Terminal) printHeader() {
text: util.ToChars([]byte(trimmed)), text: util.ToChars([]byte(trimmed)),
colors: colors} colors: colors}
t.move(line, 0, true)
t.window.Print(" ")
t.printHighlighted(Result{item: item}, t.printHighlighted(Result{item: item},
tui.ColHeader, tui.ColHeader, false, false) tui.ColHeader, tui.ColHeader, false, false, line, line, true,
func(int) { t.window.Print(" ") }, nil)
} }
} }
@ -1840,53 +1876,66 @@ func (t *Terminal) printList() {
t.constrain() t.constrain()
barLength, barStart := t.getScrollbar() barLength, barStart := t.getScrollbar()
maxy := t.maxItems() maxy := t.maxItems() - 1
count := t.merger.Length() - t.offset count := t.merger.Length() - t.offset
for j := 0; j < maxy; j++ {
i := j // Start line
if t.layout == layoutDefault { startLine := 2 + t.visibleHeaderLines()
i = maxy - 1 - j if t.noSeparatorLine() {
} startLine--
line := i + 2 + t.visibleHeaderLines() }
if t.noSeparatorLine() { maxy += startLine
line--
} barRange := [2]int{startLine + barStart, startLine + barStart + barLength}
if i < count { for line, itemCount := startLine, 0; line <= maxy; line, itemCount = line+1, itemCount+1 {
t.printItem(t.merger.Get(i+t.offset), line, i, i == t.cy-t.offset, i >= barStart && i < barStart+barLength) if itemCount < count {
} else if t.prevLines[i] != emptyLine || t.prevLines[i].offset != line { item := t.merger.Get(itemCount + t.offset)
t.prevLines[i] = emptyLine line = t.printItem(item, line, maxy, itemCount, itemCount == t.cy-t.offset, barRange)
} else if !t.prevLines[line].empty {
t.move(line, 0, true) t.move(line, 0, true)
t.markEmptyLine(line)
// If the screen is not filled with the list in non-multi-line mode,
// scrollbar is not visible at all. But in multi-line mode, we may need
// to redraw the scrollbar character at the end.
if t.multiLine {
t.prevLines[line].hasBar = t.printBar(line, true, barRange)
}
} }
} }
} }
func (t *Terminal) printItem(result Result, line int, i int, current bool, bar bool) { func (t *Terminal) printBar(lineNum int, forceRedraw bool, barRange [2]int) bool {
hasBar := lineNum >= barRange[0] && lineNum < barRange[1]
if len(t.scrollbar) > 0 && (hasBar != t.prevLines[lineNum].hasBar || forceRedraw) {
t.move(lineNum, t.window.Width()-1, true)
if hasBar {
t.window.CPrint(tui.ColScrollbar, t.scrollbar)
}
}
return hasBar
}
func (t *Terminal) printItem(result Result, line int, maxLine int, index int, current bool, barRange [2]int) int {
item := result.item item := result.item
_, selected := t.selected[item.Index()] _, selected := t.selected[item.Index()]
label := "" label := ""
if t.jumping != jumpDisabled { if t.jumping != jumpDisabled {
if i < len(t.jumpLabels) { if index < len(t.jumpLabels) {
// Striped // Striped
current = i%2 == 0 current = index%2 == 0
label = t.jumpLabels[i:i+1] + strings.Repeat(" ", t.pointerLen-1) label = t.jumpLabels[index:index+1] + strings.Repeat(" ", t.pointerLen-1)
} }
} else if current { } else if current {
label = t.pointer label = t.pointer
} }
// Avoid unnecessary redraw // Avoid unnecessary redraw
newLine := itemLine{offset: line, current: current, selected: selected, label: label, newLine := itemLine{firstLine: line, cy: index + t.offset, current: current, selected: selected, label: label,
result: result, queryLen: len(t.input), width: 0, bar: bar} result: result, queryLen: len(t.input), width: 0, hasBar: line >= barRange[0] && line < barRange[1]}
prevLine := t.prevLines[i] prevLine := t.prevLines[line]
forceRedraw := prevLine.offset != newLine.offset forceRedraw := prevLine.other || prevLine.firstLine != newLine.firstLine
printBar := func() { printBar := func(lineNum int, forceRedraw bool) bool {
if len(t.scrollbar) > 0 && (bar != prevLine.bar || forceRedraw) { return t.printBar(lineNum, forceRedraw, barRange)
t.prevLines[i].bar = bar
t.move(line, t.window.Width()-1, true)
if bar {
t.window.CPrint(tui.ColScrollbar, t.scrollbar)
}
}
} }
if !forceRedraw && if !forceRedraw &&
@ -1895,33 +1944,64 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool, bar b
prevLine.label == newLine.label && prevLine.label == newLine.label &&
prevLine.queryLen == newLine.queryLen && prevLine.queryLen == newLine.queryLen &&
prevLine.result == newLine.result { prevLine.result == newLine.result {
printBar() t.prevLines[line].hasBar = printBar(line, false)
return if !t.multiLine {
return line
}
return line + item.text.NumLines(maxLine-line+1) - 1
} }
t.move(line, 0, forceRedraw) maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
postTask := func(lineNum int, width int) {
if (current || selected) && t.highlightLine {
color := tui.ColSelected
if current {
color = tui.ColCurrent
}
fillSpaces := maxWidth - width
if fillSpaces > 0 {
t.window.CPrint(color, strings.Repeat(" ", fillSpaces))
}
newLine.width = maxWidth
} else {
fillSpaces := t.prevLines[lineNum].width - width
if fillSpaces > 0 {
t.window.Print(strings.Repeat(" ", fillSpaces))
}
newLine.width = width
}
// When width is 0, line is completely cleared. We need to redraw scrollbar
newLine.hasBar = printBar(lineNum, forceRedraw || width == 0)
t.prevLines[lineNum] = newLine
}
var finalLineNum int
if current { if current {
if len(label) == 0 { preTask := func(lineOffset int) {
t.window.CPrint(tui.ColCurrentCursorEmpty, t.pointerEmpty) if len(label) == 0 {
} else { t.window.CPrint(tui.ColCurrentCursorEmpty, t.pointerEmpty)
t.window.CPrint(tui.ColCurrentCursor, label) } else {
t.window.CPrint(tui.ColCurrentCursor, label)
}
if selected {
t.window.CPrint(tui.ColCurrentMarker, t.marker)
} else {
t.window.CPrint(tui.ColCurrentSelectedEmpty, t.markerEmpty)
}
} }
if selected { finalLineNum = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true, line, maxLine, forceRedraw, preTask, postTask)
t.window.CPrint(tui.ColCurrentMarker, t.marker)
} else {
t.window.CPrint(tui.ColCurrentSelectedEmpty, t.markerEmpty)
}
newLine.width = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true)
} else { } else {
if len(label) == 0 { preTask := func(lineOffset int) {
t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty) if len(label) == 0 {
} else { t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty)
t.window.CPrint(tui.ColCursor, label) } else {
} t.window.CPrint(tui.ColCursor, label)
if selected { }
t.window.CPrint(tui.ColMarker, t.marker) if selected {
} else { t.window.CPrint(tui.ColMarker, t.marker)
t.window.Print(t.markerEmpty) } else {
t.window.Print(t.markerEmpty)
}
} }
var base, match tui.ColorPair var base, match tui.ColorPair
if selected { if selected {
@ -1931,27 +2011,9 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool, bar b
base = tui.ColNormal base = tui.ColNormal
match = tui.ColMatch match = tui.ColMatch
} }
newLine.width = t.printHighlighted(result, base, match, false, true) finalLineNum = t.printHighlighted(result, base, match, false, true, line, maxLine, forceRedraw, preTask, postTask)
} }
if (current || selected) && t.highlightLine { return finalLineNum
color := tui.ColSelected
if current {
color = tui.ColCurrent
}
maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
fillSpaces := maxWidth - newLine.width
newLine.width = maxWidth
if fillSpaces > 0 {
t.window.CPrint(color, strings.Repeat(" ", fillSpaces))
}
} else {
fillSpaces := prevLine.width - newLine.width
if fillSpaces > 0 {
t.window.Print(strings.Repeat(" ", fillSpaces))
}
}
printBar()
t.prevLines[i] = newLine
} }
func (t *Terminal) trimRight(runes []rune, width int) ([]rune, bool) { func (t *Terminal) trimRight(runes []rune, width int) ([]rune, bool) {
@ -1992,12 +2054,9 @@ func (t *Terminal) overflow(runes []rune, max int) bool {
return t.displayWidthWithLimit(runes, 0, max) > max return t.displayWidthWithLimit(runes, 0, max) > max
} }
func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMatch tui.ColorPair, current bool, match bool) int { func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMatch tui.ColorPair, current bool, match bool, lineNum int, maxLineNum int, forceRedraw bool, preTask func(int), postTask func(int, int)) int {
var displayWidth int
item := result.item item := result.item
// Overflow
text := make([]rune, item.text.Length())
copy(text, item.text.ToRunes())
matchOffsets := []Offset{} matchOffsets := []Offset{}
var pos *[]int var pos *[]int
if match && t.merger.pattern != nil { if match && t.merger.pattern != nil {
@ -2012,69 +2071,138 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
} }
sort.Sort(ByOrder(charOffsets)) sort.Sort(ByOrder(charOffsets))
} }
var maxe int allOffsets := result.colorOffsets(charOffsets, t.theme, colBase, colMatch, current)
for _, offset := range charOffsets {
maxe = util.Max(maxe, int(offset[1]))
}
offsets := result.colorOffsets(charOffsets, t.theme, colBase, colMatch, current) from := 0
maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1) text := make([]rune, item.text.Length())
ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2) copy(text, item.text.ToRunes())
maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(text))
displayWidth := t.displayWidthWithLimit(text, 0, maxWidth) finalLineNum := lineNum
if displayWidth > maxWidth { numItemLines := 1
transformOffsets := func(diff int32, rightTrim bool) { cutoff := 0
for idx, offset := range offsets { if t.multiLine && t.layout == layoutDefault {
b, e := offset.offset[0], offset.offset[1] maxLines := maxLineNum - lineNum + 1
el := int32(len(ellipsis)) numItemLines = item.text.NumLines(maxLines)
b += el - diff // Cut off the upper lines in the 'default' layout
e += el - diff if !current && maxLines == numItemLines {
b = util.Max32(b, el) cutoff = item.text.NumLines(math.MaxInt32) - maxLines
if rightTrim { }
e = util.Min32(e, int32(maxWidth-ellipsisWidth)) }
for lineOffset := 0; from <= len(text) && (lineNum <= maxLineNum || maxLineNum == 0); lineOffset++ {
finalLineNum = lineNum
line := text[from:]
if t.multiLine {
for idx, r := range text[from:] {
if r == '\n' {
line = line[:idx]
break
} }
offsets[idx].offset[0] = b
offsets[idx].offset[1] = util.Max32(b, e)
} }
} }
if t.hscroll {
if t.keepRight && pos == nil { offsets := []colorOffset{}
trimmed, diff := t.trimLeft(text, maxWidth-ellipsisWidth) for _, offset := range allOffsets {
transformOffsets(diff, false) if offset.offset[0] >= int32(from) && offset.offset[1] <= int32(from+len(line)) {
text = append(ellipsis, trimmed...) offset.offset[0] -= int32(from)
} else if !t.overflow(text[:maxe], maxWidth-ellipsisWidth) { offset.offset[1] -= int32(from)
// Stri.. offsets = append(offsets, offset)
text, _ = t.trimRight(text, maxWidth-ellipsisWidth)
text = append(text, ellipsis...)
} else { } else {
// Stri.. allOffsets = allOffsets[len(offsets):]
rightTrim := false break
if t.overflow(text[maxe:], ellipsisWidth) {
text = append(text[:maxe], ellipsis...)
rightTrim = true
}
// ..ri..
var diff int32
text, diff = t.trimLeft(text, maxWidth-ellipsisWidth)
// Transform offsets
transformOffsets(diff, rightTrim)
text = append(ellipsis, text...)
}
} else {
text, _ = t.trimRight(text, maxWidth-ellipsisWidth)
text = append(text, ellipsis...)
for idx, offset := range offsets {
offsets[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-len(ellipsis)))
offsets[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth))
} }
} }
displayWidth = t.displayWidthWithLimit(text, 0, displayWidth)
from += len(line) + 1
if cutoff > 0 {
cutoff--
lineOffset--
continue
}
var maxe int
for _, offset := range offsets {
if offset.match {
maxe = util.Max(maxe, int(offset.offset[1]))
}
}
actualLineNum := lineNum
if t.layout == layoutDefault {
actualLineNum = (lineNum - lineOffset) + (numItemLines - lineOffset) - 1
}
t.move(actualLineNum, 0, forceRedraw)
if preTask != nil {
preTask(lineOffset)
}
maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
ellipsis, ellipsisWidth := util.Truncate(t.ellipsis, maxWidth/2)
maxe = util.Constrain(maxe+util.Min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(line))
displayWidth = t.displayWidthWithLimit(line, 0, maxWidth)
if displayWidth > maxWidth {
transformOffsets := func(diff int32, rightTrim bool) {
for idx, offset := range offsets {
b, e := offset.offset[0], offset.offset[1]
el := int32(len(ellipsis))
b += el - diff
e += el - diff
b = util.Max32(b, el)
if rightTrim {
e = util.Min32(e, int32(maxWidth-ellipsisWidth))
}
offsets[idx].offset[0] = b
offsets[idx].offset[1] = util.Max32(b, e)
}
}
if t.hscroll {
if t.keepRight && pos == nil {
trimmed, diff := t.trimLeft(line, maxWidth-ellipsisWidth)
transformOffsets(diff, false)
line = append(ellipsis, trimmed...)
} else if !t.overflow(line[:maxe], maxWidth-ellipsisWidth) {
// Stri..
line, _ = t.trimRight(line, maxWidth-ellipsisWidth)
line = append(line, ellipsis...)
} else {
// Stri..
rightTrim := false
if t.overflow(line[maxe:], ellipsisWidth) {
line = append(line[:maxe], ellipsis...)
rightTrim = true
}
// ..ri..
var diff int32
line, diff = t.trimLeft(line, maxWidth-ellipsisWidth)
// Transform offsets
transformOffsets(diff, rightTrim)
line = append(ellipsis, line...)
}
} else {
line, _ = t.trimRight(line, maxWidth-ellipsisWidth)
line = append(line, ellipsis...)
for idx, offset := range offsets {
offsets[idx].offset[0] = util.Min32(offset.offset[0], int32(maxWidth-len(ellipsis)))
offsets[idx].offset[1] = util.Min32(offset.offset[1], int32(maxWidth))
}
}
displayWidth = t.displayWidthWithLimit(line, 0, displayWidth)
}
t.printColoredString(t.window, line, offsets, colBase)
if postTask != nil {
postTask(actualLineNum, displayWidth)
} else {
t.markOtherLine(actualLineNum)
}
lineNum += 1
} }
t.printColoredString(t.window, text, offsets, colBase) return finalLineNum
return displayWidth
} }
func (t *Terminal) printColoredString(window tui.Window, text []rune, offsets []colorOffset, colBase tui.ColorPair) { func (t *Terminal) printColoredString(window tui.Window, text []rune, offsets []colorOffset, colBase tui.ColorPair) {
@ -2172,7 +2300,7 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
} }
effectiveHeight := height - headerLines effectiveHeight := height - headerLines
barLength, barStart := getScrollbar(len(body), effectiveHeight, util.Min(len(body)-effectiveHeight, t.previewer.offset-headerLines)) barLength, barStart := getScrollbar(1, len(body), effectiveHeight, util.Min(len(body)-effectiveHeight, t.previewer.offset-headerLines))
t.renderPreviewScrollbar(headerLines, barLength, barStart) t.renderPreviewScrollbar(headerLines, barLength, barStart)
} }
@ -4022,7 +4150,7 @@ func (t *Terminal) Loop() error {
if pbarDragging { if pbarDragging {
effectiveHeight := t.pwindow.Height() - headerLines effectiveHeight := t.pwindow.Height() - headerLines
numLines := len(t.previewer.lines) - headerLines numLines := len(t.previewer.lines) - headerLines
barLength, _ := getScrollbar(numLines, effectiveHeight, util.Min(numLines-effectiveHeight, t.previewer.offset-headerLines)) barLength, _ := getScrollbar(1, numLines, effectiveHeight, util.Min(numLines-effectiveHeight, t.previewer.offset-headerLines))
if barLength > 0 { if barLength > 0 {
y := my - t.pwindow.Top() - headerLines - barLength/2 y := my - t.pwindow.Top() - headerLines - barLength/2
y = util.Constrain(y, 0, effectiveHeight-barLength) y = util.Constrain(y, 0, effectiveHeight-barLength)
@ -4068,7 +4196,8 @@ func (t *Terminal) Loop() error {
total := t.merger.Length() total := t.merger.Length()
prevOffset := t.offset prevOffset := t.offset
// barStart = (maxItems - barLength) * t.offset / (total - maxItems) // barStart = (maxItems - barLength) * t.offset / (total - maxItems)
t.offset = int(math.Ceil(float64(newBarStart) * float64(total-maxItems) / float64(maxItems-barLength))) perLine := t.avgNumLines()
t.offset = int(math.Ceil(float64(newBarStart) * float64(total*perLine-maxItems) / float64(maxItems*perLine-barLength)))
t.cy = t.offset + t.cy - prevOffset t.cy = t.offset + t.cy - prevOffset
req(reqList) req(reqList)
} }
@ -4076,11 +4205,18 @@ func (t *Terminal) Loop() error {
break break
} }
// There can be empty lines after the list in multi-line mode
prevLine := t.prevLines[my]
if prevLine.empty {
break
}
// Double-click on an item // Double-click on an item
cy := prevLine.cy
if me.Double && mx < t.window.Width()-1 { if me.Double && mx < t.window.Width()-1 {
// Double-click // Double-click
if my >= min { if my >= min {
if t.vset(t.offset+my-min) && t.cy < t.merger.Length() { if t.vset(cy) && t.cy < t.merger.Length() {
return doActions(actionsFor(tui.DoubleClick)) return doActions(actionsFor(tui.DoubleClick))
} }
} }
@ -4093,7 +4229,7 @@ func (t *Terminal) Loop() error {
// Prompt // Prompt
t.cx = mxCons + t.xoffset t.cx = mxCons + t.xoffset
} else if my >= min { } else if my >= min {
t.vset(t.offset + my - min) t.vset(cy)
req(reqList) req(reqList)
evt := tui.RightClick evt := tui.RightClick
if me.Mod { if me.Mod {
@ -4312,26 +4448,66 @@ func (t *Terminal) Loop() error {
func (t *Terminal) constrain() { func (t *Terminal) constrain() {
// count of items to display allowed by filtering // count of items to display allowed by filtering
count := t.merger.Length() count := t.merger.Length()
// count of lines can be displayed maxItems := t.maxItems()
height := t.maxItems()
t.cy = util.Constrain(t.cy, 0, util.Max(0, count-1)) // May need to try again after adjusting the offset
for tries := 0; tries < maxItems; tries++ {
height := maxItems
// How many items can be fit on screen including the current item?
if t.multiLine && t.merger.Length() > 0 {
actualHeight := 0
linesSum := 0
minOffset := util.Max(t.cy-height+1, 0) add := func(i int) bool {
maxOffset := util.Max(util.Min(count-height, t.cy), 0) lines := t.merger.Get(i).item.text.NumLines(height - linesSum)
t.offset = util.Constrain(t.offset, minOffset, maxOffset) linesSum += lines
if t.scrollOff == 0 { if linesSum >= height {
return if actualHeight == 0 {
} actualHeight = 1
}
return false
}
actualHeight++
return true
}
scrollOff := util.Min(height/2, t.scrollOff) for i := t.offset; i < t.merger.Length(); i++ {
for { if !add(i) {
prevOffset := t.offset break
if t.cy-t.offset < scrollOff { }
t.offset = util.Max(minOffset, t.offset-1) }
// We can possibly fit more items "before" the offset on screen
if linesSum < height {
for i := t.offset - 1; i >= 0; i-- {
if !add(i) {
break
}
}
}
height = actualHeight
} }
if t.cy-t.offset >= height-scrollOff {
t.offset = util.Min(maxOffset, t.offset+1) t.cy = util.Constrain(t.cy, 0, util.Max(0, count-1))
minOffset := util.Max(t.cy-height+1, 0)
maxOffset := util.Max(util.Min(count-height, t.cy), 0)
prevOffset := t.offset
t.offset = util.Constrain(t.offset, minOffset, maxOffset)
if t.scrollOff > 0 {
scrollOff := util.Min(height/2, t.scrollOff)
for {
prevOffset := t.offset
if t.cy-t.offset < scrollOff {
t.offset = util.Max(minOffset, t.offset-1)
}
if t.cy-t.offset >= height-scrollOff {
t.offset = util.Min(maxOffset, t.offset+1)
}
if t.offset == prevOffset {
break
}
}
} }
if t.offset == prevOffset { if t.offset == prevOffset {
break break

View File

@ -1,6 +1,7 @@
package util package util
import ( import (
"bytes"
"fmt" "fmt"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
@ -74,6 +75,35 @@ func (chars *Chars) Bytes() []byte {
return chars.slice return chars.slice
} }
func (chars *Chars) NumLines(atMost int) int {
lines := 1
if runes := chars.optionalRunes(); runes != nil {
for _, r := range runes {
if r == '\n' {
lines++
}
if lines >= atMost {
return atMost
}
}
return lines
}
for idx := 0; idx < len(chars.slice); idx++ {
found := bytes.IndexByte(chars.slice[idx:], '\n')
if found < 0 {
break
}
idx += found
lines++
if lines >= atMost {
return atMost
}
}
return lines
}
func (chars *Chars) optionalRunes() []rune { func (chars *Chars) optionalRunes() []rune {
if chars.inBytes { if chars.inBytes {
return nil return nil

View File

@ -2128,7 +2128,7 @@ class TestGoFZF < TestBase
end end
def test_keep_right def test_keep_right
tmux.send_keys "seq 10000 | #{FZF} --read0 --keep-right", :Enter tmux.send_keys "seq 10000 | #{FZF} --read0 --keep-right --no-multi-line", :Enter
tmux.until { |lines| assert lines.any_include?('9999␊10000') } tmux.until { |lines| assert lines.any_include?('9999␊10000') }
end end
@ -3398,7 +3398,7 @@ module TestShell
def test_ctrl_r_multiline def test_ctrl_r_multiline
# NOTE: Current bash implementation shows an extra new line if there's # NOTE: Current bash implementation shows an extra new line if there's
# only enty in the history # only entry in the history
tmux.send_keys ':', :Enter tmux.send_keys ':', :Enter
tmux.send_keys 'echo "foo', :Enter, 'bar"', :Enter tmux.send_keys 'echo "foo', :Enter, 'bar"', :Enter
tmux.until { |lines| assert_equal %w[foo bar], lines[-2..] } tmux.until { |lines| assert_equal %w[foo bar], lines[-2..] }