Implement change-preview and change-preview-window actions

The new actions are named with 'change-' prefix to differentiate from
the pre-existing, one-off 'preview(...)' action.

Fix #2360
Fix #2505
Fix #2666

Related #2435
Related #2376
  - Can set up multiple bindings with different change-preview-window actions
  - Not possible to "rotate" through the options with a single binding
  - Enlarge or shrink not possible
This commit is contained in:
Junegunn Choi
2021-11-30 23:37:48 +09:00
parent 7da287e3aa
commit 20b4e6953e
5 changed files with 367 additions and 221 deletions

View File

@@ -1,6 +1,13 @@
CHANGELOG CHANGELOG
========= =========
0.29.0
------
- Added `change-preview(...)` action to change the `--preview` command
- cf. `preview(...)` is a one-off action that doesn't change the default
preview command
- Added `change-preview-window(...)` action
0.28.0 0.28.0
------ ------
- Added `--header-first` option to print header before the prompt line - Added `--header-first` option to print header before the prompt line

View File

@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf 1 "Nov 2021" "fzf 0.28.0" "fzf - a command-line fuzzy finder" .TH fzf 1 "Dec 2021" "fzf 0.29.0" "fzf - a command-line fuzzy finder"
.SH NAME .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@@ -820,6 +820,8 @@ A key or an event can be bound to one or more of the following actions.
\fBbackward-word\fR \fIalt-b shift-left\fR \fBbackward-word\fR \fIalt-b shift-left\fR
\fBbeginning-of-line\fR \fIctrl-a home\fR \fBbeginning-of-line\fR \fIctrl-a home\fR
\fBcancel\fR (clear query string if not empty, abort fzf otherwise) \fBcancel\fR (clear query string if not empty, abort fzf otherwise)
\fBchange-preview(...)\fR (change \fB--preview\fR option)
\fBchange-preview-window(...)\fR (change \fB--preview-window\fR option)
\fBchange-prompt(...)\fR (change prompt to the given string) \fBchange-prompt(...)\fR (change prompt to the given string)
\fBclear-screen\fR \fIctrl-l\fR \fBclear-screen\fR \fIctrl-l\fR
\fBclear-selection\fR (clear multi-selection) \fBclear-selection\fR (clear multi-selection)

View File

@@ -176,6 +176,14 @@ type previewOpts struct {
headerLines int headerLines int
} }
func (a previewOpts) sameLayout(b previewOpts) bool {
return a.size == b.size && a.position == b.position && a.border == b.border && a.hidden == b.hidden
}
func (a previewOpts) sameContentLayout(b previewOpts) bool {
return a.wrap == b.wrap && a.headerLines == b.headerLines
}
// Options stores the values of command-line options // Options stores the values of command-line options
type Options struct { type Options struct {
Fuzzy bool Fuzzy bool
@@ -787,7 +795,7 @@ func init() {
// Backreferences are not supported. // Backreferences are not supported.
// "~!@#$%^&*;/|".each_char.map { |c| Regexp.escape(c) }.map { |c| "#{c}[^#{c}]*#{c}" }.join('|') // "~!@#$%^&*;/|".each_char.map { |c| Regexp.escape(c) }.map { |c| "#{c}[^#{c}]*#{c}" }.join('|')
executeRegexp = regexp.MustCompile( executeRegexp = regexp.MustCompile(
`(?si)[:+](execute(?:-multi|-silent)?|reload|preview|change-prompt|unbind):.+|[:+](execute(?:-multi|-silent)?|reload|preview|change-prompt|unbind)(\([^)]*\)|\[[^\]]*\]|~[^~]*~|![^!]*!|@[^@]*@|\#[^\#]*\#|\$[^\$]*\$|%[^%]*%|\^[^\^]*\^|&[^&]*&|\*[^\*]*\*|;[^;]*;|/[^/]*/|\|[^\|]*\|)`) `(?si)[:+](execute(?:-multi|-silent)?|reload|preview|change-prompt|change-preview-window|change-preview|unbind):.+|[:+](execute(?:-multi|-silent)?|reload|preview|change-prompt|change-preview-window|change-preview|unbind)(\([^)]*\)|\[[^\]]*\]|~[^~]*~|![^!]*!|@[^@]*@|\#[^\#]*\#|\$[^\$]*\$|%[^%]*%|\^[^\^]*\^|&[^&]*&|\*[^\*]*\*|;[^;]*;|/[^/]*/|\|[^\|]*\|)`)
} }
func parseKeymap(keymap map[tui.Event][]action, str string) { func parseKeymap(keymap map[tui.Event][]action, str string) {
@@ -799,6 +807,10 @@ func parseKeymap(keymap map[tui.Event][]action, str string) {
prefix := symbol + "execute" prefix := symbol + "execute"
if strings.HasPrefix(src[1:], "reload") { if strings.HasPrefix(src[1:], "reload") {
prefix = symbol + "reload" prefix = symbol + "reload"
} else if strings.HasPrefix(src[1:], "change-preview-window") {
prefix = symbol + "change-preview-window"
} else if strings.HasPrefix(src[1:], "change-preview") {
prefix = symbol + "change-preview"
} else if strings.HasPrefix(src[1:], "preview") { } else if strings.HasPrefix(src[1:], "preview") {
prefix = symbol + "preview" prefix = symbol + "preview"
} else if strings.HasPrefix(src[1:], "unbind") { } else if strings.HasPrefix(src[1:], "unbind") {
@@ -1002,6 +1014,10 @@ func parseKeymap(keymap map[tui.Event][]action, str string) {
offset = len("reload") offset = len("reload")
case actPreview: case actPreview:
offset = len("preview") offset = len("preview")
case actChangePreviewWindow:
offset = len("change-preview-window")
case actChangePreview:
offset = len("change-preview")
case actChangePrompt: case actChangePrompt:
offset = len("change-prompt") offset = len("change-prompt")
case actUnbind: case actUnbind:
@@ -1028,6 +1044,9 @@ func parseKeymap(keymap map[tui.Event][]action, str string) {
} }
if t == actUnbind { if t == actUnbind {
parseKeyChords(actionArg, "unbind target required") parseKeyChords(actionArg, "unbind target required")
} else if t == actChangePreviewWindow {
opts := previewOpts{}
parsePreviewWindow(&opts, actionArg)
} }
} }
} }
@@ -1053,6 +1072,10 @@ func isExecuteAction(str string) actionType {
return actUnbind return actUnbind
case "preview": case "preview":
return actPreview return actPreview
case "change-preview-window":
return actChangePreviewWindow
case "change-preview":
return actChangePreview
case "change-prompt": case "change-prompt":
return actChangePrompt return actChangePrompt
case "execute": case "execute":
@@ -1633,11 +1656,29 @@ func postProcessOptions(opts *Options) {
// Extend the default key map // Extend the default key map
keymap := defaultKeymap() keymap := defaultKeymap()
for key, actions := range opts.Keymap { for key, actions := range opts.Keymap {
lastChangePreviewWindow := action{t: actIgnore}
for _, act := range actions { for _, act := range actions {
if act.t == actToggleSort { switch act.t {
case actToggleSort:
// To display "+S"/"-S" on info line
opts.ToggleSort = true opts.ToggleSort = true
case actChangePreviewWindow:
lastChangePreviewWindow = act
} }
} }
// Re-organize actions so that we only keep the last change-preview-window
// and it comes first in the list.
// * change-preview-window(up,+10)+preview(sleep 3; cat {})+change-preview-window(up,+20)
// -> change-preview-window(up,+20)+preview(sleep 3; cat {})
if lastChangePreviewWindow.t == actChangePreviewWindow {
reordered := []action{lastChangePreviewWindow}
for _, act := range actions {
if act.t != actChangePreviewWindow {
reordered = append(reordered, act)
}
}
actions = reordered
}
keymap[key] = actions keymap[key] = actions
} }
opts.Keymap = keymap opts.Keymap = keymap

View File

@@ -170,6 +170,7 @@ type Terminal struct {
selected map[int32]selectedItem selected map[int32]selectedItem
version int64 version int64
reqBox *util.EventBox reqBox *util.EventBox
initialPreviewOpts previewOpts
previewOpts previewOpts previewOpts previewOpts
previewer previewer previewer previewer
previewed previewed previewed previewed
@@ -286,6 +287,8 @@ const (
actTogglePreview actTogglePreview
actTogglePreviewWrap actTogglePreviewWrap
actPreview actPreview
actChangePreview
actChangePreviewWindow
actPreviewTop actPreviewTop
actPreviewBottom actPreviewBottom
actPreviewUp actPreviewUp
@@ -326,6 +329,7 @@ type searchRequest struct {
type previewRequest struct { type previewRequest struct {
template string template string
pwindow tui.Window pwindow tui.Window
scrollOffset int
list []*Item list []*Item
} }
@@ -416,7 +420,7 @@ func trimQuery(query string) []rune {
func hasPreviewAction(opts *Options) bool { func hasPreviewAction(opts *Options) bool {
for _, actions := range opts.Keymap { for _, actions := range opts.Keymap {
for _, action := range actions { for _, action := range actions {
if action.t == actPreview { if action.t == actPreview || action.t == actChangePreview {
return true return true
} }
} }
@@ -547,6 +551,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
merger: EmptyMerger, merger: EmptyMerger,
selected: make(map[int32]selectedItem), selected: make(map[int32]selectedItem),
reqBox: util.NewEventBox(), reqBox: util.NewEventBox(),
initialPreviewOpts: opts.Preview,
previewOpts: opts.Preview, previewOpts: opts.Preview,
previewer: previewer{0, []string{}, 0, showPreviewWindow, false, true, false, ""}, previewer: previewer{0, []string{}, 0, showPreviewWindow, false, true, false, ""},
previewed: previewed{0, 0, 0, false}, previewed: previewed{0, 0, 0, false},
@@ -1642,9 +1647,14 @@ func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input str
template, t.ansi, t.delimiter, t.printsep, forcePlus, input, list) template, t.ansi, t.delimiter, t.printsep, forcePlus, input, list)
} }
func (t *Terminal) evaluateScrollOffset(list []*Item, height int) int { func (t *Terminal) evaluateScrollOffset() int {
if t.pwindow == nil {
return 0
}
// We only need the current item to calculate the scroll offset
offsetExpr := offsetTrimCharsRegex.ReplaceAllString( offsetExpr := offsetTrimCharsRegex.ReplaceAllString(
t.replacePlaceholder(t.previewOpts.scroll, false, "", list), "") t.replacePlaceholder(t.previewOpts.scroll, false, "", []*Item{t.currentItem(), nil}), "")
atoi := func(s string) int { atoi := func(s string) int {
n, e := strconv.Atoi(s) n, e := strconv.Atoi(s)
@@ -1655,20 +1665,21 @@ func (t *Terminal) evaluateScrollOffset(list []*Item, height int) int {
} }
base := -1 base := -1
height := util.Max(0, t.pwindow.Height()-t.previewOpts.headerLines)
for _, component := range offsetComponentRegex.FindAllString(offsetExpr, -1) { for _, component := range offsetComponentRegex.FindAllString(offsetExpr, -1) {
if strings.HasPrefix(component, "-/") { if strings.HasPrefix(component, "-/") {
component = component[1:] component = component[1:]
} }
if component[0] == '/' { if component[0] == '/' {
denom := atoi(component[1:]) denom := atoi(component[1:])
if denom == 0 { if denom != 0 {
return base base -= height / denom
} }
return base - height/denom break
} }
base += atoi(component) base += atoi(component)
} }
return base return util.Max(0, base)
} }
func replacePlaceholder(template string, stripAnsi bool, delimiter Delimiter, printsep string, forcePlus bool, query string, allItems []*Item) string { func replacePlaceholder(template string, stripAnsi bool, delimiter Delimiter, printsep string, forcePlus bool, query string, allItems []*Item) string {
@@ -1972,12 +1983,14 @@ func (t *Terminal) Loop() {
var items []*Item var items []*Item
var commandTemplate string var commandTemplate string
var pwindow tui.Window var pwindow tui.Window
initialOffset := 0
t.previewBox.Wait(func(events *util.Events) { t.previewBox.Wait(func(events *util.Events) {
for req, value := range *events { for req, value := range *events {
switch req { switch req {
case reqPreviewEnqueue: case reqPreviewEnqueue:
request := value.(previewRequest) request := value.(previewRequest)
commandTemplate = request.template commandTemplate = request.template
initialOffset = request.scrollOffset
items = request.list items = request.list
pwindow = request.pwindow pwindow = request.pwindow
} }
@@ -1989,11 +2002,9 @@ func (t *Terminal) Loop() {
if items[0] != nil { if items[0] != nil {
_, query := t.Input() _, query := t.Input()
command := t.replacePlaceholder(commandTemplate, false, string(query), items) command := t.replacePlaceholder(commandTemplate, false, string(query), items)
initialOffset := 0
cmd := util.ExecCommand(command, true) cmd := util.ExecCommand(command, true)
if pwindow != nil { if pwindow != nil {
height := pwindow.Height() height := pwindow.Height()
initialOffset = util.Max(0, t.evaluateScrollOffset(items, util.Max(0, height-t.previewOpts.headerLines)))
env := os.Environ() env := os.Environ()
lines := fmt.Sprintf("LINES=%d", height) lines := fmt.Sprintf("LINES=%d", height)
columns := fmt.Sprintf("COLUMNS=%d", pwindow.Width()) columns := fmt.Sprintf("COLUMNS=%d", pwindow.Width())
@@ -2128,7 +2139,7 @@ func (t *Terminal) Loop() {
if len(command) > 0 && t.isPreviewEnabled() { if len(command) > 0 && t.isPreviewEnabled() {
_, list := t.buildPlusList(command, false) _, list := t.buildPlusList(command, false)
t.cancelPreview() t.cancelPreview()
t.previewBox.Set(reqPreviewEnqueue, previewRequest{command, t.pwindow, list}) t.previewBox.Set(reqPreviewEnqueue, previewRequest{command, t.pwindow, t.evaluateScrollOffset(), list})
} }
} }
@@ -2253,13 +2264,16 @@ func (t *Terminal) Loop() {
} }
} }
} }
togglePreview := func(enabled bool) { togglePreview := func(enabled bool) bool {
if t.previewer.enabled != enabled { if t.previewer.enabled != enabled {
t.previewer.enabled = enabled t.previewer.enabled = enabled
// We need to immediately update t.pwindow so we don't use reqRedraw
t.tui.Clear() t.tui.Clear()
t.resizeWindows() t.resizeWindows()
req(reqPrompt, reqList, reqInfo, reqHeader) req(reqPrompt, reqList, reqInfo, reqHeader)
return true
} }
return false
} }
toggle := func() bool { toggle := func() bool {
current := t.currentItem() current := t.currentItem()
@@ -2327,7 +2341,7 @@ func (t *Terminal) Loop() {
if valid { if valid {
t.cancelPreview() t.cancelPreview()
t.previewBox.Set(reqPreviewEnqueue, t.previewBox.Set(reqPreviewEnqueue,
previewRequest{t.previewOpts.command, t.pwindow, list}) previewRequest{t.previewOpts.command, t.pwindow, t.evaluateScrollOffset(), list})
} }
} }
} }
@@ -2707,6 +2721,39 @@ func (t *Terminal) Loop() {
for key := range keys { for key := range keys {
delete(t.keymap, key) delete(t.keymap, key)
} }
case actChangePreview:
if t.previewOpts.command != a.a {
togglePreview(len(a.a) > 0)
t.previewOpts.command = a.a
refreshPreview(t.previewOpts.command)
}
case actChangePreviewWindow:
currentPreviewOpts := t.previewOpts
// Reset preview options and apply the additional options
t.previewOpts = t.initialPreviewOpts
parsePreviewWindow(&t.previewOpts, a.a)
if t.previewOpts.hidden {
togglePreview(false)
} else {
// Full redraw
if !currentPreviewOpts.sameLayout(t.previewOpts) {
if togglePreview(true) {
refreshPreview(t.previewOpts.command)
} else {
req(reqRedraw)
}
} else if !currentPreviewOpts.sameContentLayout(t.previewOpts) {
t.previewed.version = 0
req(reqPreviewRefresh)
}
// Adjust scroll offset
if t.hasPreviewWindow() && currentPreviewOpts.scroll != t.previewOpts.scroll {
scrollPreviewTo(t.evaluateScrollOffset())
}
}
} }
return true return true
} }

View File

@@ -2142,6 +2142,55 @@ class TestGoFZF < TestBase
assert_equal expected.chomp, lines.take(6).join("\n") assert_equal expected.chomp, lines.take(6).join("\n")
end end
end end
def test_change_preview_window
tmux.send_keys "seq 1000 | #{FZF} --preview 'echo [[{}]]' --preview-window border-none --bind '" \
'a:change-preview(echo __{}__),' \
'b:change-preview-window(down)+change-preview(echo =={}==)+change-preview-window(up),' \
'c:change-preview(),d:change-preview-window(hidden),' \
"e:preview(printf ::%${FZF_PREVIEW_COLUMNS}s{})+change-preview-window(up),f:change-preview-window(up,wrap)'", :Enter
tmux.until { |lines| assert_equal 1000, lines.item_count }
tmux.until { |lines| assert_includes lines[0], '[[1]]' }
# change-preview action permanently changes the preview command set by --preview
tmux.send_keys 'a'
tmux.until { |lines| assert_includes lines[0], '__1__' }
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines[0], '__2__' }
# When multiple change-preview-window actions are bound to a single key,
# the last one wins and the updated options are immediately applied to the new preview
tmux.send_keys 'b'
tmux.until { |lines| assert_equal '==2==', lines[0] }
tmux.send_keys :Up
tmux.until { |lines| assert_equal '==3==', lines[0] }
# change-preview with an empty preview command closes the preview window
tmux.send_keys 'c'
tmux.until { |lines| refute_includes lines[0], '==' }
# change-preview again to re-open the preview window
tmux.send_keys 'a'
tmux.until { |lines| assert_equal '__3__', lines[0] }
# Hide the preview window with hidden flag
tmux.send_keys 'd'
tmux.until { |lines| refute_includes lines[0], '__3__' }
# One-off preview
tmux.send_keys 'e'
tmux.until do |lines|
assert_equal '::', lines[0]
refute_includes lines[1], '3'
end
# Wrapped
tmux.send_keys 'f'
tmux.until do |lines|
assert_equal '::', lines[0]
assert_equal ' 3', lines[1]
end
end
end end
module TestShell module TestShell