Skip to content

Instantly share code, notes, and snippets.

@runiq
Last active July 9, 2024 15:35
Show Gist options
  • Save runiq/31aa5c4bf00f8e0843cd267880117201 to your computer and use it in GitHub Desktop.
Save runiq/31aa5c4bf00f8e0843cd267880117201 to your computer and use it in GitHub Desktop.
Neovim throttle & debounce

What are these?

Functions that allow you to call a function not more than once in a given timeframe.

Throttling on the leading edge

This can be illustrated with timing diagrams. In the following diagrams, f designates call to the throttled function, t is the period where the timer is running, and x shows you the actual execution point of the throttled function.

f 1  2  3  4  5  6
t -------- --------
x 1--      4--

In this case, the function f was called 6 times. f's timer t was started on the first invocation of f, and stopped shortly after the third invocation, taking 8 'ticks'. During this run of the timer, the function f was executed only once –– at the beginning (leading edge) --, taking 3 ticks to execute. This is then repeated for function invocations 4 through 6.

You can use the test_defer() function from this module to show you an example that corresponds to the timing diagrams. Its invocation and output will look something like this:

:lua require'defer'.test_defer('tl')
" tl: 1
" 1
" 2
" 3
" tl: 4
" 4
" 5
" 6

Throttling on the trailing edge

Using the arguments of the first function call while the timer was running (the default).

f 1  2  3    4  5  6
t --------   --------
x         1--        4--
:lua require'defer'.test_defer('tt')`
" 1
" 2
" 3
" tt: 1
" 4
" 5
" 6
" tt: 4

Using the arguments of the last call while the timer was running:

f 1  2  3    4  5  6
t --------   --------
x         3--        6--
:lua require'defer'.test_defer('tt', true)`
" 1
" 2
" 3
" tt: 3
" 4
" 5
" 6
" tt: 6

Debouncing on the leading edge

On each function call, the timer is reset (indicated by an r):

f 1  2  3  4  5  6
t ---r--r--r--r--r-------
x 1--
:lua require'defer'.test_defer('dl')
" dl: 1
" 1
" 2
" 3
" 4
" 5
" 6

Debouncing on the trailing edge

Using the arguments of the last function call while the timer was running (the default).

f 1  2  3  4  5  6
t ---r--r--r--r--r-------
x                        6--
:lua require'defer'.test_defer('dt')
" 1
" 2
" 3
" 4
" 5
" 6
" (pause, press <cr>)
" dt: 6

Using the arguments of the first function call while the timer was running.

f 1  2  3  4  5  6
t ---r--r--r--r--r-------
x                        1--
:lua require'defer'.test_defer('dt', true)
" 1
" 2
" 3
" 4
" 5
" 6
" (pause, press <cr>)
" dt: 1
local M = {}
---Validates args for `throttle()` and `debounce()`.
local function td_validate(fn, ms)
vim.validate{
fn = { fn, 'f' },
ms = {
ms,
function(ms)
return type(ms) == 'number' and ms > 0
end,
"number > 0",
},
}
end
--- Throttles a function on the leading edge. Automatically `schedule_wrap()`s.
---
--@param fn (function) Function to throttle
--@param timeout (number) Timeout in ms
--@returns (function, timer) throttled function and timer. Remember to call
---`timer:close()` at the end or you will leak memory!
function M.throttle_leading(fn, ms)
td_validate(fn, ms)
local timer = vim.loop.new_timer()
local running = false
local function wrapped_fn(...)
if not running then
timer:start(ms, 0, function()
running = false
end)
running = true
pcall(vim.schedule_wrap(fn), select(1, ...))
end
end
return wrapped_fn, timer
end
--- Throttles a function on the trailing edge. Automatically
--- `schedule_wrap()`s.
---
--@param fn (function) Function to throttle
--@param timeout (number) Timeout in ms
--@param last (boolean, optional) Whether to use the arguments of the last
---call to `fn` within the timeframe. Default: Use arguments of the first call.
--@returns (function, timer) Throttled function and timer. Remember to call
---`timer:close()` at the end or you will leak memory!
function M.throttle_trailing(fn, ms, last)
td_validate(fn, ms)
local timer = vim.loop.new_timer()
local running = false
local wrapped_fn
if not last then
function wrapped_fn(...)
if not running then
local argv = {...}
local argc = select('#', ...)
timer:start(ms, 0, function()
running = false
pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc))
end)
running = true
end
end
else
local argv, argc
function wrapped_fn(...)
argv = {...}
argc = select('#', ...)
if not running then
timer:start(ms, 0, function()
running = false
pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc))
end)
running = true
end
end
end
return wrapped_fn, timer
end
--- Debounces a function on the leading edge. Automatically `schedule_wrap()`s.
---
--@param fn (function) Function to debounce
--@param timeout (number) Timeout in ms
--@returns (function, timer) Debounced function and timer. Remember to call
---`timer:close()` at the end or you will leak memory!
function M.debounce_leading(fn, ms)
td_validate(fn, ms)
local timer = vim.loop.new_timer()
local running = false
local function wrapped_fn(...)
timer:start(ms, 0, function()
running = false
end)
if not running then
running = true
pcall(vim.schedule_wrap(fn), select(1, ...))
end
end
return wrapped_fn, timer
end
--- Debounces a function on the trailing edge. Automatically
--- `schedule_wrap()`s.
---
--@param fn (function) Function to debounce
--@param timeout (number) Timeout in ms
--@param first (boolean, optional) Whether to use the arguments of the first
---call to `fn` within the timeframe. Default: Use arguments of the last call.
--@returns (function, timer) Debounced function and timer. Remember to call
---`timer:close()` at the end or you will leak memory!
function M.debounce_trailing(fn, ms, first)
td_validate(fn, ms)
local timer = vim.loop.new_timer()
local wrapped_fn
if not first then
function wrapped_fn(...)
local argv = {...}
local argc = select('#', ...)
timer:start(ms, 0, function()
pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc))
end)
end
else
local argv, argc
function wrapped_fn(...)
argv = argv or {...}
argc = argc or select('#', ...)
timer:start(ms, 0, function()
pcall(vim.schedule_wrap(fn), unpack(argv, 1, argc))
end)
end
end
return wrapped_fn, timer
end
--- Test deferment methods (`{throttle,debounce}_{leading,trailing}()`).
---
--@param bouncer (string) Bouncer function to test
--@param ms (number, optional) Timeout in ms, default 2000.
--@param firstlast (bool, optional) Whether to use the 'other' fn call
---strategy.
function M.test_defer(bouncer, ms, firstlast)
local bouncers = {
tl = M.throttle_leading,
tt = M.throttle_trailing,
dl = M.debounce_leading,
dt = M.debounce_trailing,
}
local timeout = ms or 2000
local bounced = bouncers[bouncer](
function(i) vim.cmd('echom "' .. bouncer .. ': ' .. i .. '"') end,
timeout,
firstlast
)
for i, _ in ipairs{1,2,3,4,5} do
bounced(i)
vim.schedule(function () vim.cmd('echom ' .. i) end)
vim.fn.call("wait", {1000, "v:false"})
end
end
return M
@bennypowers
Copy link

Hello thanks for publishing this.

What's the license for this code? MIT?

@runiq
Copy link
Author

runiq commented Feb 21, 2022

Yea sure, let's go with MIT. I'll move it to a repo with a proper license file once I'm home.

@sle-c
Copy link

sle-c commented Mar 16, 2022

thank you for publishing this. It's super helpful

@runiq
Copy link
Author

runiq commented Apr 7, 2022

@bennypowers Sorry, this completely fell off my radar. :( Here you go: https://github.com/runiq/neovim-throttle-debounce

@ColinKennedy
Copy link

@runiq About https://gist.github.com/runiq/31aa5c4bf00f8e0843cd267880117201#file-defer-lua-L98-L100, wouldn't it be okay to call timer:close() within the debounced function? Or would doing that leak memory still? And I guess same question for debounce_trailing. Why not close the timer in the debouncer, rather than leave it as a caller responsibility to handle?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment