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` - 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 - 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 - Native `--tmux` integration to replace fzf-tmux script
```sh ```sh
# --tmux [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] # --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" .BI "--marker=" "STR"
Multi-select marker (default: '┃' or '>' depending on \fB--no-unicode\fR) Multi-select marker (default: '┃' or '>' depending on \fB--no-unicode\fR)
.TP .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" .BI "--header=" "STR"
The given string will be printed as the sticky header. The lines are displayed 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 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 --tiebreak=CRI[,..] Comma-separated list of sort criteria to apply
when the scores are tied [length|chunk|begin|end|index] when the scores are tied [length|chunk|begin|end|index]
(default: length) (default: length)
Interface Interface
-m, --multi[=MAX] Enable multi-select with tab/shift-tab -m, --multi[=MAX] Enable multi-select with tab/shift-tab
--no-mouse Disable mouse --no-mouse Disable mouse
@ -94,6 +93,8 @@ Usage: fzf [options]
--prompt=STR Input prompt (default: '> ') --prompt=STR Input prompt (default: '> ')
--pointer=STR Pointer to the current line (default: '▌' or '>') --pointer=STR Pointer to the current line (default: '▌' or '>')
--marker=STR Multi-select marker (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=STR String to print as header
--header-lines=N The first N lines of the input are treated as header --header-lines=N The first N lines of the input are treated as header
--header-first Print header before the prompt line --header-first Print header before the prompt line
@ -438,6 +439,7 @@ type Options struct {
Prompt string Prompt string
Pointer *string Pointer *string
Marker *string Marker *string
MarkerMulti [3]string
Query string Query string
Select1 bool Select1 bool
Exit0 bool Exit0 bool
@ -1847,6 +1849,37 @@ func parseMargin(opt string, margin string) ([4]sizeSpec, error) {
return [4]sizeSpec{}, errors.New("invalid " + opt + ": " + margin) 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 { func parseOptions(opts *Options, allArgs []string) error {
var err error var err error
var historyMax int var historyMax int
@ -2186,19 +2219,27 @@ func parseOptions(opts *Options, allArgs []string) error {
return err return err
} }
case "--pointer": case "--pointer":
str, err := nextString(allArgs, &i, "pointer sign string required") str, err := nextString(allArgs, &i, "pointer sign required")
if err != nil { if err != nil {
return err return err
} }
str = firstLine(str) str = firstLine(str)
opts.Pointer = &str opts.Pointer = &str
case "--marker": case "--marker":
str, err := nextString(allArgs, &i, "selected sign string required") str, err := nextString(allArgs, &i, "marker sign required")
if err != nil { if err != nil {
return err return err
} }
str = firstLine(str) str = firstLine(str)
opts.Marker = &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": case "--sync":
opts.Sync = true opts.Sync = true
case "--no-sync", "--async": case "--no-sync", "--async":
@ -2439,6 +2480,10 @@ func parseOptions(opts *Options, allArgs []string) error {
} else if match, value := optString(arg, "--marker="); match { } else if match, value := optString(arg, "--marker="); match {
str := firstLine(value) str := firstLine(value)
opts.Marker = &str 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 { } else if match, value := optString(arg, "-n", "--nth="); match {
if opts.Nth, err = splitNth(value); err != nil { if opts.Nth, err = splitNth(value); err != nil {
return err return err
@ -2680,13 +2725,35 @@ func postProcessOptions(opts *Options) error {
opts.Pointer = &defaultPointer opts.Pointer = &defaultPointer
} }
markerLen := 1
if opts.Marker == nil { if opts.Marker == nil {
// "" looks better, but not all terminals render it correctly // "" looks better, but not all terminals render it correctly
defaultMarker := "┃" defaultMarker := "┃"
if !opts.Unicode { if !opts.Unicode {
defaultMarker = ">" defaultMarker = ">"
} }
opts.Marker = &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 // 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 labelPrinter func(tui.Window, int)
type markerClass int
const (
markerSingle markerClass = iota
markerTop
markerMiddle
markerBottom
)
type StatusItem struct { type StatusItem struct {
Index int `json:"index"` Index int `json:"index"`
Text string `json:"text"` Text string `json:"text"`
@ -218,6 +227,7 @@ type Terminal struct {
marker string marker string
markerLen int markerLen int
markerEmpty string markerEmpty string
markerMultiLine [3]string
queryLen [2]int queryLen [2]int
layout layoutType layout layoutType
fullscreen bool fullscreen bool
@ -755,6 +765,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
pointerLen: uniseg.StringWidth(*opts.Pointer), pointerLen: uniseg.StringWidth(*opts.Pointer),
marker: *opts.Marker, marker: *opts.Marker,
markerLen: uniseg.StringWidth(*opts.Marker), markerLen: uniseg.StringWidth(*opts.Marker),
markerMultiLine: opts.MarkerMulti,
wordRubout: wordRubout, wordRubout: wordRubout,
wordNext: wordNext, wordNext: wordNext,
cx: len(input), cx: len(input),
@ -1084,7 +1095,8 @@ func (t *Terminal) avgNumLines() int {
offset := util.Max(0, util.Min(t.offset, total-maxItems-1)) offset := util.Max(0, util.Min(t.offset, total-maxItems-1))
for idx := 0; idx < maxItems && idx+offset < total; idx++ { for idx := 0; idx < maxItems && idx+offset < total; idx++ {
item := t.merger.Get(idx + offset) item := t.merger.Get(idx + offset)
numLines += item.item.text.NumLines(maxItems) lines, _ := item.item.text.NumLines(maxItems)
numLines += lines
count++ count++
} }
if count == 0 { if count == 0 {
@ -1884,7 +1896,7 @@ func (t *Terminal) printHeader() {
t.printHighlighted(Result{item: item}, t.printHighlighted(Result{item: item},
tui.ColHeader, tui.ColHeader, false, false, line, line, true, 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 { if !t.multiLine {
return line 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) 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 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 { if current {
preTask := func(lineOffset int) { preTask := func(marker markerClass) {
if len(label) == 0 { if len(label) == 0 {
t.window.CPrint(tui.ColCurrentCursorEmpty, t.pointerEmpty) t.window.CPrint(tui.ColCurrentCursorEmpty, t.pointerEmpty)
} else { } else {
t.window.CPrint(tui.ColCurrentCursor, label) t.window.CPrint(tui.ColCurrentCursor, label)
} }
if selected { if selected {
t.window.CPrint(tui.ColCurrentMarker, t.marker) t.window.CPrint(tui.ColCurrentMarker, markerFor(marker))
} else { } else {
t.window.CPrint(tui.ColCurrentSelectedEmpty, t.markerEmpty) t.window.CPrint(tui.ColCurrentSelectedEmpty, t.markerEmpty)
} }
} }
finalLineNum = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true, line, maxLine, forceRedraw, preTask, postTask) finalLineNum = t.printHighlighted(result, tui.ColCurrent, tui.ColCurrentMatch, true, true, line, maxLine, forceRedraw, preTask, postTask)
} else { } else {
preTask := func(lineOffset int) { preTask := func(marker markerClass) {
if len(label) == 0 { if len(label) == 0 {
t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty) t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty)
} else { } else {
t.window.CPrint(tui.ColCursor, label) t.window.CPrint(tui.ColCursor, label)
} }
if selected { if selected {
t.window.CPrint(tui.ColMarker, t.marker) t.window.CPrint(tui.ColMarker, markerFor(marker))
} else { } else {
t.window.Print(t.markerEmpty) 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 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 var displayWidth int
item := result.item item := result.item
matchOffsets := []Offset{} matchOffsets := []Offset{}
@ -2096,12 +2121,16 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
finalLineNum := lineNum finalLineNum := lineNum
numItemLines := 1 numItemLines := 1
cutoff := 0 cutoff := 0
if t.multiLine && t.layout == layoutDefault { overflow := false
topCutoff := false
if t.multiLine {
maxLines := maxLineNum - lineNum + 1 maxLines := maxLineNum - lineNum + 1
numItemLines = item.text.NumLines(maxLines) numItemLines, overflow = item.text.NumLines(maxLines)
// Cut off the upper lines in the 'default' layout // Cut off the upper lines in the 'default' layout
if !current && maxLines == numItemLines { if t.layout == layoutDefault && !current && maxLines == numItemLines && overflow {
cutoff = item.text.NumLines(math.MaxInt32) - maxLines actualLines, _ := item.text.NumLines(math.MaxInt32)
cutoff = actualLines - maxLines
topCutoff = true
} }
} }
for lineOffset := 0; from <= len(text) && (lineNum <= maxLineNum || maxLineNum == 0); lineOffset++ { 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) t.move(actualLineNum, 0, forceRedraw)
if preTask != nil { 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) maxWidth := t.window.Width() - (t.pointerLen + t.markerLen + 1)
@ -4502,7 +4558,7 @@ func (t *Terminal) constrain() {
linesSum := 0 linesSum := 0
add := func(i int) bool { 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 linesSum += lines
if linesSum >= numItems { if linesSum >= numItems {
if numItemsFound == 0 { if numItemsFound == 0 {
@ -4547,13 +4603,14 @@ func (t *Terminal) constrain() {
numItems := t.merger.Length() numItems := t.merger.Length()
itemLines := 1 itemLines := 1
if t.multiLine && t.cy < numItems { 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 linesBefore := t.cy - newOffset
if t.multiLine { if t.multiLine {
linesBefore = 0 linesBefore = 0
for i := newOffset; i < t.cy && i < numItems; i++ { 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) linesAfter := maxLines - (linesBefore + itemLines)

View File

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

View File

@ -2752,8 +2752,9 @@ class TestGoFZF < TestBase
def assert_block(expected, lines) def assert_block(expected, lines)
cols = expected.lines.map(&:chomp).map(&:length).max cols = expected.lines.map(&:chomp).map(&:length).max
actual = lines.reverse.take(expected.lines.length).reverse.map { _1[0, cols].rstrip + "\n" }.join top = lines.take(expected.lines.length).map { _1[0, cols].rstrip + "\n" }.join
assert_equal_org expected, actual bottom = lines.reverse.take(expected.lines.length).reverse.map { _1[0, cols].rstrip + "\n" }.join
assert_includes [top, bottom], expected
end end
def test_height_range_fit def test_height_range_fit
@ -3268,6 +3269,55 @@ class TestGoFZF < TestBase
tmux.send_keys '99' tmux.send_keys '99'
tmux.until { |lines| assert(lines.any? { |line| line.include?('0 / 0') }) } tmux.until { |lines| assert(lines.any? { |line| line.include?('0 / 0') }) }
end 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 end
module TestShell module TestShell