From 38c88e4753c73f1e3c2295b73476b70a048b9a6b Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 16 May 2026 14:31:46 +0900 Subject: [PATCH] Add every(N) bind event and FZF_IDLE_TIME env var - every(N) fires every N seconds (fractional, floored to 0.01s) - Encoded as tui.Every with duration in Char as milliseconds, so every(1) and every(2) coexist as distinct keymap entries - FZF_IDLE_TIME exposes whole seconds since the last user activity (keystroke or mouse event); pair with every() for idle-based patterns like auto-accept/auto-quit Close #1211 --- CHANGELOG.md | 13 +++++++ man/man1/fzf.1 | 25 ++++++++++++++ src/options.go | 22 +++++++++++- src/options_test.go | 37 ++++++++++++++++++++ src/terminal.go | 37 ++++++++++++++++++++ src/tui/eventtype_string.go | 5 +-- src/tui/tui.go | 5 +++ test/test_core.rb | 67 +++++++++++++++++++++++++++++++++++++ 8 files changed, 208 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbbcdac..56925661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ CHANGELOG 0.73.0 ------ +- Timer-driven `every(N)` event for `--bind`, where `N` is seconds (fractional, floored to `0.01`). Ticks that overlap an in-flight action are coalesced, so a slow `reload` cannot accumulate a backlog. +- New `FZF_IDLE_TIME` environment variable (whole seconds since the last user activity) exported to child processes. Pair with `every(N)` to build idle-based behavior such as auto-accept or auto-quit (#1211). + ```sh + # Live process list; --track --id-nth 2 keeps the cursor on the same PID across reloads + fzf --header-lines 1 --track --id-nth 2 --bind 'start,every(2):reload-sync:ps -ef' + + # Auto-accept after 10 seconds of inactivity, with a countdown in the footer after 5s + fzf --bind 'every(1):bg-transform: + if [[ $FZF_IDLE_TIME -lt 5 ]]; then echo change-footer: + elif [[ $FZF_IDLE_TIME -lt 10 ]]; then echo "change-footer:auto-accept in $((10 - FZF_IDLE_TIME))s" + else echo accept + fi' + ``` - Bug fixes - `change-preview-window` no longer resets `wrap` / `wrap-word` state set via `toggle-preview-wrap` / `toggle-preview-wrap-word`. Layout fields still snap to the preset, so cycling and the empty-token reset behave as before. The new spec can still override by including `wrap` or `nowrap` explicitly. (#4791) diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 00e3b5fc..3ef6675b 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -1500,6 +1500,8 @@ fzf exports the following environment variables to its child processes. .br .BR FZF_KEY " The name of the last key pressed" .br +.BR FZF_IDLE_TIME " Whole seconds since the last user activity" +.br .BR FZF_PORT " Port number when \-\-listen option is used" .br .BR FZF_SOCK " Unix socket path when \-\-listen option is used" @@ -1939,6 +1941,29 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR if clicked on a word. .RE +\fIevery(N)\fR +.RS +Triggered every \fIN\fR seconds (\fIN\fR can be a fractional number, e.g. +\fB0.5\fR). The minimum interval is \fB0.01\fR seconds; values are floored +to that. + +Combine with the \fBFZF_IDLE_TIME\fR environment variable (seconds since +last user activity) to build idle\-based behavior without a separate event. + +e.g. + \fB# Live process list, refreshed every 2 seconds. + # --track --id-nth 2 keeps the cursor on the same PID across reloads. + fzf \-\-header\-lines 1 \-\-track \-\-id\-nth 2 \\ + \-\-bind 'start,every(2):reload\-sync:ps \-ef' + + # Auto\-accept after 10 seconds of inactivity, with a countdown in the footer after 5s. + fzf \-\-bind 'every(1):bg\-transform: + if [[ $FZF_IDLE_TIME \-lt 5 ]]; then echo change\-footer: + elif [[ $FZF_IDLE_TIME \-lt 10 ]]; then echo "change\-footer:auto\-accept in $((10 \- FZF_IDLE_TIME))s" + else echo accept + fi'\fR +.RE + .SS AVAILABLE ACTIONS: A key or an event can be bound to one or more of the following actions. diff --git a/src/options.go b/src/options.go index 9e9f9f5b..0ee6d7fa 100644 --- a/src/options.go +++ b/src/options.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "maps" + "math" "os" "regexp" "strconv" @@ -1257,7 +1258,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve add(tui.F12) default: runes := []rune(key) - if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) { + if strings.HasPrefix(lkey, "every(") && strings.HasSuffix(lkey, ")") { + evt, err := parseEveryEvent(key[6 : len(key)-1]) + if err != nil { + return nil, list, err + } + chords[evt] = key + list = append(list, evt) + } else if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) { r := rune(lkey[9]) evt := tui.CtrlAltKey(r) if r == 'h' && !util.IsWindows() { @@ -1299,6 +1307,18 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve return chords, list, nil } +func parseEveryEvent(arg string) (tui.Event, error) { + secs, err := strconv.ParseFloat(strings.TrimSpace(arg), 64) + if err != nil || math.IsNaN(secs) || math.IsInf(secs, 0) || secs <= 0 { + return tui.Event{}, errors.New("every() requires a positive number of seconds") + } + if secs < 0.01 { + secs = 0.01 + } + ms := int32(math.Round(secs * 1000)) + return tui.Event{Type: tui.Every, Char: rune(ms)}, nil +} + func parseScheme(str string) (string, []criterion, error) { str = strings.ToLower(str) switch str { diff --git a/src/options_test.go b/src/options_test.go index a8c42cef..b32dbfef 100644 --- a/src/options_test.go +++ b/src/options_test.go @@ -299,6 +299,43 @@ func TestBind(t *testing.T) { check(tui.F1.AsEvent(), "", actAbort) } +func TestParseEveryEvent(t *testing.T) { + pairs, _, err := parseKeyChords("every(2),every(0.5)", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pairs) != 2 { + t.Errorf("expected 2 distinct every events, got %d", len(pairs)) + } + if pairs[(tui.Event{Type: tui.Every, Char: 2000})] != "every(2)" { + t.Errorf("every(2) not registered") + } + if pairs[(tui.Event{Type: tui.Every, Char: 500})] != "every(0.5)" { + t.Errorf("every(0.5) not registered") + } + + // Floor at 0.01s -> 10ms + pairs, _, err = parseKeyChords("every(0.001)", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pairs[(tui.Event{Type: tui.Every, Char: 10})] != "every(0.001)" { + t.Errorf("every(0.001) should floor to 10ms") + } + + // Reject zero and negatives + for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()"} { + if _, _, err := parseKeyChords(bad, ""); err == nil { + t.Errorf("%s should be rejected", bad) + } + } + + // KeyName round-trips with the original duration + if got := (tui.Event{Type: tui.Every, Char: 2000}).KeyName(); got != "every(2)" { + t.Errorf("KeyName: %q != every(2)", got) + } +} + func TestColorSpec(t *testing.T) { var base *tui.ColorTheme theme := tui.Dark256 diff --git a/src/terminal.go b/src/terminal.go index c4b62914..4389af10 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -436,6 +436,7 @@ type Terminal struct { bgSemaphores map[action]chan struct{} keyChan chan tui.Event eventChan chan tui.Event + timerChan chan tui.Event slab *util.Slab theme *tui.ColorTheme tui tui.Renderer @@ -456,6 +457,7 @@ type Terminal struct { proxyScript string numLinesCache map[int32]numLinesCacheValue raw bool + lastActivity time.Time } type numLinesCacheValue struct { @@ -1151,6 +1153,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor bgSemaphores: make(map[action]chan struct{}), keyChan: make(chan tui.Event), eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize) + timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy tui: renderer, ttyDefault: opts.TtyDefault, ttyin: ttyin, @@ -1158,6 +1161,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor executing: util.NewAtomicBool(false), lastAction: actStart, lastFocus: minItem.Index(), + lastActivity: time.Now(), numLinesCache: make(map[int32]numLinesCacheValue)} if opts.AcceptNth != nil { t.acceptNth = opts.AcceptNth(t.delimiter) @@ -1385,6 +1389,7 @@ func (t *Terminal) environImpl(forPreview bool) []string { env = append(env, "FZF_QUERY="+string(t.input)) env = append(env, "FZF_ACTION="+t.lastAction.Name()) env = append(env, "FZF_KEY="+t.lastKey) + env = append(env, fmt.Sprintf("FZF_IDLE_TIME=%d", int(time.Since(t.lastActivity).Seconds()))) env = append(env, "FZF_PROMPT="+string(t.promptString)) env = append(env, "FZF_GHOST="+string(t.ghost)) env = append(env, "FZF_POINTER="+string(t.pointer)) @@ -5807,6 +5812,35 @@ func (t *Terminal) addClickFooterWord(env []string) []string { return env } +// startTimers spawns a goroutine per every() bind event. Forwarding ticks +// onto the unbuffered timerChan lets the ticker drop overlapping ticks +// while the main loop is busy. +func (t *Terminal) startTimers(ctx context.Context) { + for evt := range t.keymap { + switch evt.Type { + case tui.Every: + d := time.Duration(evt.Char) * time.Millisecond + evt := evt + go func() { + ticker := time.NewTicker(d) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + select { + case <-ctx.Done(): + return + case t.timerChan <- evt: + } + } + } + }() + } + } +} + // Loop is called to start Terminal I/O func (t *Terminal) Loop() error { // prof := profile.Start(profile.ProfilePath("/tmp/")) @@ -6314,6 +6348,7 @@ func (t *Terminal) Loop() error { } } }() + t.startTimers(ctx) previewDraggingPos := -1 barDragging := false pbarDragging := false @@ -6373,6 +6408,8 @@ func (t *Terminal) Loop() error { select { case event = <-t.keyChan: needBarrier = true + t.lastActivity = time.Now() + case event = <-t.timerChan: case event = <-t.eventChan: // Drain channel to process all queued events at once without rendering // the intermediate states diff --git a/src/tui/eventtype_string.go b/src/tui/eventtype_string.go index 08b5c21f..ddf6fa19 100644 --- a/src/tui/eventtype_string.go +++ b/src/tui/eventtype_string.go @@ -163,11 +163,12 @@ func _() { _ = x[ClickHeader-152] _ = x[ClickFooter-153] _ = x[Multi-154] + _ = x[Every-155] } -const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti" +const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery" -var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1067, 1072, 1091, 1108, 1113, 1124, 1133, 1143, 1153, 1164, 1172, 1182, 1191, 1202, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325} +var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1067, 1072, 1091, 1108, 1113, 1124, 1133, 1143, 1153, 1164, 1172, 1182, 1191, 1202, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330} func (i EventType) String() string { if i < 0 || i >= EventType(len(_EventType_index)-1) { diff --git a/src/tui/tui.go b/src/tui/tui.go index 606aa0c9..e6afca9c 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -229,6 +229,7 @@ const ( ClickHeader ClickFooter Multi + Every ) func (t EventType) AsEvent() Event { @@ -253,6 +254,10 @@ func (e Event) KeyName() string { return me.Name() } + if e.Type == Every { + return "every(" + strconv.FormatFloat(float64(e.Char)/1000, 'f', -1, 64) + ")" + } + if e.Type >= Invalid { return "" } diff --git a/test/test_core.rb b/test/test_core.rb index 965aa6dc..18301da5 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1387,6 +1387,73 @@ class TestCore < TestInteractive tmux.until { |lines| assert_includes lines, '> 1' } end + def test_every_event + tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(cat #{tempname})'), :Enter + tmux.until { |lines| assert_equal 100, lines.match_count } + # Trigger external state changes; the every() tick should pick them up. + writelines(['AAA>']) + tmux.until { |lines| assert_includes lines[-1], 'AAA>' } + writelines(['BBB>']) + tmux.until { |lines| assert_includes lines[-1], 'BBB>' } + end + + def test_every_event_multiple_independent_timers + # Two timers with different durations should fire independently. + fast = tempname + '.fast' + slow = tempname + '.slow' + FileUtils.rm_f(fast) + FileUtils.rm_f(slow) + tmux.send_keys %(seq 100 | fzf \\ + --bind 'every(0.1):execute-silent(printf . >> #{fast})' \\ + --bind 'every(0.5):execute-silent(printf . >> #{slow})'), :Enter + tmux.until { |lines| assert_equal 100, lines.match_count } + sleep 1.2 + a = File.exist?(fast) ? File.size(fast) : 0 + b = File.exist?(slow) ? File.size(slow) : 0 + # Sanity: faster timer fired more times. + assert a > b, "fast timer should fire more (#{a} vs #{b})" + # Sanity: slow timer fired at least once. + assert b >= 1, "slow timer should have fired at least once (#{b})" + ensure + FileUtils.rm_f(fast) + FileUtils.rm_f(slow) + end + + def test_every_event_unbind + tmux.send_keys %(seq 100 | fzf --bind 'every(0.1):transform-header(date +%S.%N)' --bind 'space:unbind(every(0.1))+change-header(STOPPED)'), :Enter + tmux.until { |lines| assert_equal 100, lines.match_count } + # Header should be ticking + tmux.until { |lines| assert_match(/^ \d{2}\.\d+/, lines[-3]) } + tmux.send_keys :Space + tmux.until { |lines| assert_includes lines[-3], 'STOPPED' } + sleep 0.4 + # Header must stay STOPPED after the unbind + assert_includes tmux.capture[-3], 'STOPPED' + end + + def test_fzf_idle_time_env + # FZF_IDLE_TIME, combined with every(), implements idle-based behavior. + tmux.send_keys %(seq 100 | fzf --bind 'every(0.5):transform-header(echo "idle=$FZF_IDLE_TIME")'), :Enter + tmux.until { |lines| assert_equal 100, lines.match_count } + # Idle counter advances without any input + tmux.until { |lines| assert_includes lines[-3], 'idle=1' } + tmux.until { |lines| assert_includes lines[-3], 'idle=2' } + # Any keystroke resets the counter + tmux.send_keys 'x' + tmux.until { |lines| assert_includes lines[-3], 'idle=0' } + tmux.send_keys :BSpace + # And it advances again afterwards + tmux.until { |lines| assert_includes lines[-3], 'idle=1' } + end + + def test_every_event_rejects_invalid_arg + %w[every(0) every(-1) every(abc) every()].each do |spec| + tmux.send_keys %(seq 1 | fzf --bind '#{spec}:abort' 2>&1; echo done=$?), :Enter + tmux.until { |lines| assert(lines.any? { |l| l.include?('done=2') }) } + tmux.send_keys 'clear', :Enter + end + end + def test_labels_center tmux.send_keys 'echo x | fzf --border --border-label foobar --preview : --preview-label barfoo --bind "space:change-border-label(foobarfoo)+change-preview-label(barfoobar),enter:transform-border-label(echo foo{}foo)+transform-preview-label(echo bar{}bar)"', :Enter tmux.until do