diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a70e49..5ddc570e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ CHANGELOG - Fixed `--gap-line` cutting a grapheme cluster when filling the last cells of the line (#4920) - Fixed an escape sequence fzf does not recognize being partly consumed, which leaked the rest into the query (#4926) - Fixed a key lost when it arrived in the same read as an escape sequence +- Fixed `focus` event not triggered after `reload` when the new item is at the same position as the previous one (#4930) + - With `--id-nth`, the event is not triggered if the focus moves directly from an item to another item with the same key + - A `focus` binding that runs `reload` now triggers itself again after each reload, unless `--id-nth` is set and the key does not change 0.74.4 ------ diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 343615c4..07749297 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -1909,7 +1909,10 @@ e.g. \fIfocus\fR .RS Triggered when the focus changes due to a vertical cursor movement or a search -result update. +result update. After a reload, the focused item is considered new unless +\fB\-\-id\-nth\fR is set and the item has the same key as the previously +focused item. Because of this, binding \fBreload\fR to this event can cause an +infinite loop. e.g. \fBfzf \-\-bind 'focus:transform\-preview\-label:echo [ {} ]' \-\-preview 'cat {}' diff --git a/src/terminal.go b/src/terminal.go index ec0e7609..21a2967b 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -467,7 +467,7 @@ type Terminal struct { termSize tui.TermSize lastAction actionType lastKey string - lastFocus int32 + lastFocus focusedItem areaLines int areaColumns int forcePreview bool @@ -492,6 +492,11 @@ type selectedItem struct { item *Item } +type focusedItem struct { + major int // revision.major; changes on reload + item *Item +} + type byTimeOrder []selectedItem func (a byTimeOrder) Len() int { @@ -1186,7 +1191,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor initFunc: func() error { return renderer.Init() }, executing: util.NewAtomicBool(false), lastAction: actStart, - lastFocus: minItem.Index(), + lastFocus: focusedItem{}, lastActivity: time.Now(), numLinesCache: make(map[int32]numLinesCacheValue), // The initial load counts as a search in progress ('start:wait'). @@ -6066,6 +6071,26 @@ func (t *Terminal) currentIndex() int32 { return minItem.Index() } +func (t *Terminal) currentFocus() focusedItem { + return focusedItem{t.revision.major, t.currentItem()} +} + +// Records the current focus and returns whether it differs from the last one. +// Indexes restart after a reload, so items are compared by --id-nth key, if +// set. Without it, any focused item after a reload is a new one. +func (t *Terminal) updateFocus() bool { + curr := t.currentFocus() + last := t.lastFocus + t.lastFocus = curr + if curr.item == nil || last.item == nil { + return curr.item != last.item + } + if curr.major == last.major { + return curr.item.Index() != last.item.Index() + } + return len(t.idNth) == 0 || t.trackKeyFor(curr.item, t.idNth) != t.trackKeyFor(last.item, t.idNth) +} + func (t *Terminal) trackKeyFor(item *Item, nth []Range) string { tokens := Tokenize(item.AsString(t.ansi), t.delimiter) return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter) @@ -6631,8 +6656,7 @@ func (t *Terminal) Loop() error { info = true } focusChanged := focusedIndex != currentIndex - if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus { - t.lastFocus = currentIndex + if (t.hasFocusActions || t.infoCommand != "") && !t.trackBlocked && t.updateFocus() { t.eventChan <- tui.Focus.AsEvent() if t.infoCommand != "" { info = true @@ -7017,9 +7041,11 @@ func (t *Terminal) Loop() error { } } - if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents { - if newIndex := t.currentIndex(); newIndex != currentIndex { - t.lastFocus = newIndex + // Actions are ignored while blocked. The render loop will + // report the focus after the reload. + if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents && !t.trackBlocked { + if t.currentIndex() != currentIndex { + t.lastFocus = t.currentFocus() if t.infoCommand != "" { req(reqInfo) } diff --git a/test/test_core.rb b/test/test_core.rb index f32e24b1..9cf399f7 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1532,6 +1532,61 @@ class TestCore < TestInteractive tmux.until { |lines| refute_includes(lines[-1], '[[1]]') } end + def assert_focus_log(expected) + wait do + assert_path_exists tempname + assert_equal expected, File.readlines(tempname, chomp: true) + end + end + + def test_focus_event_after_reload + # Without --id-nth, the item at the same index after reload is a new item + tmux.send_keys %(printf 'a\\nb\\n' | #{FZF} --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "c\\nd\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> a' } + assert_focus_log(%w[a]) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines, '> c' } + assert_focus_log(%w[a c]) + end + + def test_focus_event_after_reload_id_nth_same_key + # Same --id-nth key after reload: no focus event + tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n1 aa\\n2 bb\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> 1 a' } + assert_focus_log(['1 a']) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines, '> 1 aa' } + tmux.send_keys :Up + tmux.until { |lines| assert_includes lines, '> 2 bb' } + assert_focus_log(['1 a', '2 bb']) + end + + def test_focus_event_after_reload_id_nth_key_not_found + tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "3 c\\n4 d\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> 1 a' } + assert_focus_log(['1 a']) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines, '> 3 c' } + assert_focus_log(['1 a', '3 c']) + end + + def test_focus_event_after_async_reload_id_nth + # No focus event for the items shown before the tracked item arrives. + # 'result' binding makes fzf redraw the list while blocked. + tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'result:change-footer(R)' --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n"; sleep 1; printf "1 aa\\n2 bb\\n")'), :Enter + tmux.until { |lines| assert_includes lines, '> 1 a' } + assert_focus_log(['1 a']) + tmux.send_keys 'C-r' + tmux.until { |lines| assert_includes lines[-2], '+T*' } + tmux.until do |lines| + assert_includes lines, '> 1 aa' + refute_includes lines[-2], '+T*' + end + tmux.send_keys :Up + tmux.until { |lines| assert_includes lines, '> 2 bb' } + assert_focus_log(['1 a', '2 bb']) + end + def test_result_event tmux.send_keys '(echo 0; seq 10) | fzf --bind "result:pos(2)"', :Enter tmux.until { |lines| assert_equal 11, lines.match_count }