Repository: junegunn/fzf
Pull request #1844 (2020-01-29 02:56:46)

Make pointer and selected sign customizable
Thank you for developing and maintaining this state-of-the-art CLI.

I opened this PR because as well as prompt, pointer sign and selected signs (in multi-select mode) should also be customizable with command line arguments.

![fzf_demo](https://user-images.githubusercontent.com/6816040/73324272-c91a7e00-428d-11ea-9b20-d5c8ab327b92.gif)

Such feature, for example, would be useful in a case, where UI-provider really want users to know "WHAT" will happen ( `multi-delete` :fire: or `multi-star` ⭐️ ) with emoji.

relastle: Sorry, this PR is currently behind the upstream HEAD, i will fix it now.
junegunn: Thanks for your interest in the project. This was suggested a few times in the past, but I rejected the idea because I felt that adding it would eventually lead to feature creep. You can read my previous comments on that matter:

- https://github.com/junegunn/fzf/issues/692#issuecomment-251436739
- https://github.com/junegunn/fzf/issues/1769#issuecomment-557946235

Fast forward a few years, I'm not sure if I still feel the same. But you made a good point that using different marker signs can provide additional context to the users, so that this is not purely cosmetic.

There are a few things I'd do differently:

- We already use the terms "pointer" and "marker" (see `man fzf`), so I would go for more concise option names: `--pointer` and `--marker`
- I'd like to limit the width of the signs to 2 (Emojis or CJK wide characters are allowed), and make sure that they don't contain `\n` or `\t` so we have less to worry about screen overflow issues: (`fzf --pointer "$(seq 100)"`)
- Make it compatible with `jump` feature.
- Test cases would be nice.

relastle: @junegunn 

Thank you for the insightful review comments.

>We already use the terms "pointer" and "marker" (see man fzf), so I would go for more concise option names: --pointer and --marker

I'm sorry for not following the existing naming concistency.
It was fixed in (https://github.com/junegunn/fzf/pull/1844/commits/2aff65ccafcd306946e439c1793f9d0301dd1e6d)

> I'd like to limit the width of the signs to 2 (Emojis or CJK wide characters are allowed), and make sure that they don't contain \n or \t so we have less to worry about screen overflow issues: (say no to fzf --pointer "$(seq 100)")

That's really reasonable. I added option parameter validation and some table-driven test-cases (https://github.com/junegunn/fzf/pull/1844/commits/c54dbc30d13a7636abc422efecdf74d6d8eda521) 

> Make it compatible with jump feature.

I forgot to test new functions working with that feature.
It was fixed in (https://github.com/junegunn/fzf/pull/1844/commits/df6b1441f29ffdf32c79c75100a6d2a169c5d81f)

-- -

Finally, I personally don't think that `pointer` and `marker` display length should be absolutely less than or equal `2`. (i.g. Someone may want to use `>>>` as pointer 🤔 )
So I implemented the logics so that only few changes should be done when the limit of display length would be longer (than 2) in the future.


relastle: ![fzf_jump_demo](https://user-images.githubusercontent.com/6816040/74601349-93282700-50e0-11ea-88c5-f6169d87951d.gif)

Here is the demonstration of multi-display-width marker and pointer working with the jump feature.
relastle: @junegunn 

Thank you for your reviews!

I amended the description of the options and added three integration test-cases.
relastle: > We should disallow empty pointer or marker.

Exactly. Validation for empty string and its test-case was added in (5b8bca2)


Patch:
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index d6fe163d..b7060af9 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 8b3025ac..9d52a6ba 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 815ac444..3bbeb1c5 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/terminal.go b/src/terminal.go
index e9101a52..bc975546 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)
 	}


Test patch:
diff --git a/src/options_test.go b/src/options_test.go
index 66d7c8f5..b312be11 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/test/test_go.rb b/test/test_go.rb
index d2fe1809..22862d7d 100755
--- a/test/test_go.rb
+++ b/test/test_go.rb
@@ -1407,6 +1407,39 @@ class TestGoFZF < TestBase
     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}') }