Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
25 changes: 25 additions & 0 deletions man/man1/fzf.1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.

Expand Down
22 changes: 21 additions & 1 deletion src/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"maps"
"math"
"os"
"regexp"
"strconv"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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))
Comment thread
junegunn marked this conversation as resolved.
Outdated
return tui.Event{Type: tui.Every, Char: rune(ms)}, nil
}

func parseScheme(str string) (string, []criterion, error) {
str = strings.ToLower(str)
switch str {
Expand Down
37 changes: 37 additions & 0 deletions src/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -456,6 +457,7 @@ type Terminal struct {
proxyScript string
numLinesCache map[int32]numLinesCacheValue
raw bool
lastActivity time.Time
}

type numLinesCacheValue struct {
Expand Down Expand Up @@ -1151,13 +1153,15 @@ 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,
initFunc: func() error { return renderer.Init() },
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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Comment thread
junegunn marked this conversation as resolved.
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/"))
Expand Down Expand Up @@ -6314,6 +6348,7 @@ func (t *Terminal) Loop() error {
}
}
}()
t.startTimers(ctx)
previewDraggingPos := -1
barDragging := false
pbarDragging := false
Expand Down Expand Up @@ -6373,6 +6408,8 @@ func (t *Terminal) Loop() error {
select {
case event = <-t.keyChan:
needBarrier = true
t.lastActivity = time.Now()
case event = <-t.timerChan:
Comment thread
junegunn marked this conversation as resolved.
case event = <-t.eventChan:
// Drain channel to process all queued events at once without rendering
// the intermediate states
Expand Down
5 changes: 3 additions & 2 deletions src/tui/eventtype_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ const (
ClickHeader
ClickFooter
Multi
Every
)

func (t EventType) AsEvent() Event {
Expand All @@ -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 ""
}
Expand Down
67 changes: 67 additions & 0 deletions test/test_core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,73 @@ def test_result_event
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
Comment thread
junegunn marked this conversation as resolved.
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
Expand Down
Loading