From a67aa85820c2e278e1c32fb8fdfe137523537ccb Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Tue, 16 Sep 2025 21:22:56 +0900 Subject: [PATCH] Style change: thinner gutter column (#4521) --- CHANGELOG.md | 26 +++++++++++++++++ src/options.go | 25 +++++++++++++---- src/options_test.go | 2 +- src/terminal.go | 38 ++++++++++++++++++++++--- src/tui/tui.go | 4 ++- test/lib/common.rb | 2 +- test/test_exec.rb | 2 +- test/test_layout.rb | 68 ++++++++++++++++++++++++++++++++++----------- 8 files changed, 138 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2b30d9..228624a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ CHANGELOG ========= +0.66.0 +------ +- Style changes + - Narrowed the gutter column by using the left-half block character (`▌`). + - Removed background colors from markers. +- Added `--gutter CHAR` option for customizing the gutter column. Some examples using [box-drawing characters](https://en.wikipedia.org/wiki/Box-drawing_characters): + ```sh + # Right-aligned gutter + fzf --gutter '▐' + + # Even thinner gutter + fzf --gutter '▎' + + # Checker + fzf --gutter '▚' + + # Dotted + fzf --gutter '▖' + + # Full-width + fzf --gutter '█' + + # No gutter + fzf --gutter ' ' + ``` + 0.65.2 ------ - Bug fixes and improvements diff --git a/src/options.go b/src/options.go index 76081ca6..dc6ac050 100644 --- a/src/options.go +++ b/src/options.go @@ -590,6 +590,7 @@ type Options struct { Separator *string JumpLabels string Prompt string + Gutter *string Pointer *string Marker *string MarkerMulti *[3]string @@ -710,6 +711,7 @@ func defaultOptions() *Options { Separator: nil, JumpLabels: defaultJumpLabels, Prompt: "> ", + Gutter: nil, Pointer: nil, Marker: nil, MarkerMulti: nil, @@ -2857,6 +2859,13 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { if err != nil { return err } + case "--gutter": + str, err := nextString("gutter character required") + if err != nil { + return err + } + str = firstLine(str) + opts.Gutter = &str case "--pointer": str, err := nextString("pointer sign required") if err != nil { @@ -3355,22 +3364,28 @@ func applyPreset(opts *Options, preset string) error { return nil } -func validateSign(sign string, signOptName string) error { - if uniseg.StringWidth(sign) > 2 { - return fmt.Errorf("%v display width should be up to 2", signOptName) +func validateSign(sign string, signOptName string, maxWidth int) error { + if uniseg.StringWidth(sign) > maxWidth { + return fmt.Errorf("%v display width should be up to %d", signOptName, maxWidth) } return nil } func validateOptions(opts *Options) error { if opts.Pointer != nil { - if err := validateSign(*opts.Pointer, "pointer"); err != nil { + if err := validateSign(*opts.Pointer, "pointer", 2); err != nil { return err } } if opts.Marker != nil { - if err := validateSign(*opts.Marker, "marker"); err != nil { + if err := validateSign(*opts.Marker, "marker", 2); err != nil { + return err + } + } + + if opts.Gutter != nil { + if err := validateSign(*opts.Gutter, "gutter", 1); err != nil { return err } } diff --git a/src/options_test.go b/src/options_test.go index f35c7ee3..a14dece7 100644 --- a/src/options_test.go +++ b/src/options_test.go @@ -462,7 +462,7 @@ func TestValidateSign(t *testing.T) { } for _, testCase := range testCases { - err := validateSign(testCase.inputSign, "") + err := validateSign(testCase.inputSign, "", 2) if testCase.isValid && err != nil { t.Errorf("Input sign `%s` caused error", testCase.inputSign) } diff --git a/src/terminal.go b/src/terminal.go index 11882d8f..ddaad44e 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -272,6 +272,7 @@ type Terminal struct { footerLabel labelPrinter footerLabelLen int footerLabelOpts labelOpts + gutterReverse bool pointer string pointerLen int pointerEmpty string @@ -1094,10 +1095,27 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor // This should be called before accessing tui.Color* tui.InitTheme(opts.Theme, renderer.DefaultTheme(), opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible()) + // Gutter character + var gutterChar string + if opts.Gutter != nil { + gutterChar = *opts.Gutter + } else if t.unicode && !t.theme.Gutter.Color.IsDefault() { + gutterChar = "▌" + } else { + gutterChar = " " + t.gutterReverse = true + } + t.prompt, t.promptLen = t.parsePrompt(opts.Prompt) // Pre-calculated empty pointer and marker signs - t.pointerEmpty = strings.Repeat(" ", t.pointerLen) + if t.pointerLen == 0 { + t.pointerEmpty = "" + } else { + t.pointerEmpty = gutterChar + strings.Repeat(" ", util.Max(0, t.pointerLen-1)) + } t.markerEmpty = strings.Repeat(" ", t.markerLen) + + // Labels t.listLabel, t.listLabelLen = t.ansiLabelPrinter(opts.ListLabel.label, &tui.ColListLabel, false) t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(opts.BorderLabel.label, &tui.ColBorderLabel, false) t.previewLabel, t.previewLabelLen = t.ansiLabelPrinter(opts.PreviewLabel.label, &tui.ColPreviewLabel, false) @@ -3096,9 +3114,21 @@ func (t *Terminal) renderEmptyLine(line int, barRange [2]int) { t.renderBar(line, barRange) } +func (t *Terminal) gutter(current bool) { + var color tui.ColorPair + if current { + color = tui.ColCurrentCursorEmpty + } else if t.gutterReverse { + color = tui.ColCursorEmpty + } else { + color = tui.ColCursorEmptyChar + } + t.window.CPrint(color, t.pointerEmpty) +} + func (t *Terminal) renderGapLine(line int, barRange [2]int, drawLine bool) { t.move(line, 0, false) - t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty) + t.gutter(false) t.window.Print(t.markerEmpty) x := t.pointerLen + t.markerLen @@ -3262,7 +3292,7 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu return indentSize } if len(label) == 0 { - t.window.CPrint(tui.ColCurrentCursorEmpty, t.pointerEmpty) + t.gutter(true) } else { t.window.CPrint(tui.ColCurrentCursor, label) } @@ -3284,7 +3314,7 @@ func (t *Terminal) printItem(result Result, line int, maxLine int, index int, cu return indentSize } if len(label) == 0 { - t.window.CPrint(tui.ColCursorEmpty, t.pointerEmpty) + t.gutter(false) } else { t.window.CPrint(tui.ColCursor, label) } diff --git a/src/tui/tui.go b/src/tui/tui.go index 92154533..965be337 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -776,6 +776,7 @@ var ( ColMatch ColorPair ColCursor ColorPair ColCursorEmpty ColorPair + ColCursorEmptyChar ColorPair ColMarker ColorPair ColSelected ColorPair ColSelectedMatch ColorPair @@ -1168,10 +1169,11 @@ func initPalette(theme *ColorTheme) { ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg) ColCursor = pair(theme.Cursor, theme.Gutter) ColCursorEmpty = pair(blank, theme.Gutter) + ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg) if theme.SelectedBg.Color != theme.ListBg.Color { ColMarker = pair(theme.Marker, theme.SelectedBg) } else { - ColMarker = pair(theme.Marker, theme.Gutter) + ColMarker = pair(theme.Marker, theme.ListBg) } ColCurrent = pair(theme.Current, theme.DarkBg) ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg) diff --git a/test/lib/common.rb b/test/lib/common.rb index ec8b05e9..d93c7685 100644 --- a/test/lib/common.rb +++ b/test/lib/common.rb @@ -24,7 +24,7 @@ DEFAULT_TIMEOUT = 10 FILE = File.expand_path(__FILE__) BASE = File.expand_path('../..', __dir__) Dir.chdir(BASE) -FZF = "FZF_DEFAULT_OPTS=\"--no-scrollbar --pointer \\> --marker \\>\" FZF_DEFAULT_COMMAND= #{BASE}/bin/fzf".freeze +FZF = %[FZF_DEFAULT_OPTS="--no-scrollbar --gutter ' ' --pointer '>' --marker '>'" FZF_DEFAULT_COMMAND= #{BASE}/bin/fzf].freeze def wait(timeout = DEFAULT_TIMEOUT) since = Time.now diff --git a/test/test_exec.rb b/test/test_exec.rb index c02e8c94..339a2bde 100644 --- a/test/test_exec.rb +++ b/test/test_exec.rb @@ -403,7 +403,7 @@ class TestExec < TestInteractive end def test_become - tmux.send_keys "seq 100 | #{FZF} --bind 'enter:become:seq {} | #{FZF}'", :Enter + tmux.send_keys "seq 100 | fzf --bind 'enter:become:seq {} | fzf'", :Enter tmux.until { |lines| assert_equal 100, lines.match_count } tmux.send_keys 999 tmux.until { |lines| assert_equal 0, lines.match_count } diff --git a/test/test_layout.rb b/test/test_layout.rb index fd92cd63..db04c044 100644 --- a/test/test_layout.rb +++ b/test/test_layout.rb @@ -178,8 +178,8 @@ class TestLayout < TestInteractive tmux.send_keys 'seq 3 | fzf --height ~100% --info=inline --border rounded', :Enter expected = <<~OUTPUT ╭────────── - │ 3 - │ 2 + │ ▌ 3 + │ ▌ 2 │ > 1 │ > < 3/3 ╰────────── @@ -197,8 +197,8 @@ class TestLayout < TestInteractive │ │ │ │ │ ╰──────── - │ 3 - │ 2 + │ ▌ 3 + │ ▌ 2 │ > 1 │ > < 3/3 ╰────────── @@ -247,7 +247,7 @@ class TestLayout < TestInteractive tmux.send_keys 'seq 100 | fzf --height ~5 --info=inline --border rounded', :Enter expected = <<~OUTPUT ╭────────────── - │ 2 + │ ▌ 2 │ > 1 │ > < 100/100 ╰────────────── @@ -275,12 +275,12 @@ class TestLayout < TestInteractive 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 + │ ▌┃998 + │ ▌┃999 + │ ▌┃1000 + │ ▌╹ + │ ▌╻1 + │ ▌╹2 │ >>0 │ 3/3 (3) │ > @@ -312,11 +312,11 @@ class TestLayout < TestInteractive │ > │ 3/3 (3) │ >>0 - │ ╻1 - │ ╹2 - │ ╻1 - │ ┃2 - │ ┃3 + │ ▌╻1 + │ ▌╹2 + │ ▌╻1 + │ ▌┃2 + │ ▌┃3 BLOCK tmux.until { assert_block(block, it) } end @@ -1156,6 +1156,42 @@ class TestLayout < TestInteractive tmux.until { assert_block(block, it) } end + def test_gutter_default + tmux.send_keys %(seq 10 | fzf), :Enter + block = <<~BLOCK + ▌ 3 + ▌ 2 + > 1 + 10/10 + > + BLOCK + tmux.until { assert_block(block, it) } + end + + def test_gutter_default_no_unicode + tmux.send_keys %(seq 10 | fzf --no-unicode), :Enter + block = <<~BLOCK + 3 + 2 + > 1 + 10/10 + > + BLOCK + tmux.until { assert_block(block, it) } + end + + def test_gutter_custom + tmux.send_keys %(seq 10 | fzf --gutter x), :Enter + block = <<~BLOCK + x 3 + x 2 + > 1 + 10/10 + > + BLOCK + tmux.until { assert_block(block, it) } + end + def test_combinations skip unless ENV['LONGTEST']