Throttle/debounce a Common Lisp function
vindarel
Posted on February 20, 2024
A typical use case of debouncing function calls is an interactive text input: let the user type a search query, but wait for 500ms before sending a request to your application, so than you don't send a potentially expensive request at each key press.
I was wondering how to do that, so a quick (apropos "debounce")
in my Lisp image brought me to the method implemented in the nodgui GUI framework (Tk bindings).
It boils down to this:
(defun calculate-internal-time-scaling-millis (&optional (scaling 1000))
(if (<= (/ internal-time-units-per-second scaling)
1000)
scaling
(calculate-internal-time-scaling-millis (* 10 scaling))))
(defparameter *internal-time-scaling-millis* (calculate-internal-time-scaling-millis))
(defparameter *debounce-minimum-delay* 120
"milliseconds")
(defun calculate-milliseconds-elapsed ()
(truncate (/ (get-internal-real-time)
*internal-time-scaling-millis*)))
(defmacro lambda-debounce (args &body body)
(alexandria:with-gensyms (last-fired saved-last-fired fired-time results)
`(let ((,last-fired (calculate-milliseconds-elapsed)))
(lambda ,args
(let ((,fired-time (calculate-milliseconds-elapsed))
(,saved-last-fired ,last-fired)
(,results nil))
(log:info ,fired-time ,saved-last-fired (- ,fired-time ,saved-last-fired))
(when (> (- ,fired-time ,saved-last-fired)
*debounce-minimum-delay*)
(log:info "running body…")
(setf ,results (progn ,@body)))
(setf ,last-fired
(calculate-milliseconds-elapsed))
,results)))))
and it is used like this in the framework (which isn't very important for us, we just acknowledge it is used on UI events)
(defun autocomplete-key-press-clsr (candidates-widget
autocomplete-entry-widget
autocomplete-function)
(let ((ignore-next-key nil))
(lambda-debounce (event)
(cond
(ignore-next-key
(setf ignore-next-key nil))
((scan "(?i)(control|alt)" (event-char event))
(setf ignore-next-key t))
;; etc
;; …
(bind autocomplete-entry-widget
#$<KeyPress>$
(autocomplete-key-press-clsr candidates-widget
autocomplete-entry-widget
autocomplete-function)
Here's a quick example for us:
(defun generate-event-calls ()
(loop repeat 3
collect
(lambda-debounce ()
"hello?")))
(loop for fn in (generate-event-calls)
for ms in '(0.001 0.1 0.021)
collect
(progn
(sleep ms)
(funcall fn)))
which gives (NIL NIL "hello?")
.
How does it work?
The important bit is
`(let ((,last-fired (calculate-milliseconds-elapsed)))
(lambda ,args
the let
binds a variable before returning a function, effectively creating a closure common to all lambdas. Later on, the last-fired
variable is compared to the current time, the time the function is called. When the time difference is significant, we run the macro body and our program logic.
All lambdas are tested if they should be run. Our first one is tested after 1ms: it is discarded. The second one, after 100 + 1 ms: still discarded. The third one after 1 + 100 + 21ms since the macro expansion time and last-fired
was first set: our macro body is run.
my 2c.
Posted on February 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.