lib/debouncex.ex

defmodule Debouncex do
  @moduledoc ~S"""
  A process debouncex for Elixir.

  # What is debounce?
  Debounce will call function with delay timeout, but if function is called multiple time with delay
  period, the time is reset and delay is counted again. Like debounce in Javascript but for Elixir.

  # Example
  ```elixir
  iex> defmodule Hello do
     def hello do
      :world
    end
  end

  iex> {:ok, pid} = Debouncex.start_link({
    &Hello.hello/0,
    [],
    1000
  })

  iex> Debouncex.call(pid) # Scheduler call after 1s
  iex> :timer.sleep(100)
  iex> Debouncex.call(pid) # Scheduler call after 1s
  iex> :world
   ```
  """
  use GenServer

  @type arg :: {
          function,
          keyword,
          timeout :: Millisecond
        }

  @type name :: atom()

  @type server :: pid() | name()

  # Functions
  @doc """
  Start Debounce process linked to current process.

  Delay call function until after timeout millisecond have elapsed after last time function call.

  ## Options
    - `:name` : used name for registration. Like in [GenServer.start_link/3](https://hexdocs.pm/elixir/GenServer.html#start_link/3)
  """

  @spec start_link(init_arg :: arg(), opts :: keyword()) :: {:ok, pid}
  def start_link(init_arg, opts \\ []) do
    GenServer.start_link(__MODULE__, init_arg, opts)
  end

  @doc """
  Call function with delay timeout.
  """

  @spec call(server :: server()) :: :ok

  def call(server) do
    GenServer.cast(server, :call)
  end

  @doc """
  Immediately invokes the function.
  """
  @spec flush(server :: server()) :: :ok
  def flush(server) do
    GenServer.cast(server, :flush)
  end

  @doc """
  Change timeout delay call function.
  """
  @spec change_timeout(server :: server(), timeout: Millisecond) :: :ok
  def change_timeout(server, timeout) do
    GenServer.call(server, {:timeout, timeout})
  end

  @doc """
  Cancel current debounce.
  """
  @spec cancel(server :: server()) :: :ok
  def cancel(server) do
    GenServer.cast(server, :cancel)
  end

  @doc """
  Stop current process Debounce
  """
  @spec stop(server :: server()) :: :ok
  def stop(server) do
    GenServer.stop(server, :shutdown)
  end

  # Callbacks
  def init(opts) do
    {:ok, %{info: opts, pid: nil}}
  end

  def handle_cast(:call, state) do
    %{pid: pid, info: info} = state

    if pid && is_pid(pid) do
      Process.exit(pid, :kill)
    end

    pid = schedule(info)

    {:noreply, %{info: info, pid: pid}}
  end

  def handle_cast(:reset, state) do
    {:noreply, %{info: state.info, pid: nil}}
  end

  def handle_cast(:flush, state) do
    %{info: info, pid: pid} = state

    if pid && is_pid(pid) do
      Process.exit(pid, :kill)
    end

    {fun, args, _time} = info

    if is_function(fun) do
      fun.(args)
    end

    {:noreply, %{info: info, pid: nil}}
  end

  def handle_cast(:cancel, state) do
    %{info: info, pid: pid} = state

    if pid && is_pid(pid) do
      Process.exit(pid, :kill)
    end

    {:noreply, %{info: info, pid: nil}}
  end

  defp schedule({fun, args, timeout}) do
    pid = self()

    spawn(fn ->
      :timer.sleep(timeout)

      if is_function(fun) do
        fun.(args)
      end

      GenServer.cast(pid, :reset)
    end)
  end
end