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"
Enable cyclic scroll
.TP
.B "--no-multi-line"
Disable multi-line display of items when using \fB--read0\fR
.TP
.B "--keep-right"
Keep the right end of the line visible when it's too long. Effective only when
the query string is empty.
@ -204,13 +207,19 @@ height minus the given value.
fzf --height=-1
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
with top/bottom margin and padding given in percent size. It is also not
compatible with a negative height value.
range according to the input size.
# Will not take up 100% of the screen
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
.BI "--min-height=" "HEIGHT"
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
--bind=KEYBINDS Custom key bindings. Refer to the man page.
--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
--scroll-off=LINES Number of screen lines to keep above or below when
scrolling to the top or to the bottom (default: 0)
@ -409,6 +410,7 @@ type Options struct {
MinHeight int
Layout layoutType
Cycle bool
MultiLine bool
CursorLine bool
KeepRight bool
Hscroll bool
@ -506,6 +508,7 @@ func defaultOptions() *Options {
MinHeight: 10,
Layout: layoutDefault,
Cycle: false,
MultiLine: true,
KeepRight: false,
Hscroll: true,
HscrollOff: 10,
@ -2062,6 +2065,10 @@ func parseOptions(opts *Options, allArgs []string) error {
opts.CursorLine = false
case "--no-cycle":
opts.Cycle = false
case "--multi-line":
opts.MultiLine = true
case "--no-multi-line":
opts.MultiLine = false
case "--keep-right":
opts.KeepRight = true
case "--no-keep-right":

View File

@ -15,6 +15,7 @@ type Offset [2]int32
type colorOffset struct {
offset [2]int32
color tui.ColorPair
match bool
}
type Result struct {
@ -109,7 +110,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, theme *tui.ColorTheme,
if len(itemColors) == 0 {
var offsets []colorOffset
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
}
@ -193,12 +194,13 @@ func (result *Result) colorOffsets(matchOffsets []Offset, theme *tui.ColorTheme,
}
}
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 {
ansi := itemColors[curr-1]
colors = append(colors, colorOffset{
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 {
offset int
firstLine int
cy int
current bool
selected bool
label string
queryLen int
width int
bar bool
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 {
@ -163,8 +174,6 @@ type fitpad struct {
pad int
}
var emptyLine = itemLine{}
type labelPrinter func(tui.Window, int)
type StatusItem struct {
@ -224,6 +233,7 @@ type Terminal struct {
yanked []rune
input []rune
multi int
multiLine bool
sort bool
toggleSort bool
track trackOption
@ -739,6 +749,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
yanked: []rune{},
input: input,
multi: opts.Multi,
multiLine: opts.ReadZero && opts.MultiLine,
sort: opts.Sort > 0,
toggleSort: opts.ToggleSort,
track: opts.Track,
@ -1009,8 +1020,9 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
}
}
output := func() {
line := t.promptLine()
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)
@ -1031,22 +1043,45 @@ func (t *Terminal) noSeparatorLine() bool {
return noSeparatorLine(t.infoStyle, t.separatorLen > 0)
}
func getScrollbar(total int, height int, offset int) (int, int) {
if total == 0 || total <= height {
func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
if total == 0 || total*perLine <= height {
return 0, 0
}
barLength := util.Max(1, height*height/total)
barLength := util.Max(1, height*height/(total*perLine))
var barStart int
if total == height {
barStart = 0
} else {
barStart = (height - barLength) * offset / (total - height)
barStart = util.Min(height-barLength, (height*perLine-barLength)*offset/(total*perLine-height))
}
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) {
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
@ -1622,7 +1657,6 @@ func (t *Terminal) placeCursor() {
}
func (t *Terminal) printPrompt() {
t.move(t.promptLine(), 0, true)
t.prompt()
before, after := t.updatePromptOffset()
@ -1645,6 +1679,10 @@ func (t *Terminal) trimMessage(message string, maxWidth int) string {
func (t *Terminal) printInfo() {
pos := 0
line := t.promptLine()
move := func(y int, x int, clear bool) {
t.move(y, x, clear)
t.markOtherLine(y)
}
printSpinner := func() {
if t.reading {
duration := int64(spinnerDuration)
@ -1663,7 +1701,7 @@ func (t *Terminal) printInfo() {
str = string(trimmed)
width = maxWidth
}
t.move(line, pos, t.separatorLen == 0)
move(line, pos, t.separatorLen == 0)
if t.reading {
t.window.CPrint(tui.ColSpinner, str)
} else {
@ -1672,7 +1710,6 @@ func (t *Terminal) printInfo() {
pos += width
}
printSeparator := func(fillLength int, pad bool) {
// --------_
if t.separatorLen > 0 {
t.separator(t.window, fillLength)
t.window.Print(" ")
@ -1682,12 +1719,12 @@ func (t *Terminal) printInfo() {
}
switch t.infoStyle {
case infoDefault:
t.move(line+1, 0, t.separatorLen == 0)
move(line+1, 0, t.separatorLen == 0)
printSpinner()
t.window.Print(" ") // Margin
pos = 2
case infoRight:
t.move(line+1, 0, false)
move(line+1, 0, false)
case infoInlineRight:
pos = t.promptLen + t.queryLen[0] + t.queryLen[1] + 1
case infoInline:
@ -1695,7 +1732,7 @@ func (t *Terminal) printInfo() {
printInfoPrefix()
case infoHidden:
if t.separatorLen > 0 {
t.move(line+1, 0, false)
move(line+1, 0, false)
printSeparator(t.window.Width()-1, false)
}
return
@ -1755,7 +1792,7 @@ func (t *Terminal) printInfo() {
if t.infoStyle == infoInlineRight {
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)
t.window.Print(strings.Repeat(" ", newPos-pos))
pos = newPos
@ -1779,7 +1816,7 @@ func (t *Terminal) printInfo() {
if t.infoStyle == infoInlineRight {
if t.separatorLen > 0 {
t.move(line+1, 0, false)
move(line+1, 0, false)
printSeparator(t.window.Width()-1, false)
}
return
@ -1829,10 +1866,9 @@ func (t *Terminal) printHeader() {
text: util.ToChars([]byte(trimmed)),
colors: colors}
t.move(line, 0, true)
t.window.Print(" ")
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()
barLength, barStart := t.getScrollbar()
maxy := t.maxItems()
maxy := t.maxItems() - 1
count := t.merger.Length() - t.offset
for j := 0; j < maxy; j++ {
i := j
if t.layout == layoutDefault {
i = maxy - 1 - j
}
line := i + 2 + t.visibleHeaderLines()
// Start line
startLine := 2 + t.visibleHeaderLines()
if t.noSeparatorLine() {
line--
startLine--
}
if i < count {
t.printItem(t.merger.Get(i+t.offset), line, i, i == t.cy-t.offset, i >= barStart && i < barStart+barLength)
} else if t.prevLines[i] != emptyLine || t.prevLines[i].offset != line {
t.prevLines[i] = emptyLine
maxy += startLine
barRange := [2]int{startLine + barStart, startLine + barStart + barLength}
for line, itemCount := startLine, 0; line <= maxy; line, itemCount = line+1, itemCount+1 {
if itemCount < count {
item := t.merger.Get(itemCount + t.offset)
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.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
_, selected := t.selected[item.Index()]
label := ""
if t.jumping != jumpDisabled {
if i < len(t.jumpLabels) {
if index < len(t.jumpLabels) {
// Striped
current = i%2 == 0
label = t.jumpLabels[i:i+1] + strings.Repeat(" ", t.pointerLen-1)
current = index%2 == 0
label = t.jumpLabels[index:index+1] + strings.Repeat(" ", t.pointerLen-1)
}
} else if current {
label = t.pointer
}
// Avoid unnecessary redraw
newLine := itemLine{offset: line, current: current, selected: selected, label: label,
result: result, queryLen: len(t.input), width: 0, bar: bar}
prevLine := t.prevLines[i]
forceRedraw := prevLine.offset != newLine.offset
printBar := func() {
if len(t.scrollbar) > 0 && (bar != prevLine.bar || forceRedraw) {
t.prevLines[i].bar = bar
t.move(line, t.window.Width()-1, true)
if bar {
t.window.CPrint(tui.ColScrollbar, t.scrollbar)
}
}
newLine := itemLine{firstLine: line, cy: index + t.offset, current: current, selected: selected, label: label,
result: result, queryLen: len(t.input), width: 0, hasBar: line >= barRange[0] && line < barRange[1]}
prevLine := t.prevLines[line]
forceRedraw := prevLine.other || prevLine.firstLine != newLine.firstLine
printBar := func(lineNum int, forceRedraw bool) bool {
return t.printBar(lineNum, forceRedraw, barRange)
}
if !forceRedraw &&
@ -1895,12 +1944,40 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool, bar b
prevLine.label == newLine.label &&
prevLine.queryLen == newLine.queryLen &&
prevLine.result == newLine.result {
printBar()
return
t.prevLines[line].hasBar = printBar(line, false)
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 {
preTask := func(lineOffset int) {
if len(label) == 0 {
t.window.CPrint(tui.ColCurrentCursorEmpty, t.pointerEmpty)
} else {
@ -1911,8 +1988,10 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool, bar b
} else {
t.window.CPrint(tui.ColCurrentSelectedEmpty, t.markerEmpty)
}
newLine.width = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true)
}
finalLineNum = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true, line, maxLine, forceRedraw, preTask, postTask)
} else {
preTask := func(lineOffset int) {
if len(label) == 0 {
t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty)
} else {
@ -1923,6 +2002,7 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool, bar b
} else {
t.window.Print(t.markerEmpty)
}
}
var base, match tui.ColorPair
if selected {
base = tui.ColSelected
@ -1931,27 +2011,9 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool, bar b
base = tui.ColNormal
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 {
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
return finalLineNum
}
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
}
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
// Overflow
text := make([]rune, item.text.Length())
copy(text, item.text.ToRunes())
matchOffsets := []Offset{}
var pos *[]int
if match && t.merger.pattern != nil {
@ -2012,16 +2071,77 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
}
sort.Sort(ByOrder(charOffsets))
}
var maxe int
for _, offset := range charOffsets {
maxe = util.Max(maxe, int(offset[1]))
allOffsets := result.colorOffsets(charOffsets, t.theme, colBase, colMatch, current)
from := 0
text := make([]rune, item.text.Length())
copy(text, item.text.ToRunes())
finalLineNum := lineNum
numItemLines := 1
cutoff := 0
if t.multiLine && t.layout == layoutDefault {
maxLines := maxLineNum - lineNum + 1
numItemLines = item.text.NumLines(maxLines)
// Cut off the upper lines in the 'default' layout
if !current && maxLines == numItemLines {
cutoff = item.text.NumLines(math.MaxInt32) - maxLines
}
}
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 := []colorOffset{}
for _, offset := range allOffsets {
if offset.offset[0] >= int32(from) && offset.offset[1] <= int32(from+len(line)) {
offset.offset[0] -= int32(from)
offset.offset[1] -= int32(from)
offsets = append(offsets, offset)
} else {
allOffsets = allOffsets[len(offsets):]
break
}
}
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)
}
offsets := result.colorOffsets(charOffsets, t.theme, colBase, colMatch, current)
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(text))
displayWidth := t.displayWidthWithLimit(text, 0, maxWidth)
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 {
@ -2039,42 +2159,50 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
}
if t.hscroll {
if t.keepRight && pos == nil {
trimmed, diff := t.trimLeft(text, maxWidth-ellipsisWidth)
trimmed, diff := t.trimLeft(line, maxWidth-ellipsisWidth)
transformOffsets(diff, false)
text = append(ellipsis, trimmed...)
} else if !t.overflow(text[:maxe], maxWidth-ellipsisWidth) {
line = append(ellipsis, trimmed...)
} else if !t.overflow(line[:maxe], maxWidth-ellipsisWidth) {
// Stri..
text, _ = t.trimRight(text, maxWidth-ellipsisWidth)
text = append(text, ellipsis...)
line, _ = t.trimRight(line, maxWidth-ellipsisWidth)
line = append(line, ellipsis...)
} else {
// Stri..
rightTrim := false
if t.overflow(text[maxe:], ellipsisWidth) {
text = append(text[:maxe], ellipsis...)
if t.overflow(line[maxe:], ellipsisWidth) {
line = append(line[:maxe], ellipsis...)
rightTrim = true
}
// ..ri..
var diff int32
text, diff = t.trimLeft(text, maxWidth-ellipsisWidth)
line, diff = t.trimLeft(line, maxWidth-ellipsisWidth)
// Transform offsets
transformOffsets(diff, rightTrim)
text = append(ellipsis, text...)
line = append(ellipsis, line...)
}
} else {
text, _ = t.trimRight(text, maxWidth-ellipsisWidth)
text = append(text, ellipsis...)
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(text, 0, displayWidth)
displayWidth = t.displayWidthWithLimit(line, 0, displayWidth)
}
t.printColoredString(t.window, text, offsets, colBase)
return displayWidth
t.printColoredString(t.window, line, offsets, colBase)
if postTask != nil {
postTask(actualLineNum, displayWidth)
} else {
t.markOtherLine(actualLineNum)
}
lineNum += 1
}
return finalLineNum
}
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
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)
}
@ -4022,7 +4150,7 @@ func (t *Terminal) Loop() error {
if pbarDragging {
effectiveHeight := t.pwindow.Height() - 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 {
y := my - t.pwindow.Top() - headerLines - barLength/2
y = util.Constrain(y, 0, effectiveHeight-barLength)
@ -4068,7 +4196,8 @@ func (t *Terminal) Loop() error {
total := t.merger.Length()
prevOffset := t.offset
// 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
req(reqList)
}
@ -4076,11 +4205,18 @@ func (t *Terminal) Loop() error {
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
cy := prevLine.cy
if me.Double && mx < t.window.Width()-1 {
// Double-click
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))
}
}
@ -4093,7 +4229,7 @@ func (t *Terminal) Loop() error {
// Prompt
t.cx = mxCons + t.xoffset
} else if my >= min {
t.vset(t.offset + my - min)
t.vset(cy)
req(reqList)
evt := tui.RightClick
if me.Mod {
@ -4312,18 +4448,53 @@ func (t *Terminal) Loop() error {
func (t *Terminal) constrain() {
// count of items to display allowed by filtering
count := t.merger.Length()
// count of lines can be displayed
height := t.maxItems()
maxItems := 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)
maxOffset := util.Max(util.Min(count-height, t.cy), 0)
t.offset = util.Constrain(t.offset, minOffset, maxOffset)
if t.scrollOff == 0 {
return
add := func(i int) bool {
lines := t.merger.Get(i).item.text.NumLines(height - linesSum)
linesSum += lines
if linesSum >= height {
if actualHeight == 0 {
actualHeight = 1
}
return false
}
actualHeight++
return true
}
for i := t.offset; i < t.merger.Length(); i++ {
if !add(i) {
break
}
}
// 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
}
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
@ -4338,6 +4509,11 @@ func (t *Terminal) constrain() {
}
}
}
if t.offset == prevOffset {
break
}
}
}
func (t *Terminal) vmove(o int, allowCycle bool) {
if t.layout != layoutDefault {

View File

@ -1,6 +1,7 @@
package util
import (
"bytes"
"fmt"
"unicode"
"unicode/utf8"
@ -74,6 +75,35 @@ func (chars *Chars) Bytes() []byte {
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 {
if chars.inBytes {
return nil

View File

@ -2128,7 +2128,7 @@ class TestGoFZF < TestBase
end
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') }
end
@ -3398,7 +3398,7 @@ module TestShell
def test_ctrl_r_multiline
# 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 'echo "foo', :Enter, 'bar"', :Enter
tmux.until { |lines| assert_equal %w[foo bar], lines[-2..] }