diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index d6fe163d0c6..b7060af953d 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -231,6 +231,12 @@ A synonym for \fB--info=hidden\fB .BI "--prompt=" "STR" Input prompt (default: '> ') .TP +.BI "--pointer=" "STR" +Pointer to the current line (default: '>') +.TP +.BI "--marker=" "STR" +Multi-select marker (default: '>') +.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 diff --git a/shell/completion.bash b/shell/completion.bash index 8b3025ac2d0..9d52a6ba821 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -72,6 +72,8 @@ _fzf_opts_completion() { --margin --inline-info --prompt + --pointer + --marker --header --header-lines --ansi diff --git a/src/options.go b/src/options.go index 815ac444827..3bbeb1c5503 100644 --- a/src/options.go +++ b/src/options.go @@ -6,12 +6,14 @@ import ( "regexp" "strconv" "strings" + "unicode" "unicode/utf8" "github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/util" + "github.com/mattn/go-runewidth" "github.com/mattn/go-shellwords" ) @@ -59,6 +61,8 @@ const usage = `usage: fzf [options] --margin=MARGIN Screen margin (TRBL / TB,RL / T,RL,B / T,R,B,L) --info=STYLE Finder info style [default|inline|hidden] --prompt=STR Input prompt (default: '> ') + --pointer=STR Pointer to the current line (default: '>') + --marker=STR Multi-select marker (default: '>') --header=STR String to print as header --header-lines=N The first N lines of the input are treated as header @@ -189,6 +193,8 @@ type Options struct { InfoStyle infoStyle JumpLabels string Prompt string + Pointer string + Marker string Query string Select1 bool Exit0 bool @@ -242,6 +248,8 @@ func defaultOptions() *Options { InfoStyle: infoDefault, JumpLabels: defaultJumpLabels, Prompt: "> ", + Pointer: ">", + Marker: ">", Query: "", Select1: false, Exit0: false, @@ -1041,6 +1049,8 @@ func parseOptions(opts *Options, allArgs []string) { } } validateJumpLabels := false + validatePointer := false + validateMarker := false for i := 0; i < len(allArgs); i++ { arg := allArgs[i] switch arg { @@ -1189,6 +1199,12 @@ func parseOptions(opts *Options, allArgs []string) { opts.PrintQuery = false case "--prompt": opts.Prompt = nextString(allArgs, &i, "prompt string required") + case "--pointer": + opts.Pointer = nextString(allArgs, &i, "pointer sign string required") + validatePointer = true + case "--marker": + opts.Marker = nextString(allArgs, &i, "selected sign string required") + validateMarker = true case "--sync": opts.Sync = true case "--no-sync": @@ -1255,6 +1271,12 @@ func parseOptions(opts *Options, allArgs []string) { opts.Delimiter = delimiterRegexp(value) } else if match, value := optString(arg, "--prompt="); match { opts.Prompt = value + } else if match, value := optString(arg, "--pointer="); match { + opts.Pointer = value + validatePointer = true + } else if match, value := optString(arg, "--marker="); match { + opts.Marker = value + validateMarker = true } else if match, value := optString(arg, "-n", "--nth="); match { opts.Nth = splitNth(value) } else if match, value := optString(arg, "--with-nth="); match { @@ -1333,6 +1355,35 @@ func parseOptions(opts *Options, allArgs []string) { } } } + + if validatePointer { + if err := validateSign(opts.Pointer, "pointer"); err != nil { + errorExit(err.Error()) + } + } + + if validateMarker { + if err := validateSign(opts.Marker, "marker"); err != nil { + errorExit(err.Error()) + } + } +} + +func validateSign(sign string, signOptName string) error { + if sign == "" { + return fmt.Errorf("%v cannot be empty", signOptName) + } + widthSum := 0 + for _, r := range sign { + if !unicode.IsGraphic(r) { + return fmt.Errorf("invalid character in %v", signOptName) + } + widthSum += runewidth.RuneWidth(r) + if widthSum > 2 { + return fmt.Errorf("%v display width should be up to 2", signOptName) + } + } + return nil } func postProcessOptions(opts *Options) { diff --git a/src/options_test.go b/src/options_test.go index 66d7c8f58b0..b312be117cf 100644 --- a/src/options_test.go +++ b/src/options_test.go @@ -422,3 +422,29 @@ func TestAdditiveExpect(t *testing.T) { t.Error(opts.Expect) } } + +func TestValidateSign(t *testing.T) { + testCases := []struct { + inputSign string + isValid bool + }{ + {"> ", true}, + {"아", true}, + {"😀", true}, + {"", false}, + {">>>", false}, + {"\n", false}, + {"\t", false}, + } + + for _, testCase := range testCases { + err := validateSign(testCase.inputSign, "") + if testCase.isValid && err != nil { + t.Errorf("Input sign `%s` caused error", testCase.inputSign) + } + + if !testCase.isValid && err == nil { + t.Errorf("Input sign `%s` did not cause error", testCase.inputSign) + } + } +} diff --git a/src/terminal.go b/src/terminal.go index e9101a5221b..bc975546f92 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -59,72 +59,78 @@ var emptyLine = itemLine{} // Terminal represents terminal input/output type Terminal struct { - initDelay time.Duration - infoStyle infoStyle - spinner []string - prompt string - promptLen int - queryLen [2]int - layout layoutType - fullscreen bool - hscroll bool - hscrollOff int - wordRubout string - wordNext string - cx int - cy int - offset int - xoffset int - yanked []rune - input []rune - multi int - sort bool - toggleSort bool - delimiter Delimiter - expect map[int]string - keymap map[int][]action - pressed string - printQuery bool - history *History - cycle bool - header []string - header0 []string - ansi bool - tabstop int - margin [4]sizeSpec - strong tui.Attr - unicode bool - bordered bool - cleanExit bool - border tui.Window - window tui.Window - pborder tui.Window - pwindow tui.Window - count int - progress int - reading bool - failed *string - jumping jumpMode - jumpLabels string - printer func(string) - printsep string - merger *Merger - selected map[int32]selectedItem - version int64 - reqBox *util.EventBox - preview previewOpts - previewer previewer - previewBox *util.EventBox - eventBox *util.EventBox - mutex sync.Mutex - initFunc func() - prevLines []itemLine - suppress bool - startChan chan bool - killChan chan int - slab *util.Slab - theme *tui.ColorTheme - tui tui.Renderer + initDelay time.Duration + infoStyle infoStyle + spinner []string + prompt string + promptLen int + pointer string + pointerLen int + pointerEmpty string + marker string + markerLen int + markerEmpty string + queryLen [2]int + layout layoutType + fullscreen bool + hscroll bool + hscrollOff int + wordRubout string + wordNext string + cx int + cy int + offset int + xoffset int + yanked []rune + input []rune + multi int + sort bool + toggleSort bool + delimiter Delimiter + expect map[int]string + keymap map[int][]action + pressed string + printQuery bool + history *History + cycle bool + header []string + header0 []string + ansi bool + tabstop int + margin [4]sizeSpec + strong tui.Attr + unicode bool + bordered bool + cleanExit bool + border tui.Window + window tui.Window + pborder tui.Window + pwindow tui.Window + count int + progress int + reading bool + failed *string + jumping jumpMode + jumpLabels string + printer func(string) + printsep string + merger *Merger + selected map[int32]selectedItem + version int64 + reqBox *util.EventBox + preview previewOpts + previewer previewer + previewBox *util.EventBox + eventBox *util.EventBox + mutex sync.Mutex + initFunc func() + prevLines []itemLine + suppress bool + startChan chan bool + killChan chan int + slab *util.Slab + theme *tui.ColorTheme + tui tui.Renderer } type selectedItem struct { @@ -441,6 +447,12 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { tui: renderer, initFunc: func() { renderer.Init() }} t.prompt, t.promptLen = t.processTabs([]rune(opts.Prompt), 0) + t.pointer, t.pointerLen = t.processTabs([]rune(opts.Pointer), 0) + t.marker, t.markerLen = t.processTabs([]rune(opts.Marker), 0) + // Pre-calculated empty pointer and marker signs + t.pointerEmpty = strings.Repeat(" ", t.pointerLen) + t.markerEmpty = strings.Repeat(" ", t.markerLen) + return &t } @@ -852,15 +864,15 @@ func (t *Terminal) printList() { func (t *Terminal) printItem(result Result, line int, i int, current bool) { item := result.item _, selected := t.selected[item.Index()] - label := " " + label := t.pointerEmpty if t.jumping != jumpDisabled { if i < len(t.jumpLabels) { // Striped current = i%2 == 0 - label = t.jumpLabels[i : i+1] + label = t.jumpLabels[i:i+1] + strings.Repeat(" ", t.pointerLen-1) } } else if current { - label = ">" + label = t.pointer } // Avoid unnecessary redraw @@ -879,17 +891,17 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool) { if current { t.window.CPrint(tui.ColCurrentCursor, t.strong, label) if selected { - t.window.CPrint(tui.ColCurrentSelected, t.strong, ">") + t.window.CPrint(tui.ColCurrentSelected, t.strong, t.marker) } else { - t.window.CPrint(tui.ColCurrentSelected, t.strong, " ") + t.window.CPrint(tui.ColCurrentSelected, t.strong, t.markerEmpty) } newLine.width = t.printHighlighted(result, t.strong, tui.ColCurrent, tui.ColCurrentMatch, true, true) } else { t.window.CPrint(tui.ColCursor, t.strong, label) if selected { - t.window.CPrint(tui.ColSelected, t.strong, ">") + t.window.CPrint(tui.ColSelected, t.strong, t.marker) } else { - t.window.Print(" ") + t.window.Print(t.markerEmpty) } newLine.width = t.printHighlighted(result, 0, tui.ColNormal, tui.ColMatch, false, true) } diff --git a/test/test_go.rb b/test/test_go.rb index d2fe1809f42..22862d7d773 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -1407,6 +1407,39 @@ def test_jump_accept assert_equal '3', readonce.chomp end + def test_pointer + pointer = '>>' + tmux.send_keys "seq 10 | #{fzf "--pointer '#{pointer}'"}", :Enter + tmux.until { |lines| lines[-2] == ' 10/10' } + lines = tmux.capture + # Assert that specified pointer is displayed + assert_equal "#{pointer} 1", lines[-3] + end + + def test_pointer_with_jump + pointer = '>>' + tmux.send_keys "seq 10 | #{fzf "--multi --jump-labels 12345 --bind 'ctrl-j:jump' --pointer '#{pointer}'"}", :Enter + tmux.until { |lines| lines[-2] == ' 10/10' } + tmux.send_keys 'C-j' + # Correctly padded jump label should appear + tmux.until { |lines| lines[-7] == '5 5' } + tmux.until { |lines| lines[-8] == ' 6' } + tmux.send_keys '5' + lines = tmux.capture + # Assert that specified pointer is displayed + assert_equal "#{pointer} 5", lines[-7] + end + + def test_marker + marker = '>>' + tmux.send_keys "seq 10 | #{fzf "--multi --marker '#{marker}'"}", :Enter + tmux.until { |lines| lines[-2] == ' 10/10' } + tmux.send_keys :BTab + lines = tmux.capture + # Assert that specified marker is displayed + assert_equal " #{marker}1", lines[-3] + end + def test_preview tmux.send_keys %(seq 1000 | sed s/^2$// | #{FZF} -m --preview 'sleep 0.2; echo {{}-{+}}' --bind ?:toggle-preview), :Enter tmux.until { |lines| lines[1].include?(' {1-1}') }