Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
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` (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).
```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
28 changes: 28 additions & 0 deletions man/man1/fzf.1
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,10 @@ 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_IDLE_TIME_MS " Milliseconds 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 +1943,30 @@ 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 (whole seconds) and
\fBFZF_IDLE_TIME_MS\fR (milliseconds) environment variables 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
25 changes: 24 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,21 @@ 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 := math.Round(secs * 1000)
if ms > math.MaxInt32 {
return tui.Event{}, errors.New("every() interval is too large")
}
return tui.Event{Type: tui.Every, Char: rune(int32(ms))}, nil
}

func parseScheme(str string) (string, []criterion, error) {
str = strings.ToLower(str)
switch str {
Expand Down
33 changes: 33 additions & 0 deletions src/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,39 @@ 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, negatives, and overflow (>= 2^31 ms = ~24.85 days)
for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()", "every(2147484)"} {
if _, _, err := parseKeyChords(bad, ""); err == nil {
t.Errorf("%s should be rejected", bad)
}
}

}

func TestColorSpec(t *testing.T) {
var base *tui.ColorTheme
theme := tui.Dark256
Expand Down
45 changes: 44 additions & 1 deletion 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,9 @@ 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)
idleMs := time.Since(t.lastActivity).Milliseconds()
env = append(env, fmt.Sprintf("FZF_IDLE_TIME=%d", idleMs/1000))
env = append(env, fmt.Sprintf("FZF_IDLE_TIME_MS=%d", idleMs))
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 +5814,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 +6350,7 @@ func (t *Terminal) Loop() error {
}
}
}()
t.startTimers(ctx)
previewDraggingPos := -1
barDragging := false
pbarDragging := false
Expand Down Expand Up @@ -6373,6 +6410,10 @@ func (t *Terminal) Loop() error {
select {
case event = <-t.keyChan:
needBarrier = true
if event.Type < tui.Invalid {
t.lastActivity = time.Now()
Comment thread
junegunn marked this conversation as resolved.
Outdated
}
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 Expand Up @@ -6437,7 +6478,9 @@ func (t *Terminal) Loop() error {
previousInput := t.input
previousCx := t.cx
previousVersion := t.version
t.lastKey = event.KeyName()
if event.Type < tui.Invalid {
t.lastKey = event.KeyName()
}
updatePreviewWindow := func(forcePreview bool) {
t.resizeWindows(forcePreview, false)
req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter)
Expand Down
37 changes: 19 additions & 18 deletions src/tui/eventtype_string.go

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

16 changes: 10 additions & 6 deletions src/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,6 @@ const (
CtrlAltShiftPageUp
CtrlAltShiftPageDown

Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd

Mouse
DoubleClick
LeftClick
Expand All @@ -214,7 +209,15 @@ const (
PreviewScrollUp
PreviewScrollDown

// Events
// Synthetic / non-user events. Everything from Invalid onward is
// either internally generated or a state-change notification, not
// direct user input. Use `>= Invalid` to gate activity tracking.
// BracketedPasteBegin/End sit here too: they enclose user input
// (which arrives as Rune events) and should not appear in FZF_KEY.
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Resize
Change
BackwardEOF
Expand All @@ -229,6 +232,7 @@ const (
ClickHeader
ClickFooter
Multi
Every
)

func (t EventType) AsEvent() Event {
Expand Down
Loading
Loading