Skip to content

Commit fcc3c6a

Browse files
authored
Add every(N) bind event and FZF_IDLE_TIME env var (#4797)
- 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
1 parent e0d0819 commit fcc3c6a

8 files changed

Lines changed: 248 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@ CHANGELOG
33

44
0.73.0
55
------
6+
- 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.
7+
- New `FZF_IDLE_TIME` (whole seconds) and `FZF_IDLE_TIME_MS` (milliseconds) environment variables exported to child processes, holding the elapsed time since the last user activity. Pair with `every(N)` to build idle-based behavior such as auto-accept or auto-quit (#1211).
8+
```sh
9+
# Live process list; --track --id-nth 2 keeps the cursor on the same PID across reloads
10+
fzf --header-lines 1 --track --id-nth 2 --bind 'start,every(2):reload-sync:ps -ef'
11+
12+
# Auto-accept after 10 seconds of inactivity, with a countdown in the footer after 5s
13+
fzf --bind 'every(1):bg-transform:
14+
if [[ $FZF_IDLE_TIME -lt 5 ]]; then echo change-footer:
15+
elif [[ $FZF_IDLE_TIME -lt 10 ]]; then echo "change-footer:auto-accept in $((10 - FZF_IDLE_TIME))s"
16+
else echo accept
17+
fi'
18+
```
619
- Bug fixes
720
- `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)
821

man/man1/fzf.1

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,6 +1500,10 @@ fzf exports the following environment variables to its child processes.
15001500
.br
15011501
.BR FZF_KEY " The name of the last key pressed"
15021502
.br
1503+
.BR FZF_IDLE_TIME " Whole seconds since the last user activity"
1504+
.br
1505+
.BR FZF_IDLE_TIME_MS " Milliseconds since the last user activity"
1506+
.br
15031507
.BR FZF_PORT " Port number when \-\-listen option is used"
15041508
.br
15051509
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
@@ -1939,6 +1943,30 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR
19391943
if clicked on a word.
19401944
.RE
19411945

1946+
\fIevery(N)\fR
1947+
.RS
1948+
Triggered every \fIN\fR seconds (\fIN\fR can be a fractional number, e.g.
1949+
\fB0.5\fR). The minimum interval is \fB0.01\fR seconds; values are floored
1950+
to that.
1951+
1952+
Combine with the \fBFZF_IDLE_TIME\fR (whole seconds) and
1953+
\fBFZF_IDLE_TIME_MS\fR (milliseconds) environment variables to build
1954+
idle\-based behavior without a separate event.
1955+
1956+
e.g.
1957+
\fB# Live process list, refreshed every 2 seconds.
1958+
# --track --id-nth 2 keeps the cursor on the same PID across reloads.
1959+
fzf \-\-header\-lines 1 \-\-track \-\-id\-nth 2 \\
1960+
\-\-bind 'start,every(2):reload\-sync:ps \-ef'
1961+
1962+
# Auto\-accept after 10 seconds of inactivity, with a countdown in the footer after 5s.
1963+
fzf \-\-bind 'every(1):bg\-transform:
1964+
if [[ $FZF_IDLE_TIME \-lt 5 ]]; then echo change\-footer:
1965+
elif [[ $FZF_IDLE_TIME \-lt 10 ]]; then echo "change\-footer:auto\-accept in $((10 \- FZF_IDLE_TIME))s"
1966+
else echo accept
1967+
fi'\fR
1968+
.RE
1969+
19421970
.SS AVAILABLE ACTIONS:
19431971
A key or an event can be bound to one or more of the following actions.
19441972

src/options.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"maps"
7+
"math"
78
"os"
89
"regexp"
910
"strconv"
@@ -1257,7 +1258,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
12571258
add(tui.F12)
12581259
default:
12591260
runes := []rune(key)
1260-
if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
1261+
if strings.HasPrefix(lkey, "every(") && strings.HasSuffix(lkey, ")") {
1262+
evt, err := parseEveryEvent(key[6 : len(key)-1])
1263+
if err != nil {
1264+
return nil, list, err
1265+
}
1266+
chords[evt] = key
1267+
list = append(list, evt)
1268+
} else if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
12611269
r := rune(lkey[9])
12621270
evt := tui.CtrlAltKey(r)
12631271
if r == 'h' && !util.IsWindows() {
@@ -1299,6 +1307,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
12991307
return chords, list, nil
13001308
}
13011309

1310+
func parseEveryEvent(arg string) (tui.Event, error) {
1311+
secs, err := strconv.ParseFloat(strings.TrimSpace(arg), 64)
1312+
if err != nil || math.IsNaN(secs) || math.IsInf(secs, 0) || secs <= 0 {
1313+
return tui.Event{}, errors.New("every() requires a positive number of seconds")
1314+
}
1315+
if secs < 0.01 {
1316+
secs = 0.01
1317+
}
1318+
ms := math.Round(secs * 1000)
1319+
if ms > math.MaxInt32 {
1320+
return tui.Event{}, errors.New("every() interval is too large")
1321+
}
1322+
return tui.Event{Type: tui.Every, Char: rune(int32(ms))}, nil
1323+
}
1324+
13021325
func parseScheme(str string) (string, []criterion, error) {
13031326
str = strings.ToLower(str)
13041327
switch str {

src/options_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,39 @@ func TestBind(t *testing.T) {
299299
check(tui.F1.AsEvent(), "", actAbort)
300300
}
301301

302+
func TestParseEveryEvent(t *testing.T) {
303+
pairs, _, err := parseKeyChords("every(2),every(0.5)", "")
304+
if err != nil {
305+
t.Fatalf("unexpected error: %v", err)
306+
}
307+
if len(pairs) != 2 {
308+
t.Errorf("expected 2 distinct every events, got %d", len(pairs))
309+
}
310+
if pairs[(tui.Event{Type: tui.Every, Char: 2000})] != "every(2)" {
311+
t.Errorf("every(2) not registered")
312+
}
313+
if pairs[(tui.Event{Type: tui.Every, Char: 500})] != "every(0.5)" {
314+
t.Errorf("every(0.5) not registered")
315+
}
316+
317+
// Floor at 0.01s -> 10ms
318+
pairs, _, err = parseKeyChords("every(0.001)", "")
319+
if err != nil {
320+
t.Fatalf("unexpected error: %v", err)
321+
}
322+
if pairs[(tui.Event{Type: tui.Every, Char: 10})] != "every(0.001)" {
323+
t.Errorf("every(0.001) should floor to 10ms")
324+
}
325+
326+
// Reject zero, negatives, and overflow (>= 2^31 ms = ~24.85 days)
327+
for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()", "every(2147484)"} {
328+
if _, _, err := parseKeyChords(bad, ""); err == nil {
329+
t.Errorf("%s should be rejected", bad)
330+
}
331+
}
332+
333+
}
334+
302335
func TestColorSpec(t *testing.T) {
303336
var base *tui.ColorTheme
304337
theme := tui.Dark256

src/terminal.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ type Terminal struct {
436436
bgSemaphores map[action]chan struct{}
437437
keyChan chan tui.Event
438438
eventChan chan tui.Event
439+
timerChan chan tui.Event
439440
slab *util.Slab
440441
theme *tui.ColorTheme
441442
tui tui.Renderer
@@ -456,6 +457,7 @@ type Terminal struct {
456457
proxyScript string
457458
numLinesCache map[int32]numLinesCacheValue
458459
raw bool
460+
lastActivity time.Time
459461
}
460462

461463
type numLinesCacheValue struct {
@@ -1151,13 +1153,15 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
11511153
bgSemaphores: make(map[action]chan struct{}),
11521154
keyChan: make(chan tui.Event),
11531155
eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize)
1156+
timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy
11541157
tui: renderer,
11551158
ttyDefault: opts.TtyDefault,
11561159
ttyin: ttyin,
11571160
initFunc: func() error { return renderer.Init() },
11581161
executing: util.NewAtomicBool(false),
11591162
lastAction: actStart,
11601163
lastFocus: minItem.Index(),
1164+
lastActivity: time.Now(),
11611165
numLinesCache: make(map[int32]numLinesCacheValue)}
11621166
if opts.AcceptNth != nil {
11631167
t.acceptNth = opts.AcceptNth(t.delimiter)
@@ -1385,6 +1389,9 @@ func (t *Terminal) environImpl(forPreview bool) []string {
13851389
env = append(env, "FZF_QUERY="+string(t.input))
13861390
env = append(env, "FZF_ACTION="+t.lastAction.Name())
13871391
env = append(env, "FZF_KEY="+t.lastKey)
1392+
idleMs := time.Since(t.lastActivity).Milliseconds()
1393+
env = append(env, fmt.Sprintf("FZF_IDLE_TIME=%d", idleMs/1000))
1394+
env = append(env, fmt.Sprintf("FZF_IDLE_TIME_MS=%d", idleMs))
13881395
env = append(env, "FZF_PROMPT="+string(t.promptString))
13891396
env = append(env, "FZF_GHOST="+string(t.ghost))
13901397
env = append(env, "FZF_POINTER="+string(t.pointer))
@@ -5807,6 +5814,35 @@ func (t *Terminal) addClickFooterWord(env []string) []string {
58075814
return env
58085815
}
58095816

5817+
// startTimers spawns a goroutine per every() bind event. Forwarding ticks
5818+
// onto the unbuffered timerChan lets the ticker drop overlapping ticks
5819+
// while the main loop is busy.
5820+
func (t *Terminal) startTimers(ctx context.Context) {
5821+
for evt := range t.keymap {
5822+
switch evt.Type {
5823+
case tui.Every:
5824+
d := time.Duration(evt.Char) * time.Millisecond
5825+
evt := evt
5826+
go func() {
5827+
ticker := time.NewTicker(d)
5828+
defer ticker.Stop()
5829+
for {
5830+
select {
5831+
case <-ctx.Done():
5832+
return
5833+
case <-ticker.C:
5834+
select {
5835+
case <-ctx.Done():
5836+
return
5837+
case t.timerChan <- evt:
5838+
}
5839+
}
5840+
}
5841+
}()
5842+
}
5843+
}
5844+
}
5845+
58105846
// Loop is called to start Terminal I/O
58115847
func (t *Terminal) Loop() error {
58125848
// prof := profile.Start(profile.ProfilePath("/tmp/"))
@@ -6314,6 +6350,7 @@ func (t *Terminal) Loop() error {
63146350
}
63156351
}
63166352
}()
6353+
t.startTimers(ctx)
63176354
previewDraggingPos := -1
63186355
barDragging := false
63196356
pbarDragging := false
@@ -6373,6 +6410,7 @@ func (t *Terminal) Loop() error {
63736410
select {
63746411
case event = <-t.keyChan:
63756412
needBarrier = true
6413+
case event = <-t.timerChan:
63766414
case event = <-t.eventChan:
63776415
// Drain channel to process all queued events at once without rendering
63786416
// the intermediate states
@@ -6437,7 +6475,10 @@ func (t *Terminal) Loop() error {
64376475
previousInput := t.input
64386476
previousCx := t.cx
64396477
previousVersion := t.version
6440-
t.lastKey = event.KeyName()
6478+
if event.Type < tui.Invalid {
6479+
t.lastKey = event.KeyName()
6480+
t.lastActivity = time.Now()
6481+
}
64416482
updatePreviewWindow := func(forcePreview bool) {
64426483
t.resizeWindows(forcePreview, false)
64436484
req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter)

src/tui/eventtype_string.go

Lines changed: 19 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/tui/tui.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,6 @@ const (
196196
CtrlAltShiftPageUp
197197
CtrlAltShiftPageDown
198198

199-
Invalid
200-
Fatal
201-
BracketedPasteBegin
202-
BracketedPasteEnd
203-
204199
Mouse
205200
DoubleClick
206201
LeftClick
@@ -214,7 +209,15 @@ const (
214209
PreviewScrollUp
215210
PreviewScrollDown
216211

217-
// Events
212+
// Synthetic / non-user events. Everything from Invalid onward is
213+
// either internally generated or a state-change notification, not
214+
// direct user input. Use `>= Invalid` to gate activity tracking.
215+
// BracketedPasteBegin/End sit here too: they enclose user input
216+
// (which arrives as Rune events) and should not appear in FZF_KEY.
217+
Invalid
218+
Fatal
219+
BracketedPasteBegin
220+
BracketedPasteEnd
218221
Resize
219222
Change
220223
BackwardEOF
@@ -229,6 +232,7 @@ const (
229232
ClickHeader
230233
ClickFooter
231234
Multi
235+
Every
232236
)
233237

234238
func (t EventType) AsEvent() Event {

0 commit comments

Comments
 (0)