Throttle/debounce a Common Lisp function

vindarel

vindarel

Posted on February 20, 2024

Throttle/debounce a Common Lisp function

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)))))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

which gives (NIL NIL "hello?").

How does it work?

The important bit is

    `(let ((,last-fired (calculate-milliseconds-elapsed)))
       (lambda ,args
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
vindarel
vindarel

Posted on February 20, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related