Different marker for the first and last line of multi-line entries

Can be configured via `--marker-multi-line`
This commit is contained in:
Junegunn Choi 2024-05-27 01:20:56 +09:00
parent 0ccbd79e10
commit 2f51eb2b41
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627
6 changed files with 323 additions and 139 deletions

View File

@ -10,6 +10,12 @@ CHANGELOG
```
- To disable multi-line display, use `--no-multi-line`
- The default `--pointer` and `--marker` have been changed from `>` to Unicode bar characters as they look better with multi-line items
- Added `--marker-multi-line` to customize the select marker for multi-line entries with the default set to `╻┃╹`
```
╻First line
┃...
╹Last line
```
- Native `--tmux` integration to replace fzf-tmux script
```sh
# --tmux [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]

View File

@ -455,6 +455,10 @@ Pointer to the current line (default: '▌' or '>' depending on \fB--no-unicode\
.BI "--marker=" "STR"
Multi-select marker (default: '┃' or '>' depending on \fB--no-unicode\fR)
.TP
.BI "--marker-multi-line=" "STR"
Multi-select marker for multi-line entries. 3 elements for top, middle, and bottom.
(default: '╻┃╹' or '.|'' depending on \fB--no-unicode\fR)
.TP
.BI "--header=" "STR"
The given string will be printed as the sticky header. The lines are displayed
in the given order from top to bottom regardless of \fB--layout\fR option, and

View File

@ -47,7 +47,6 @@ Usage: fzf [options]
--tiebreak=CRI[,..] Comma-separated list of sort criteria to apply
when the scores are tied [length|chunk|begin|end|index]
(default: length)
Interface
-m, --multi[=MAX] Enable multi-select with tab/shift-tab
--no-mouse Disable mouse
@ -94,6 +93,8 @@ Usage: fzf [options]
--prompt=STR Input prompt (default: '> ')
--pointer=STR Pointer to the current line (default: '▌' or '>')
--marker=STR Multi-select marker (default: '┃' or '>')
--marker-multi-line=STR Multi-select marker for multi-line entries;
3 elements for top, middle, and bottom (default: '')
--header=STR String to print as header
--header-lines=N The first N lines of the input are treated as header
--header-first Print header before the prompt line
@ -438,6 +439,7 @@ type Options struct {
Prompt string
Pointer *string
Marker *string
MarkerMulti [3]string
Query string
Select1 bool
Exit0 bool
@ -1847,6 +1849,37 @@ func parseMargin(opt string, margin string) ([4]sizeSpec, error) {
return [4]sizeSpec{}, errors.New("invalid " + opt + ": " + margin)
}
func parseMarkerMultiLine(str string) ([3]string, error) {
gr := uniseg.NewGraphemes(str)
parts := []string{}
totalWidth := 0
for gr.Next() {
s := string(gr.Runes())
totalWidth += uniseg.StringWidth(s)
parts = append(parts, s)
}
result := [3]string{}
if totalWidth != 3 && totalWidth != 6 {
return result, fmt.Errorf("invalid total marker width: %d (expected: 3 or 6)", totalWidth)
}
expected := totalWidth / 3
idx := 0
for _, part := range parts {
expected -= uniseg.StringWidth(part)
result[idx] += part
if expected <= 0 {
idx++
expected = totalWidth / 3
}
if idx == 3 {
break
}
}
return result, nil
}
func parseOptions(opts *Options, allArgs []string) error {
var err error
var historyMax int
@ -2186,19 +2219,27 @@ func parseOptions(opts *Options, allArgs []string) error {
return err
}
case "--pointer":
str, err := nextString(allArgs, &i, "pointer sign string required")
str, err := nextString(allArgs, &i, "pointer sign required")
if err != nil {
return err
}
str = firstLine(str)
opts.Pointer = &str
case "--marker":
str, err := nextString(allArgs, &i, "selected sign string required")
str, err := nextString(allArgs, &i, "marker sign required")
if err != nil {
return err
}
str = firstLine(str)
opts.Marker = &str
case "--marker-multi-line":
str, err := nextString(allArgs, &i, "marker sign for multi-line entries required")
if err != nil {
return err
}
if opts.MarkerMulti, err = parseMarkerMultiLine(firstLine(str)); err != nil {
return err
}
case "--sync":
opts.Sync = true
case "--no-sync", "--async":
@ -2439,6 +2480,10 @@ func parseOptions(opts *Options, allArgs []string) error {
} else if match, value := optString(arg, "--marker="); match {
str := firstLine(value)
opts.Marker = &str
} else if match, value := optString(arg, "--marker-multi-line="); match {
if opts.MarkerMulti, err = parseMarkerMultiLine(firstLine(value)); err != nil {
return err
}
} else if match, value := optString(arg, "-n", "--nth="); match {
if opts.Nth, err = splitNth(value); err != nil {
return err
@ -2680,13 +2725,35 @@ func postProcessOptions(opts *Options) error {
opts.Pointer = &defaultPointer
}
markerLen := 1
if opts.Marker == nil {
// "" looks better, but not all terminals render it correctly
// "" looks better, but not all terminals render it correctly
defaultMarker := "┃"
if !opts.Unicode {
defaultMarker = ">"
}
opts.Marker = &defaultMarker
} else {
markerLen = uniseg.StringWidth(*opts.Marker)
}
markerMultiLen := 1
if len(opts.MarkerMulti[0]) == 0 {
if opts.Unicode {
opts.MarkerMulti = [3]string{"╻", "┃", "╹"}
} else {
opts.MarkerMulti = [3]string{".", "|", "'"}
}
} else {
markerMultiLen = uniseg.StringWidth(opts.MarkerMulti[0])
}
if markerMultiLen > markerLen {
padded := *opts.Marker + " "
opts.Marker = &padded
} else if markerMultiLen < markerLen {
for idx := range opts.MarkerMulti {
opts.MarkerMulti[idx] += " "
}
}
// Default actions for CTRL-N / CTRL-P when --history is set

View File

@ -177,6 +177,15 @@ type fitpad struct {
type labelPrinter func(tui.Window, int)
type markerClass int
const (
markerSingle markerClass = iota
markerTop
markerMiddle
markerBottom
)
type StatusItem struct {
Index int `json:"index"`
Text string `json:"text"`
@ -218,6 +227,7 @@ type Terminal struct {
marker string
markerLen int
markerEmpty string
markerMultiLine [3]string
queryLen [2]int
layout layoutType
fullscreen bool
@ -755,6 +765,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
pointerLen: uniseg.StringWidth(*opts.Pointer),
marker: *opts.Marker,
markerLen: uniseg.StringWidth(*opts.Marker),
markerMultiLine: opts.MarkerMulti,
wordRubout: wordRubout,
wordNext: wordNext,
cx: len(input),
@ -1084,7 +1095,8 @@ func (t *Terminal) avgNumLines() int {
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)
lines, _ := item.item.text.NumLines(maxItems)
numLines += lines
count++
}
if count == 0 {
@ -1884,7 +1896,7 @@ func (t *Terminal) printHeader() {
t.printHighlighted(Result{item: item},
tui.ColHeader, tui.ColHeader, false, false, line, line, true,
func(int) { t.window.Print(" ") }, nil)
func(markerClass) { t.window.Print(" ") }, nil)
}
}
@ -1964,7 +1976,8 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu
if !t.multiLine {
return line
}
return line + item.text.NumLines(maxLine-line+1) - 1
lines, _ := item.text.NumLines(maxLine - line + 1)
return line + lines - 1
}
maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
@ -1992,29 +2005,41 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu
}
var finalLineNum int
markerFor := func(markerClass markerClass) string {
marker := t.marker
switch markerClass {
case markerTop:
marker = t.markerMultiLine[0]
case markerMiddle:
marker = t.markerMultiLine[1]
case markerBottom:
marker = t.markerMultiLine[2]
}
return marker
}
if current {
preTask := func(lineOffset int) {
preTask := func(marker markerClass) {
if len(label) == 0 {
t.window.CPrint(tui.ColCurrentCursorEmpty, t.pointerEmpty)
} else {
t.window.CPrint(tui.ColCurrentCursor, label)
}
if selected {
t.window.CPrint(tui.ColCurrentMarker, t.marker)
t.window.CPrint(tui.ColCurrentMarker, markerFor(marker))
} else {
t.window.CPrint(tui.ColCurrentSelectedEmpty, t.markerEmpty)
}
}
finalLineNum = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true, line, maxLine, forceRedraw, preTask, postTask)
} else {
preTask := func(lineOffset int) {
preTask := func(marker markerClass) {
if len(label) == 0 {
t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty)
} else {
t.window.CPrint(tui.ColCursor, label)
}
if selected {
t.window.CPrint(tui.ColMarker, t.marker)
t.window.CPrint(tui.ColMarker, markerFor(marker))
} else {
t.window.Print(t.markerEmpty)
}
@ -2070,7 +2095,7 @@ 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, lineNum int, maxLineNum int, forceRedraw bool, preTask func(int), postTask func(int, int)) 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(markerClass), postTask func(int, int)) int {
var displayWidth int
item := result.item
matchOffsets := []Offset{}
@ -2096,12 +2121,16 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
finalLineNum := lineNum
numItemLines := 1
cutoff := 0
if t.multiLine && t.layout == layoutDefault {
overflow := false
topCutoff := false
if t.multiLine {
maxLines := maxLineNum - lineNum + 1
numItemLines = item.text.NumLines(maxLines)
numItemLines, overflow = item.text.NumLines(maxLines)
// Cut off the upper lines in the 'default' layout
if !current && maxLines == numItemLines {
cutoff = item.text.NumLines(math.MaxInt32) - maxLines
if t.layout == layoutDefault && !current && maxLines == numItemLines && overflow {
actualLines, _ := item.text.NumLines(math.MaxInt32)
cutoff = actualLines - maxLines
topCutoff = true
}
}
for lineOffset := 0; from <= len(text) && (lineNum <= maxLineNum || maxLineNum == 0); lineOffset++ {
@ -2151,7 +2180,34 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
t.move(actualLineNum, 0, forceRedraw)
if preTask != nil {
preTask(lineOffset)
var marker markerClass
if numItemLines == 1 {
if !overflow {
marker = markerSingle
} else if topCutoff {
marker = markerBottom
} else {
marker = markerTop
}
} else {
if lineOffset == 0 { // First line
if topCutoff {
marker = markerMiddle
} else {
marker = markerTop
}
} else if lineOffset == numItemLines-1 { // Last line
if topCutoff || !overflow {
marker = markerBottom
} else {
marker = markerMiddle
}
} else {
marker = markerMiddle
}
}
preTask(marker)
}
maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
@ -4502,7 +4558,7 @@ func (t *Terminal) constrain() {
linesSum := 0
add := func(i int) bool {
lines := t.merger.Get(i).item.text.NumLines(numItems - linesSum)
lines, _ := t.merger.Get(i).item.text.NumLines(numItems - linesSum)
linesSum += lines
if linesSum >= numItems {
if numItemsFound == 0 {
@ -4547,13 +4603,14 @@ func (t *Terminal) constrain() {
numItems := t.merger.Length()
itemLines := 1
if t.multiLine && t.cy < numItems {
itemLines = t.merger.Get(t.cy).item.text.NumLines(maxLines)
itemLines, _ = t.merger.Get(t.cy).item.text.NumLines(maxLines)
}
linesBefore := t.cy - newOffset
if t.multiLine {
linesBefore = 0
for i := newOffset; i < t.cy && i < numItems; i++ {
linesBefore += t.merger.Get(i).item.text.NumLines(maxLines - linesBefore - itemLines)
lines, _ := t.merger.Get(i).item.text.NumLines(maxLines - linesBefore - itemLines)
linesBefore += lines
}
}
linesAfter := maxLines - (linesBefore + itemLines)

View File

@ -75,18 +75,18 @@ func (chars *Chars) Bytes() []byte {
return chars.slice
}
func (chars *Chars) NumLines(atMost int) int {
func (chars *Chars) NumLines(atMost int) (int, bool) {
lines := 1
if runes := chars.optionalRunes(); runes != nil {
for _, r := range runes {
if r == '\n' {
lines++
}
if lines >= atMost {
return atMost
if lines > atMost {
return atMost, true
}
}
return lines
return lines, false
}
for idx := 0; idx < len(chars.slice); idx++ {
@ -97,11 +97,11 @@ func (chars *Chars) NumLines(atMost int) int {
idx += found
lines++
if lines >= atMost {
return atMost
if lines > atMost {
return atMost, true
}
}
return lines
return lines, false
}
func (chars *Chars) optionalRunes() []rune {

View File

@ -2752,8 +2752,9 @@ class TestGoFZF < TestBase
def assert_block(expected, lines)
cols = expected.lines.map(&:chomp).map(&:length).max
actual = lines.reverse.take(expected.lines.length).reverse.map { _1[0, cols].rstrip + "\n" }.join
assert_equal_org expected, actual
top = lines.take(expected.lines.length).map { _1[0, cols].rstrip + "\n" }.join
bottom = lines.reverse.take(expected.lines.length).reverse.map { _1[0, cols].rstrip + "\n" }.join
assert_includes [top, bottom], expected
end
def test_height_range_fit
@ -3268,6 +3269,55 @@ class TestGoFZF < TestBase
tmux.send_keys '99'
tmux.until { |lines| assert(lines.any? { |line| line.include?('0 / 0') }) }
end
def test_fzf_multi_line
tmux.send_keys %[(echo -en '0\\0'; echo -en '1\\n2\\0'; seq 1000) | fzf --read0 --multi --bind load:select-all --border rounded], :Enter
block = <<~BLOCK
998
999
1000
1
2
>>0
3/3 (3)
>
BLOCK
tmux.until { assert_block(block, _1) }
tmux.send_keys :Up, :Up
block = <<~BLOCK
>1
>2
>3
BLOCK
tmux.until { assert_block(block, _1) }
block = <<~BLOCK
>
>
BLOCK
tmux.until { assert_block(block, _1) }
end
def test_fzf_multi_line_reverse
tmux.send_keys %[(echo -en '0\\0'; echo -en '1\\n2\\0'; seq 1000) | fzf --read0 --multi --bind load:select-all --border rounded --reverse], :Enter
block = <<~BLOCK
>
3/3 (3)
>>0
1
2
1
2
3
BLOCK
tmux.until { assert_block(block, _1) }
end
end
module TestShell