Compare commits

..

6 Commits

Author SHA1 Message Date
Junegunn Choi 6657639578 Export both FZF_IDLE_TIME and FZF_IDLE_TIME_MS
CodeQL / Analyze (go) (push) Waiting to run
build / build (push) Waiting to run
Test fzf on macOS / build (push) Waiting to run
Seconds is more ergonomic for shell threshold checks; milliseconds
covers sub-second every() bindings. Exporting both lets the same
script pick whichever resolution matches its every() interval.
2026-05-16 18:22:21 +09:00
Junegunn Choi 4364dc1d24 Rename FZF_IDLE_TIME to FZF_IDLE_MS
Seconds resolution is too coarse to combine with sub-second every()
bindings. Switching to milliseconds gives idle-based scripts useful
resolution at any every() interval.
2026-05-16 18:11:26 +09:00
Junegunn Choi 07f3b00bd4 Group synthetic events at end of EventType enum
Move Invalid, Fatal, and BracketedPasteBegin/End into the same
synthetic block as Resize, Start, Load, etc. The single boundary
(>= Invalid) now covers every non-user event, replacing the explicit
list in the keyChan activity check.

BracketedPaste markers sit in the synthetic block because the paste
content itself arrives as Rune events and updates lastActivity.
2026-05-16 18:09:02 +09:00
Junegunn Choi 463ef212b6 Reject every() intervals that overflow int32 milliseconds
Char is rune (int32). Without an upper bound, secs * 1000 could
silently wrap to a non-positive value and panic time.NewTicker.
2026-05-16 17:58:31 +09:00
Junegunn Choi 38c88e4753 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
2026-05-16 14:47:38 +09:00
Junegunn Choi e0d081906f Reward non-word match at word boundary
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
A non-word character (e.g. '.') used to receive a flat bonusNonWord
regardless of context. Now it gets bonusBoundaryWhite at the start of
input and bonusBoundaryDelimiter right after a delimiter, matching the
treatment of word characters at the same boundaries.

Without this, '.completion' matching '.completion' lost to
'bash_completion.d/completions/X' because the consecutive chunk anchor
in the long path (the 'c' after '/') received bonusBoundaryDelimiter
while the exact match's '.' was capped at bonusNonWord.

Fix #4795
2026-05-15 00:46:36 +09:00
11 changed files with 254 additions and 27 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ jobs:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: 'Dependency Review' - name: 'Dependency Review'
uses: actions/dependency-review-action@v5 uses: actions/dependency-review-action@v4
+13
View File
@@ -3,6 +3,19 @@ CHANGELOG
0.73.0 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 - 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) - `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)
+28
View File
@@ -1500,6 +1500,10 @@ fzf exports the following environment variables to its child processes.
.br .br
.BR FZF_KEY " The name of the last key pressed" .BR FZF_KEY " The name of the last key pressed"
.br .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 FZF_PORT " Port number when \-\-listen option is used"
.br .br
.BR FZF_SOCK " Unix socket path when \-\-listen option is used" .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
if clicked on a word. if clicked on a word.
.RE .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: .SS AVAILABLE ACTIONS:
A key or an event can be bound to one or more of the following actions. A key or an event can be bound to one or more of the following actions.
+1 -1
View File
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
} }
func bonusFor(prevClass charClass, class charClass) int16 { func bonusFor(prevClass charClass, class charClass) int16 {
if class > charNonWord { if class >= charNonWord {
switch prevClass { switch prevClass {
case charWhite: case charWhite:
// Word boundary after whitespace // Word boundary after whitespace
+9
View File
@@ -57,6 +57,15 @@ func TestFuzzyMatch(t *testing.T) {
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3) scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13, assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter)) scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter))
// Non-word character at start of input is treated as a strong boundary
assertMatch(t, fn, false, forward, ".vimrc", ".vimrc", 0, 6,
scoreMatch*6+int(bonusBoundaryWhite)*(bonusFirstCharMultiplier+5))
// Non-word character right after a delimiter inherits the delimiter boundary
assertMatch(t, fn, false, forward, "/.vimrc", ".vimrc", 1, 7,
scoreMatch*6+int(bonusBoundaryDelimiter)*(bonusFirstCharMultiplier+5))
// Non-word character in the middle of a word stays at bonusNonWord
assertMatch(t, fn, false, forward, "a.vimrc", ".vimrc", 1, 7,
scoreMatch*6+bonusBoundary*(bonusFirstCharMultiplier+5))
assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10, assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension) scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10, assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
+24 -1
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
"math"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
@@ -1257,7 +1258,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.F12) add(tui.F12)
default: default:
runes := []rune(key) 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]) r := rune(lkey[9])
evt := tui.CtrlAltKey(r) evt := tui.CtrlAltKey(r)
if r == 'h' && !util.IsWindows() { if r == 'h' && !util.IsWindows() {
@@ -1299,6 +1307,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
return chords, list, nil 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) { func parseScheme(str string) (string, []criterion, error) {
str = strings.ToLower(str) str = strings.ToLower(str)
switch str { switch str {
+37
View File
@@ -299,6 +299,43 @@ func TestBind(t *testing.T) {
check(tui.F1.AsEvent(), "", actAbort) 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)
}
}
// 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) { func TestColorSpec(t *testing.T) {
var base *tui.ColorTheme var base *tui.ColorTheme
theme := tui.Dark256 theme := tui.Dark256
+41
View File
@@ -436,6 +436,7 @@ type Terminal struct {
bgSemaphores map[action]chan struct{} bgSemaphores map[action]chan struct{}
keyChan chan tui.Event keyChan chan tui.Event
eventChan chan tui.Event eventChan chan tui.Event
timerChan chan tui.Event
slab *util.Slab slab *util.Slab
theme *tui.ColorTheme theme *tui.ColorTheme
tui tui.Renderer tui tui.Renderer
@@ -456,6 +457,7 @@ type Terminal struct {
proxyScript string proxyScript string
numLinesCache map[int32]numLinesCacheValue numLinesCache map[int32]numLinesCacheValue
raw bool raw bool
lastActivity time.Time
} }
type numLinesCacheValue struct { type numLinesCacheValue struct {
@@ -1151,6 +1153,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
bgSemaphores: make(map[action]chan struct{}), bgSemaphores: make(map[action]chan struct{}),
keyChan: make(chan tui.Event), keyChan: make(chan tui.Event),
eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize) 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, tui: renderer,
ttyDefault: opts.TtyDefault, ttyDefault: opts.TtyDefault,
ttyin: ttyin, ttyin: ttyin,
@@ -1158,6 +1161,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
executing: util.NewAtomicBool(false), executing: util.NewAtomicBool(false),
lastAction: actStart, lastAction: actStart,
lastFocus: minItem.Index(), lastFocus: minItem.Index(),
lastActivity: time.Now(),
numLinesCache: make(map[int32]numLinesCacheValue)} numLinesCache: make(map[int32]numLinesCacheValue)}
if opts.AcceptNth != nil { if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter) t.acceptNth = opts.AcceptNth(t.delimiter)
@@ -1385,6 +1389,9 @@ func (t *Terminal) environImpl(forPreview bool) []string {
env = append(env, "FZF_QUERY="+string(t.input)) env = append(env, "FZF_QUERY="+string(t.input))
env = append(env, "FZF_ACTION="+t.lastAction.Name()) env = append(env, "FZF_ACTION="+t.lastAction.Name())
env = append(env, "FZF_KEY="+t.lastKey) 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_PROMPT="+string(t.promptString))
env = append(env, "FZF_GHOST="+string(t.ghost)) env = append(env, "FZF_GHOST="+string(t.ghost))
env = append(env, "FZF_POINTER="+string(t.pointer)) env = append(env, "FZF_POINTER="+string(t.pointer))
@@ -5807,6 +5814,35 @@ func (t *Terminal) addClickFooterWord(env []string) []string {
return env 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 // Loop is called to start Terminal I/O
func (t *Terminal) Loop() error { func (t *Terminal) Loop() error {
// prof := profile.Start(profile.ProfilePath("/tmp/")) // prof := profile.Start(profile.ProfilePath("/tmp/"))
@@ -6314,6 +6350,7 @@ func (t *Terminal) Loop() error {
} }
} }
}() }()
t.startTimers(ctx)
previewDraggingPos := -1 previewDraggingPos := -1
barDragging := false barDragging := false
pbarDragging := false pbarDragging := false
@@ -6373,6 +6410,10 @@ func (t *Terminal) Loop() error {
select { select {
case event = <-t.keyChan: case event = <-t.keyChan:
needBarrier = true needBarrier = true
if event.Type < tui.Invalid {
t.lastActivity = time.Now()
}
case event = <-t.timerChan:
case event = <-t.eventChan: case event = <-t.eventChan:
// Drain channel to process all queued events at once without rendering // Drain channel to process all queued events at once without rendering
// the intermediate states // the intermediate states
+19 -18
View File
@@ -133,22 +133,22 @@ func _() {
_ = x[CtrlAltShiftDelete-122] _ = x[CtrlAltShiftDelete-122]
_ = x[CtrlAltShiftPageUp-123] _ = x[CtrlAltShiftPageUp-123]
_ = x[CtrlAltShiftPageDown-124] _ = x[CtrlAltShiftPageDown-124]
_ = x[Invalid-125] _ = x[Mouse-125]
_ = x[Fatal-126] _ = x[DoubleClick-126]
_ = x[BracketedPasteBegin-127] _ = x[LeftClick-127]
_ = x[BracketedPasteEnd-128] _ = x[RightClick-128]
_ = x[Mouse-129] _ = x[SLeftClick-129]
_ = x[DoubleClick-130] _ = x[SRightClick-130]
_ = x[LeftClick-131] _ = x[ScrollUp-131]
_ = x[RightClick-132] _ = x[ScrollDown-132]
_ = x[SLeftClick-133] _ = x[SScrollUp-133]
_ = x[SRightClick-134] _ = x[SScrollDown-134]
_ = x[ScrollUp-135] _ = x[PreviewScrollUp-135]
_ = x[ScrollDown-136] _ = x[PreviewScrollDown-136]
_ = x[SScrollUp-137] _ = x[Invalid-137]
_ = x[SScrollDown-138] _ = x[Fatal-138]
_ = x[PreviewScrollUp-139] _ = x[BracketedPasteBegin-139]
_ = x[PreviewScrollDown-140] _ = x[BracketedPasteEnd-140]
_ = x[Resize-141] _ = x[Resize-141]
_ = x[Change-142] _ = x[Change-142]
_ = x[BackwardEOF-143] _ = x[BackwardEOF-143]
@@ -163,11 +163,12 @@ func _() {
_ = x[ClickHeader-152] _ = x[ClickHeader-152]
_ = x[ClickFooter-153] _ = x[ClickFooter-153]
_ = x[Multi-154] _ = x[Multi-154]
_ = x[Every-155]
} }
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti" const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery"
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, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330}
func (i EventType) String() string { func (i EventType) String() string {
if i < 0 || i >= EventType(len(_EventType_index)-1) { if i < 0 || i >= EventType(len(_EventType_index)-1) {
+14 -6
View File
@@ -196,11 +196,6 @@ const (
CtrlAltShiftPageUp CtrlAltShiftPageUp
CtrlAltShiftPageDown CtrlAltShiftPageDown
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Mouse Mouse
DoubleClick DoubleClick
LeftClick LeftClick
@@ -214,7 +209,15 @@ const (
PreviewScrollUp PreviewScrollUp
PreviewScrollDown 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 Resize
Change Change
BackwardEOF BackwardEOF
@@ -229,6 +232,7 @@ const (
ClickHeader ClickHeader
ClickFooter ClickFooter
Multi Multi
Every
) )
func (t EventType) AsEvent() Event { func (t EventType) AsEvent() Event {
@@ -253,6 +257,10 @@ func (e Event) KeyName() string {
return me.Name() return me.Name()
} }
if e.Type == Every {
return "every(" + strconv.FormatFloat(float64(e.Char)/1000, 'f', -1, 64) + ")"
}
if e.Type >= Invalid { if e.Type >= Invalid {
return "" return ""
} }
+67
View File
@@ -1387,6 +1387,73 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines, '> 1' } tmux.until { |lines| assert_includes lines, '> 1' }
end 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 + FZF_IDLE_TIME_MS combined with every() implement idle-based behavior.
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-header(echo "s=$FZF_IDLE_TIME ms_ok=$((FZF_IDLE_TIME_MS / 1000 == FZF_IDLE_TIME))")'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
# Idle counter advances without any input; ms/1000 stays consistent with seconds.
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=1' }
tmux.until { |lines| assert_includes lines[-3], 's=2 ms_ok=1' }
# Any keystroke resets the counter
tmux.send_keys 'x'
tmux.until { |lines| assert_includes lines[-3], 's=0 ms_ok=1' }
tmux.send_keys :BSpace
# And it advances again afterwards
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=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 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.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 tmux.until do