From 5cd6f1d06427f1e023573be084e384227fae3cdf Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 1 Jan 2023 14:48:14 +0900 Subject: [PATCH] Add scrollbar Close #3096 --- CHANGELOG.md | 8 ++++++ man/man1/fzf.1 | 9 +++++++ src/options.go | 23 ++++++++++++++++++ src/terminal.go | 63 ++++++++++++++++++++++++++++++++++++++++++++---- src/tui/light.go | 5 +++- src/tui/tui.go | 9 +++++++ test/test_go.rb | 8 +++--- 7 files changed, 116 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e612645..d2838d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ CHANGELOG # Send actions to the server curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )' ``` +- Added scrollbar on the main search window + ```sh + # Hide scrollbar + fzf --no-scrollbar + + # Customize scrollbar + fzf --scrollbar ┆ --color scrollbar:blue + ``` - New event - Added `load` event that is triggered when the input stream is complete and the initial processing of the list is complete. diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index e981a13..9ac213c 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -356,6 +356,15 @@ ANSI color codes are supported. Do not display horizontal separator on the info line. A synonym for \fB--separator=''\fB +.TP +.BI "--scrollbar=" "CHAR" +Use the given character to render scrollbar. (default: '▏' or ':' depending on +\fB--no-unicode\fR). + +.TP +.B "--no-scrollbar" +Do not display scrollbar. A synonym for \fB--scrollbar=''\fB + .TP .BI "--prompt=" "STR" Input prompt (default: '> ') diff --git a/src/options.go b/src/options.go index 0ee7db3..e4a6aa3 100644 --- a/src/options.go +++ b/src/options.go @@ -73,6 +73,8 @@ const usage = `usage: fzf [options] --info=STYLE Finder info style [default|inline|hidden] --separator=STR String to form horizontal separator on info line --no-separator Hide info line separator + --scrollbar[=CHAR] Scrollbar character + --no-scrollbar Hide scrollbar --prompt=STR Input prompt (default: '> ') --pointer=STR Pointer to the current line (default: '>') --marker=STR Multi-select marker (default: '>') @@ -290,6 +292,7 @@ type Options struct { HeaderLines int HeaderFirst bool Ellipsis string + Scrollbar *string Margin [4]sizeSpec Padding [4]sizeSpec BorderShape tui.BorderShape @@ -359,6 +362,7 @@ func defaultOptions() *Options { HeaderLines: 0, HeaderFirst: false, Ellipsis: "..", + Scrollbar: nil, Margin: defaultMargin(), Padding: defaultMargin(), Unicode: true, @@ -847,6 +851,8 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) *tui.ColorTheme { mergeAttr(&theme.Border) case "separator": mergeAttr(&theme.Separator) + case "scrollbar": + mergeAttr(&theme.Scrollbar) case "label": mergeAttr(&theme.BorderLabel) case "preview-label": @@ -1570,6 +1576,16 @@ func parseOptions(opts *Options, allArgs []string) { case "--no-separator": nosep := "" opts.Separator = &nosep + case "--scrollbar": + given, bar := optionalNextString(allArgs, &i) + if given { + opts.Scrollbar = &bar + } else { + opts.Scrollbar = nil + } + case "--no-scrollbar": + noBar := "" + opts.Scrollbar = &noBar case "--jump-labels": opts.JumpLabels = nextString(allArgs, &i, "label characters required") validateJumpLabels = true @@ -1739,6 +1755,8 @@ func parseOptions(opts *Options, allArgs []string) { opts.InfoStyle = parseInfoStyle(value) } else if match, value := optString(arg, "--separator="); match { opts.Separator = &value + } else if match, value := optString(arg, "--scrollbar="); match { + opts.Scrollbar = &value } else if match, value := optString(arg, "--toggle-sort="); match { parseToggleSort(opts.Keymap, value) } else if match, value := optString(arg, "--expect="); match { @@ -1845,6 +1863,11 @@ func postProcessOptions(opts *Options) { if !opts.Version && !tui.IsLightRendererSupported() && opts.Height.size > 0 { errorExit("--height option is currently not supported on this platform") } + + if opts.Scrollbar != nil && runewidth.StringWidth(*opts.Scrollbar) > 1 { + errorExit("scrollbar display width should be 1") + } + // Default actions for CTRL-N / CTRL-P when --history is set if opts.History != nil { if _, prs := opts.Keymap[tui.CtrlP.AsEvent()]; !prs { diff --git a/src/terminal.go b/src/terminal.go index a89b6ac..ed585e9 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -97,6 +97,7 @@ type itemLine struct { label string queryLen int width int + bar bool result Result } @@ -161,6 +162,7 @@ type Terminal struct { header []string header0 []string ellipsis string + scrollbar string ansi bool tabstop int margin [4]sizeSpec @@ -632,6 +634,15 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { } t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true) } + if opts.Scrollbar == nil { + if t.unicode { + t.scrollbar = "▏" // Left one eighth block + } else { + t.scrollbar = "|" + } + } else { + t.scrollbar = *opts.Scrollbar + } _, t.hasLoadActions = t.keymap[tui.Load.AsEvent()] @@ -763,6 +774,27 @@ func (t *Terminal) noInfoLine() bool { return t.infoStyle != infoDefault } +func (t *Terminal) getScrollbar() (int, int) { + total := t.merger.Length() + if total == 0 { + return 0, 0 + } + + maxItems := t.maxItems() + barLength := util.Max(1, maxItems*maxItems/total) + if total <= maxItems { + return 0, 0 + } + + var barStart int + if total == maxItems { + barStart = 0 + } else { + barStart = (maxItems - barLength) * t.offset / (total - maxItems) + } + return barLength, barStart +} + // Input returns current query string func (t *Terminal) Input() (bool, []rune) { t.mutex.Lock() @@ -1349,6 +1381,7 @@ func (t *Terminal) printHeader() { func (t *Terminal) printList() { t.constrain() + barLength, barStart := t.getScrollbar() maxy := t.maxItems() count := t.merger.Length() - t.offset @@ -1362,7 +1395,7 @@ func (t *Terminal) printList() { line-- } if i < count { - t.printItem(t.merger.Get(i+t.offset), line, i, i == t.cy-t.offset) + t.printItem(t.merger.Get(i+t.offset), line, i, i == t.cy-t.offset, i >= barStart && i < barStart+barLength) } else if t.prevLines[i] != emptyLine { t.prevLines[i] = emptyLine t.move(line, 0, true) @@ -1370,7 +1403,7 @@ func (t *Terminal) printList() { } } -func (t *Terminal) printItem(result Result, line int, i int, current bool) { +func (t *Terminal) printItem(result Result, line int, i int, current bool, bar bool) { item := result.item _, selected := t.selected[item.Index()] label := "" @@ -1386,7 +1419,7 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool) { // Avoid unnecessary redraw newLine := itemLine{current: current, selected: selected, label: label, - result: result, queryLen: len(t.input), width: 0} + result: result, queryLen: len(t.input), width: 0, bar: bar} prevLine := t.prevLines[i] if prevLine.current == newLine.current && prevLine.selected == newLine.selected && @@ -1426,6 +1459,12 @@ func (t *Terminal) printItem(result Result, line int, i int, current bool) { if fillSpaces > 0 { t.window.Print(strings.Repeat(" ", fillSpaces)) } + if len(t.scrollbar) > 0 && bar != prevLine.bar { + t.move(line, t.window.Width()-1, true) + if bar { + t.window.CPrint(tui.ColScrollbar, t.scrollbar) + } + } t.prevLines[i] = newLine } @@ -2999,8 +3038,9 @@ func (t *Terminal) Loop() { } } else if t.window.Enclose(my, mx) { mx -= t.window.Left() - my -= t.window.Top() + bar := mx == t.window.Width()-1 mx = util.Constrain(mx-t.promptLen, 0, len(t.input)) + my -= t.window.Top() min := 2 + len(t.header) if t.noInfoLine() { min-- @@ -3016,7 +3056,20 @@ func (t *Terminal) Loop() { my = h - my - 1 } } - if me.Double { + if bar && my >= min { + barLength, barStart := t.getScrollbar() + if barLength > 0 { + maxItems := t.maxItems() + if newBarStart := util.Constrain(my-min-barLength/2, 0, maxItems-barLength); newBarStart != barStart { + total := t.merger.Length() + prevOffset := t.offset + // barStart = (maxItems - barLength) * t.offset / (total - maxItems) + t.offset = int(math.Ceil(float64(newBarStart) * float64(total-maxItems) / float64(maxItems-barLength))) + t.cy = t.offset + t.cy - prevOffset + req(reqList) + } + } + } else if me.Double { // Double-click if my >= min { if t.vset(t.offset+my-min) && t.cy < t.merger.Length() { diff --git a/src/tui/light.go b/src/tui/light.go index 4225fc5..83020a7 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -176,6 +176,7 @@ func (r *LightRenderer) Init() { if r.mouse { r.csi("?1000h") + r.csi("?1002h") r.csi("?1006h") } r.csi(fmt.Sprintf("%dA", r.MaxY()-1)) @@ -569,12 +570,14 @@ func (r *LightRenderer) mouseSequence(sz *int) Event { // ctrl := t & 0b1000 mod := t&0b1100 > 0 + drag := t&0b100000 > 0 + if scroll != 0 { return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, mod}} } double := false - if down { + if down && !drag { now := time.Now() if !left { // Right double click is not allowed r.clickY = []int{} diff --git a/src/tui/tui.go b/src/tui/tui.go index d1bb571..e0fc633 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -269,6 +269,7 @@ type ColorTheme struct { Selected ColorAttr Header ColorAttr Separator ColorAttr + Scrollbar ColorAttr Border ColorAttr BorderLabel ColorAttr PreviewLabel ColorAttr @@ -466,6 +467,7 @@ var ( ColInfo ColorPair ColHeader ColorPair ColSeparator ColorPair + ColScrollbar ColorPair ColBorder ColorPair ColPreview ColorPair ColPreviewBorder ColorPair @@ -490,6 +492,7 @@ func EmptyTheme() *ColorTheme { Selected: ColorAttr{colUndefined, AttrUndefined}, Header: ColorAttr{colUndefined, AttrUndefined}, Separator: ColorAttr{colUndefined, AttrUndefined}, + Scrollbar: ColorAttr{colUndefined, AttrUndefined}, Border: ColorAttr{colUndefined, AttrUndefined}, BorderLabel: ColorAttr{colUndefined, AttrUndefined}, Disabled: ColorAttr{colUndefined, AttrUndefined}, @@ -517,6 +520,7 @@ func NoColorTheme() *ColorTheme { Selected: ColorAttr{colDefault, AttrRegular}, Header: ColorAttr{colDefault, AttrRegular}, Separator: ColorAttr{colDefault, AttrRegular}, + Scrollbar: ColorAttr{colDefault, AttrRegular}, Border: ColorAttr{colDefault, AttrRegular}, BorderLabel: ColorAttr{colDefault, AttrRegular}, Disabled: ColorAttr{colDefault, AttrRegular}, @@ -549,6 +553,7 @@ func init() { Selected: ColorAttr{colMagenta, AttrUndefined}, Header: ColorAttr{colCyan, AttrUndefined}, Separator: ColorAttr{colBlack, AttrUndefined}, + Scrollbar: ColorAttr{colBlack, AttrUndefined}, Border: ColorAttr{colBlack, AttrUndefined}, BorderLabel: ColorAttr{colWhite, AttrUndefined}, Disabled: ColorAttr{colUndefined, AttrUndefined}, @@ -573,6 +578,7 @@ func init() { Selected: ColorAttr{168, AttrUndefined}, Header: ColorAttr{109, AttrUndefined}, Separator: ColorAttr{59, AttrUndefined}, + Scrollbar: ColorAttr{59, AttrUndefined}, Border: ColorAttr{59, AttrUndefined}, BorderLabel: ColorAttr{145, AttrUndefined}, Disabled: ColorAttr{colUndefined, AttrUndefined}, @@ -597,6 +603,7 @@ func init() { Selected: ColorAttr{168, AttrUndefined}, Header: ColorAttr{31, AttrUndefined}, Separator: ColorAttr{145, AttrUndefined}, + Scrollbar: ColorAttr{145, AttrUndefined}, Border: ColorAttr{145, AttrUndefined}, BorderLabel: ColorAttr{59, AttrUndefined}, Disabled: ColorAttr{colUndefined, AttrUndefined}, @@ -645,6 +652,7 @@ func initTheme(theme *ColorTheme, baseTheme *ColorTheme, forceBlack bool) { theme.PreviewFg = o(theme.Fg, theme.PreviewFg) theme.PreviewBg = o(theme.Bg, theme.PreviewBg) theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel) + theme.Scrollbar = o(theme.Separator, theme.Scrollbar) initPalette(theme) } @@ -677,6 +685,7 @@ func initPalette(theme *ColorTheme) { ColInfo = pair(theme.Info, theme.Bg) ColHeader = pair(theme.Header, theme.Bg) ColSeparator = pair(theme.Separator, theme.Bg) + ColScrollbar = pair(theme.Scrollbar, theme.Bg) ColBorder = pair(theme.Border, theme.Bg) ColBorderLabel = pair(theme.BorderLabel, theme.Bg) ColPreviewLabel = pair(theme.PreviewLabel, theme.PreviewBg) diff --git a/test/test_go.rb b/test/test_go.rb index 45c661a..68caa35 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -23,7 +23,7 @@ DEFAULT_TIMEOUT = 10 FILE = File.expand_path(__FILE__) BASE = File.expand_path('..', __dir__) Dir.chdir(BASE) -FZF = "FZF_DEFAULT_OPTS= FZF_DEFAULT_COMMAND= #{BASE}/bin/fzf" +FZF = "FZF_DEFAULT_OPTS=--no-scrollbar FZF_DEFAULT_COMMAND= #{BASE}/bin/fzf" def wait since = Time.now @@ -65,7 +65,7 @@ class Shell end def fish - UNSETS.map { |v| v + '= ' }.join + 'fish' + UNSETS.map { |v| v + '= ' }.join + ' FZF_DEFAULT_OPTS=--no-scrollbar fish' end end end @@ -2908,7 +2908,7 @@ class TestFish < TestBase end def new_shell - tmux.send_keys 'env FZF_TMUX=1 fish', :Enter + tmux.send_keys 'env FZF_TMUX=1 FZF_DEFAULT_OPTS=--no-scrollbar fish', :Enter tmux.send_keys 'function fish_prompt; end; clear', :Enter tmux.until { |lines| assert_empty lines } end @@ -2927,6 +2927,8 @@ unset <%= UNSETS.join(' ') %> unset $(env | sed -n /^_fzf_orig/s/=.*//p) unset $(declare -F | sed -n "/_fzf/s/.*-f //p") +export FZF_DEFAULT_OPTS=--no-scrollbar + # Setup fzf # --------- if [[ ! "$PATH" == *<%= BASE %>/bin* ]]; then