Packages

A process-based debouncer for Elixir

Current section

Files

Jump to
debounce lib debounce.ex
Raw

lib/debounce.ex

defmodule Debounce do
@moduledoc """
A process-based debouncer for Elixir.
## What is a debouncer?
A debouncer is responsible for calling a function with a delay, but if that
function is called multiple times within the delay period, the time is reset
and delay is counted again. In other words, the function will be called
after a delay period has elapsed from the last application.
Each time, the debounced function is called, a new task is started.
## Example
iex> {:ok, pid} = Debounce.start_link({Kernel, :send, [self(), "Hello"]}, 100)
iex> Debounce.apply(pid) # Schedules call in 100 ms
iex> :timer.sleep(50)
iex> Debounce.apply(pid) # Resets timer back to 100 ms
iex> :timer.sleep(100)
iex> receive do msg -> msg end
"Hello" # Timer elapsed
iex> Debounce.apply(pid) # Schedules call in 100 ms
iex> Debounce.cancel(pid) # Cancels scheduled call
:ok
"""
@behaviour :gen_statem
@type mfargs :: {module, atom, [term]}
@type apply :: (() -> term) | mfargs
@type time :: non_neg_integer
@type debouncer :: :gen_statem.server_ref()
@type option :: {:name, GenServer.name()} | :gen_statem.start_opt()
defmacrop is_apply(apply) do
quote do
is_function(unquote(apply)) or
(is_atom(elem(unquote(apply), 0)) and
is_atom(elem(unquote(apply), 1)) and
is_list(elem(unquote(apply), 2)))
end
end
@doc """
Starts a `Debounce` process linked to the current process.
This can be used to start the `Debounce` as part of a supervision tree.
Delays invoking `apply` until after `timeout` millisecnds have elapsed
since the last time the `apply/2` function was called.
## Options
* `:name`- used for name registration, like in `GenServer.start_link/3`.
* all other options supported by `:gen_statem.start_link/4`
"""
@spec start_link(apply, time, [option]) :: :gen_statem.start_ret()
def start_link(apply, timeout, opts \\ []) do
do_start(:start_link, apply, timeout, opts)
end
@doc """
Starts a `Debounce` process without links (outside of a supervision tree).
See `start_link/3` for more information.
"""
@spec start(apply, time, [option]) :: :gen_statem.start_ret()
def start(apply, timeout, opts \\ []) do
do_start(:start, apply, timeout, opts)
end
@doc """
Synchronously stops the debouncer with the given `reason`.
"""
@spec stop(debouncer, reason :: term, timeout) :: :ok
def stop(debouncer, reason \\ :normal, timeout \\ :infinity) do
:gen_statem.stop(debouncer, reason, timeout)
end
@doc """
Schedules call to the current `debouncer`'s function.
If the function is a fun, calls it with provided `args`.
If the function is an `t:mfargs/0` tuple, appends provided `args`
to the original ones.
If this function is called again withing the current `debouncer`'s timeout
value, the time will reset.
"""
@spec apply(debouncer, [term]) :: :ok
def apply(debouncer, args \\ []) do
call(debouncer, {:apply, args})
end
@doc """
Cancels any scheduled call to the current `debouncer`'s function.
"""
@spec cancel(debouncer) :: :ok
def cancel(debouncer) do
call(debouncer, :cancel)
end
@doc """
Immediately invokes the current `debouncer`'s function.
If the function is a fun, calls it with provided `args`.
If the function is an `t:mfargs/0` tuple, appends provided `args`
to the original ones.
"""
@spec flush(debouncer, [term]) :: :ok
def flush(debouncer, args) do
call(debouncer, {:flush, args})
end
@doc """
Changes the function the `debouncer` is applying.
Affects only future calls to `apply/2`.
"""
@spec change_function(debouncer, apply) :: :ok
def change_function(debouncer, new_function) do
call(debouncer, {:change_function, new_function})
end
@doc """
Changes the delay the `debouncer` operates with.
Affects only future calls to `apply/2`.
"""
@spec change_timeout(debouncer, time) :: :ok
def change_timeout(debouncer, new_timeout) when is_integer(new_timeout) do
call(debouncer, {:change_timeout, new_timeout})
end
## Callbacks
import Record
defrecordp :data, [:apply, :timeout]
@doc false
def callback_mode, do: :state_functions
@doc false
def init({apply, timeout}) do
{:ok, :waiting, data(apply: apply, timeout: timeout)}
end
@doc false
def waiting({:call, from}, {:apply, args}, data(apply: apply, timeout: timeout) = data) do
{:next_state, :counting, data,
[{:reply, from, :ok}, {:state_timeout, timeout, {apply, args}}]}
end
def waiting({:call, from}, :cancel, data) do
{:keep_state, data, {:reply, from, :ok}}
end
def waiting(event, event_content, data) do
handle_event(event, event_content, data)
end
@doc false
def counting({:call, from}, {:apply, args}, data(apply: apply, timeout: timeout) = data) do
{:keep_state, data, [{:reply, from, :ok}, {:state_timeout, timeout, {apply, args}}]}
end
def counting({:call, from}, :cancel, data) do
{:next_state, :waiting, data, {:reply, from, :ok}}
end
def counting(:state_timeout, {apply, args}, data) do
apply_function(apply, args)
{:next_state, :waiting, data}
end
def counting(event, event_content, data) do
handle_event(event, event_content, data)
end
defp handle_event({:call, from}, {:change_function, apply}, data) do
{:keep_state, data(data, apply: apply), {:reply, from, :ok}}
end
defp handle_event({:call, from}, {:change_timeout, timeout}, data) do
{:keep_state, data(data, timeout: timeout), {:reply, from, :ok}}
end
defp handle_event({:call, from}, {:flush, args}, data(apply: apply) = data) do
apply_function(apply, args)
{:next_state, :waiting, data, {:reply, from, :ok}}
end
defp handle_event({:call, _from}, msg, data) do
{:stop, {:bad_call, msg}, data}
end
defp handle_event(:cast, msg, data) do
{:stop, {:bad_cast, msg}, data}
end
defp handle_event(:info, msg, data) do
proc =
case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
:error_logger.error_msg(
'~p ~p received unexpected message: ~p~n',
[__MODULE__, proc, msg]
)
{:keep_state, data}
end
@doc false
def terminate(_reason, _state, _data) do
:ok
end
@doc false
def code_change(_vsn, state, data, _extra) do
{:ok, state, data}
end
## Helpers
defp do_start(start, apply, timeout, opts) when is_apply(apply) and is_integer(timeout) do
if name = name(opts[:name]) do
apply(:gen_statem, start, [name, __MODULE__, {apply, timeout}, opts])
else
apply(:gen_statem, start, [__MODULE__, {apply, timeout}, opts])
end
end
defp name(nil), do: nil
defp name(atom) when is_atom(atom), do: {:local, atom}
defp name(other), do: other
defp call(debounce, request) do
:gen_statem.call(debounce, request, {:dirty_timeout, 5_000})
end
defp apply_function({m, f, a}, args) do
Task.Supervisor.start_child(Debounce.Supervisor, m, f, a ++ args)
end
defp apply_function(fun, args) do
Task.Supervisor.start_child(Debounce.Supervisor, :erlang, :apply, [fun, args])
end
end