lib/scenic/driver.ex

#
#  Created by Boyd Multerer on 2021-02-06
#  Copyright © 2021 Kry10 Limited. All rights reserved.
#

defmodule Scenic.Driver do
  @moduledoc """
  The main module for drawing and user input.

  Note: The driver model has completely changed in v0.11.

  Drivers make up the bottom layer of the Scenic architectural stack. They draw
  everything on the screen and originate the raw user input. In general, different
  drawing targets will need different drivers.

  The driver interface provides a great deal of flexibility, but is more
  advanced than writing scenes. You can write drivers that only provide user input,
  only draw scripts, or do both. There is no assumption at all as to what the
  output target or user source is.

  ## Starting Drivers

  Drivers are always managed by a ViewPort that hosts them.

  The most common way to instantiate a driver is set it up in the config of
  a ViewPort when it starts up.

  ```elixir
  config :my_app, :viewport,
    size: {800, 600},
    name: :main_viewport,
    theme: :dark,
    default_scene: MyApp.Scene.MainScene,
    drivers: [
      [
        module: Scenic.Driver.Local,
        name: :local_driver,
        window: [title: "My Application", resizeable: false]
      ],
      [
        module: MyApp.Driver.MyDriver,
        my_param: "abc"
      ],
    ]
  ```

  In the example above, two drivers are configured to be started when the
  `:main_viewport` starts up. Both drivers drivers will be running at the
  same time and will receive the same messages from the ViewPort.

  Drivers can be dynamically started on a `ViewPort` using
  `Scenic.ViewPort.start_driver/2`. They can be dyncamically stopped on
  a `ViewPort` using  `Scenic.ViewPort.stop_driver/2`.

  Drivers can also define their own configuration options. See the
  documentation for the driver you are interested in starting to see the
  available options.

  ## Messages from the ViewPort

  The way a ViewPort communicates with it's drivers is by sending them a
  set of well-known messages. These are picked up by the Driver module and
  sent to your driver through callbacks.

  | Callback | Description |
  |---|---|
  | `reset_scene/1` | The `ViewPort` context has been reset. The driver can clean up all scripts and cached media |
  | `request_input/2` | The ViewPort is requesting user inputs from the `keys` list |
  | `update_scene/2` | The scripts identified by `ids` have been update and should be processed |
  | `del_scripts/2` | The script identified by `id` has been deleted and can be cleaned up |
  | `clear_color/2` | The background clear color has been updated |

  ## Handling Updates

  The main drawing related task of a Driver is to receive the `update_scene/2`
  callback and then draw the updated scripts to the screen, or whatever output medium the
  driver supports.

  In a very simple example, it would look something like this.

  ```elixir
  def update_scene( ids, driver ) do
    Enum.each( ids, fn(id) ->
      with {:ok, script} <- ViewPort.get_script_by_id(vp, id) do
        script
        |> Scenic.Script.serialize()
        |> my_render_script(driver)
      end
    end)
    {:ok, driver}
  end
  ```

  The above example is overly simple and just there to get you started. Note that only
  the ids of the scripts are sent. You still need to fish them out of the `ViewPort`


  ## User Input

  User input events are created whenever a driver has events to share. The `request_input/2`
  callback indicates to you which input events are currently being listened to. This is a guide
  that you can use or not. You can send any valid input event at any time, the `ViewPort` will
  simply ignore those that aren't being listened to.

  In this example, there is some source of user input that casts messages to our driver.

  ```elixir
  def handle_cast( {:my_cursor_press, button, xy}, driver ) do
    send_input(driver, {:cursor_button, {button, 1, [], xy}} )
    { :noreply, driver }
  end
  ```

  No matter what type of input you are sending, it will be checked to
  make sure it conforms the [known input types](Scenic.ViewPort.Input.html#t:t/0).

  ## Updated Input Formats

  There are several changes to the input formats in version v0.11.

  Button indicators are now atoms that conform to standard linux-like buttons. This
  allows for more buttons on a mouse like device. Do not assume these are the only
  buttons that can be sent in a `:cursor_button` message.

  | New Button | Old Button |
  |---|---|
  | `:btn_left` | `:left` |
  | `:btn_right` | `:right` |

  Press and Release messages were atoms and are now numbers. This is to support
  multi-state buttons and joysticks and the like. Turns out that some buttons
  are pressure sensitive and can have a range of values.

  | New Action | Old Action |
  |---|---|
  | `0` | `:release` |
  | `1` | `:press` |

  Modifier keys were previously a number and you would have had to dig into the GLFW
  documentation to see how to interpret it. That was both unintuitive and I wanted to
  make it more source independent. So now it is a list of atoms. If the atom you
  are interested is in the list, then it is pressed.

  ```elixir
  [:ctrl, :shift, :alt, :meta]
  ```

  Test if a modifier key is pressed using the Enum.member?/2 function.
  ```elixir
  mods = [:ctrl, :shift]
  Enum.member?( mods, :shift )
  ```
  """

  alias Scenic.Driver
  alias Scenic.ViewPort
  alias Scenic.Color

  # import IEx
  require Logger

  @root_id ViewPort.root_id()
  # @main_id ViewPort.main_id()

  # ============================================================================
  # Driver Struct

  @type t :: %Driver{
          viewport: ViewPort.t(),
          pid: pid,
          module: atom,
          limit_ms: integer,
          dirty_ids: list,
          gated: boolean,
          input_limited: boolean,
          input_buffer: %{ViewPort.Input.class() => ViewPort.Input.t()},
          busy: boolean,
          requested_inputs: [ViewPort.Input.class()],
          assigns: map,
          update_requested: boolean,
          update_ready: boolean,
          clear_color: Color.rgba()
        }

  defstruct viewport: nil,
            pid: nil,
            module: nil,
            limit_ms: 0,
            dirty_ids: [],
            gated: false,
            input_limited: false,
            input_buffer: %{},
            busy: false,
            requested_inputs: [],
            assigns: %{},
            update_requested: false,
            update_ready: false,
            clear_color: {:color_rgba, {0, 0, 0, 255}}

  @type response_opts ::
          list(
            timeout()
            | :hibernate
            | {:continue, term}
          )

  @init :_init_
  @input_limiter :_input_limiter_expired_
  @not_busy :_not_busy_
  @do_update :_do_update_

  @put_scripts ViewPort.msg_put_scripts()
  @request_input ViewPort.msg_request_input()
  @del_scripts ViewPort.msg_del_scripts()
  @reset_scene ViewPort.msg_reset_scene()
  @gate_start ViewPort.msg_gate_start()
  @gate_complete ViewPort.msg_gate_complete()
  @clear_color ViewPort.msg_clear_color()

  # ============================================================================
  # callback definitions

  @doc """
  Validate the options passed to a Driver.

  The list of options for a driver are passed in as `opts`. If you decide then are
  good, return them, or a transformed set of them as `{:ok, opts}`

  If they are invalid, return either one of:
    * `{:error, String.t()}`
    * `{:error, NimbleOptions.ValidationError.t()}`

  Scenic uses `NimbleOptions` internally for options validation, so `NimbleOptions`
  errors are supported.
  """
  @callback validate_opts(opts :: Keyword.t()) ::
              {:ok, any}
              | {:error, String.t()}
              | {:error, NimbleOptions.ValidationError.t()}

  @doc """
  Initialize a driver process.

  The `ViewPort` and an options list for the driver are passed in. Just like
  initializing any `GenServer` process, it should return `{:ok, state}`
  """
  @callback init(
              driver :: Driver.t(),
              opts :: Keyword.t()
            ) :: {:ok, Driver.t()}

  @doc """
  Called when the scene has been reset.

  This is an opportunity for your driver to clear state that may no longer
  be relevant. This is typically scripts, inputs, media, etc.
  """
  @callback reset_scene(driver :: Driver.t()) :: {:ok, Driver.t()}

  @doc """
  Called when requested input types have changed.

  This informs your driver that the requested input types for the application
  have changed. This is useful if you want to reduce the amount of data being transferred
  between your input source (which might be expensive...) and the driver.

  This callback is optional. If you ignore it and send all input
  events, then only the ones being listened to will be processed.
  """
  @callback request_input(
              input :: [Scenic.ViewPort.Input.class()],
              driver :: Driver.t()
            ) :: {:ok, Driver.t()}

  @doc """
  Called when the scene has been updated.

  The list of ids is the set of script ids that have changed and should be updated.

  Note that the list may be empty if you have requested an update via the
  `request_update/1` function.

  This callback is optional.
  """
  @callback update_scene(
              script_ids :: [Scenic.Script.id()],
              driver :: Driver.t()
            ) :: {:ok, Driver.t()}

  @doc """
  Called when a script has been deleted and can be cleaned up.

  The deleted id is provided.

  This callback is optional.
  """
  @callback del_scripts(
              script_ids :: [Scenic.Script.id()],
              driver :: Driver.t()
            ) :: {:ok, Driver.t()}

  @doc """
  Called when the background color has changed.

  The color is provided.

  This callback is optional.
  """
  @callback clear_color(
              color :: Scenic.Color.t(),
              driver :: Driver.t()
            ) :: {:ok, Driver.t()}

  @optional_callbacks reset_scene: 1,
                      request_input: 2,
                      update_scene: 2,
                      del_scripts: 2,
                      clear_color: 2

  # ===========================================================================
  defmodule Error do
    defexception message: nil
  end

  # ============================================================================
  # client api - working with the driver

  @doc """
  Convenience function to get an assigned value out of a driver struct.
  """
  @spec get(driver :: Driver.t(), key :: any, default :: any) :: any
  def get(%Driver{assigns: assigns}, key, default \\ nil) do
    Map.get(assigns, key, default)
  end

  @doc """
  Convenience function to fetch an assigned value out of a driver struct.
  """
  @spec fetch(driver :: Driver.t(), key :: any) :: {:ok, any} | :error
  def fetch(%Driver{assigns: assigns}, key) do
    Map.fetch(assigns, key)
  end

  @doc """
  Convenience function to assign a list of values into a driver struct.
  """
  @spec assign(driver :: Driver.t(), key_list :: Keyword.t()) :: Driver.t()
  def assign(%Driver{} = driver, key_list) when is_list(key_list) do
    Enum.reduce(key_list, driver, fn {k, v}, acc -> assign(acc, k, v) end)
  end

  @doc """
  Convenience function to assign a value into a driver struct.
  """
  @spec assign(driver :: Driver.t(), key :: any, value :: any) :: Driver.t()
  def assign(%Driver{assigns: assigns} = driver, key, value) do
    %{driver | assigns: Map.put(assigns, key, value)}
  end

  @doc """
  Convenience function to assign a list of new values into a driver struct.

  Only values that do not already exist will be assigned
  """
  @spec assign_new(driver :: Driver.t(), key_list :: Keyword.t()) :: Driver.t()
  def assign_new(%Driver{} = driver, key_list) when is_list(key_list) do
    Enum.reduce(key_list, driver, fn {k, v}, acc -> assign_new(acc, k, v) end)
  end

  @doc """
  Convenience function to assign a new values into a driver struct.

  The value will only be assigned if it does not already exist in the struct.
  """
  @spec assign_new(driver :: Driver.t(), key :: any, value :: any) :: Driver.t()
  def assign_new(%Driver{assigns: assigns} = driver, key, value) do
    %{driver | assigns: Map.put_new(assigns, key, value)}
  end

  @doc """
  Set or clear the busy flag.

  When the busy flag is set, put_script messages will be consolidated until cleared.
  """
  @spec set_busy(driver :: Driver.t(), flag :: boolean) :: Driver.t()
  def set_busy(%Driver{} = driver, flag) when is_boolean(flag) do
    %{driver | busy: flag}
  end

  @doc """
  Send input from the driver.

  Send input from the driver to its ViewPort. `:cursor_pos` and `:cursor_scroll`
  input will be buffered/rate limited according the driver's `:limit_ms` setting.
  """
  @spec send_input(driver :: Driver.t(), input :: ViewPort.Input.t()) :: Driver.t()
  def send_input(%Driver{limit_ms: 0} = drvr, input), do: do_send_input(drvr, input)

  def send_input(
        %Driver{input_limited: true, input_buffer: buffer} = driver,
        {class, _} = input
      ) do
    # Logger.warn( "input_limited #{inspect({input})}" )
    case class do
      :cursor_pos -> %{driver | input_buffer: Map.put(buffer, class, input)}
      :cursor_scroll -> %{driver | input_buffer: Map.put(buffer, class, input)}
      # Everything else is sent right away
      _ -> do_send_input(driver, input)
    end
  end

  def send_input(
        %Driver{limit_ms: limit_ms} = driver,
        {class, _} = input
      ) do
    # Logger.warn( "input #{inspect({input})}" )
    case class do
      :cursor_pos ->
        Process.send_after(self(), @input_limiter, limit_ms)
        do_send_input(%{driver | input_limited: true}, input)

      :cursor_scroll ->
        Process.send_after(self(), @input_limiter, limit_ms)
        do_send_input(%{driver | input_limited: true}, input)

      _ ->
        # Everything else is does not trigger a limit
        do_send_input(driver, input)
    end
  end

  defp do_send_input(
         %Driver{viewport: vp, requested_inputs: requested_inputs} = driver,
         {input_type, _} = input
       ) do
    if Enum.member?(requested_inputs, input_type) do
      case ViewPort.input(vp, input) do
        :ok ->
          :ok

        {:error, :invalid} ->
          Logger.error("""
          #{inspect(driver.module)} attempted send an improperly formatted input message.
          Received: #{inspect(input)}
          """)
      end
    end

    driver
  end

  @doc """
  Request a scene_updated call.

  This is used when scripts are updated. Some drivers use it to batch updates
  into a single atomic operation. This call is rate limited by limit_ms.
  """

  def request_update(%Driver{update_requested: true} = driver), do: driver
  def request_update(%Driver{update_ready: true} = driver), do: driver

  # no limiter. update right away.
  def request_update(%Driver{limit_ms: 0, pid: pid} = driver) do
    send(pid, @do_update)
    %{driver | update_requested: true}
  end

  def request_update(%Driver{limit_ms: limit_ms, pid: pid} = driver) do
    Process.send_after(pid, @do_update, limit_ms)
    %{driver | update_requested: true}
  end

  # updating doesn't happen until it is marked ready, not gated and not busy
  defp do_update(%Driver{update_requested: false} = driver), do: driver
  defp do_update(%Driver{update_ready: false} = driver), do: driver
  defp do_update(%Driver{gated: true} = driver), do: driver
  defp do_update(%Driver{busy: true} = driver), do: driver

  # perform an actual update
  defp do_update(%Driver{module: module, dirty_ids: ids} = driver) do
    case Kernel.function_exported?(module, :update_scene, 2) do
      true ->
        ids =
          ids
          |> List.flatten()
          |> Enum.uniq()

        case module.update_scene(ids, %{driver | dirty_ids: ids}) do
          {:ok, %Driver{} = driver} -> driver
          other -> raise state_msg("update_scene", other)
        end

      false ->
        driver
    end
    |> Map.put(:update_requested, false)
    |> Map.put(:update_ready, false)
    |> Map.put(:dirty_ids, [])
  end

  # ===========================================================================
  # the using macro for scenes adopting this behavior
  defmacro __using__(_opts) do
    quote do
      use GenServer
      @behaviour Scenic.Driver

      import Scenic.Driver,
        only: [
          get: 2,
          get: 3,
          fetch: 2,
          assign: 2,
          assign: 3,
          assign_new: 2,
          assign_new: 3,
          set_busy: 2,
          send_input: 2
        ]

      @doc false
      def init(_param), do: :ignore
    end

    # quote
  end

  # ===========================================================================
  # calls for starting up a driver

  @doc false
  def child_spec(data) do
    %{
      id: make_ref(),
      start: {__MODULE__, :start_link, [data]},
      type: :worker,
      restart: :permanent,
      shutdown: 500
    }
  end

  @doc false
  # internal start_link
  def start_link({vp_info, opts}) do
    # GenServer.start_link(__MODULE__, {vp_info, opts})
    case opts[:name] do
      nil -> GenServer.start_link(__MODULE__, {vp_info, opts})
      name -> GenServer.start_link(__MODULE__, {vp_info, opts}, name: name)
    end
  end

  # --------------------------------------------------------
  @doc false
  def init({vp, _opts} = data) do
    GenServer.cast(vp.pid, {:register_driver, self()})
    {:ok, nil, {:continue, {@init, data}}}
  end

  # ============================================================================
  # terminate

  def terminate(reason, %Driver{module: module} = driver) do
    case Kernel.function_exported?(module, :terminate, 2) do
      true -> module.terminate(reason, driver)
      false -> nil
    end
  end

  def terminate(reason, _state), do: reason

  # --------------------------------------------------------
  @doc false
  def handle_continue({@init, {vp, opts}}, nil) do
    {:ok, module} = Keyword.fetch(opts, :module)

    # create the driver struct
    driver = %Driver{
      viewport: vp,
      module: module,
      pid: self(),
      limit_ms: opts[:limit_ms] || 0
    }

    # start up the scene
    case module.init(driver, Keyword.delete(opts, :module)) do
      {:ok, %Driver{} = driver} ->
        {:noreply, driver}

      {:ok, other} ->
        raise """
        Driver callback init returned an invalid Scenic.Driver struct
        Received: #{inspect(other)}
        """

      {:ok, %Driver{} = state, opt} ->
        {:noreply, state, opt}

      {:ok, other, _opt} ->
        raise """
        Driver callback init returned an invalid Scenic.Driver struct
        Received: #{inspect(other)}
        """

      :ignore ->
        :ignore

      {:stop, reason} ->
        {:stop, reason}
    end
  end

  # --------------------------------------------------------
  @doc false
  def handle_continue(msg, %Driver{module: module, busy: old_busy} = driver) do
    case module.handle_continue(msg, driver) do
      {:noreply, %Driver{busy: new_busy} = driver} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver}

      {:noreply, state} ->
        raise """
        Driver callback handle_continue must return a driver struct as the state
        Received: #{inspect(state)}
        """

      {:noreply, %Driver{busy: new_busy} = driver, opts} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver, opts}

      {:noreply, state, _opts} ->
        raise """
        Driver callback handle_continue must return a driver struct as the state
        Received: #{inspect(state)}
        """

      response ->
        response
    end
  end

  # --------------------------------------------------------
  # info
  @doc false

  def handle_info(@do_update, driver), do: handle_do_update(driver)
  def handle_info(@not_busy, driver), do: do_not_busy(driver)
  def handle_info(@input_limiter, driver), do: do_input_limit_expired(driver)
  # def handle_info(@limiter, driver), do: do_limit_expired(driver)
  def handle_info({@put_scripts, ids}, driver), do: do_put_scripts(ids, driver)
  def handle_info({@del_scripts, ids}, driver), do: do_del_scripts(ids, driver)
  def handle_info({@request_input, req}, driver), do: do_input_reqs(req, driver)
  def handle_info(@reset_scene, driver), do: do_reset_scene(driver)
  def handle_info(@gate_start, driver), do: do_gate_start(driver)
  def handle_info(@gate_complete, driver), do: do_gate_complete(driver)
  def handle_info({@clear_color, color}, driver), do: do_clear_color(color, driver)

  # generic handle_info. give the driver a chance to handle it
  def handle_info(msg, %Driver{module: module, busy: old_busy} = driver) do
    case module.handle_info(msg, driver) do
      {:noreply, %Driver{busy: new_busy} = driver} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver}

      {:noreply, state} ->
        raise """
        Driver callback handle_info must return a driver struct as the state
        Received: #{inspect(state)}
        """

      {:noreply, %Driver{busy: new_busy} = driver, opts} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver, opts}

      {:noreply, state, _opts} ->
        raise """
        Driver callback handle_info must return a driver struct as the state
        Received: #{inspect(state)}
        """

      response ->
        response
    end
  end

  # --------------------------------------------------------
  # cast
  @doc false
  def handle_cast(msg, %Driver{module: module, busy: old_busy} = driver) do
    case module.handle_cast(msg, driver) do
      {:noreply, %Driver{busy: new_busy} = driver} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver}

      {:noreply, state} ->
        raise """
        Driver callback handle_cast must return a driver struct as the state
        Received: #{inspect(state)}
        """

      {:noreply, %Driver{busy: new_busy} = driver, opts} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver, opts}

      {:noreply, state, _opts} ->
        raise """
        Driver callback handle_cast must return a driver struct as the state
        Received: #{inspect(state)}
        """

      response ->
        response
    end
  end

  # --------------------------------------------------------
  @doc false
  def handle_call(msg, from, %Driver{module: module, busy: old_busy} = driver) do
    case module.handle_call(msg, from, driver) do
      {:noreply, %Driver{busy: new_busy} = driver} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver}

      {:noreply, other} ->
        raise """
        Driver callback handle_call must return a driver struct as the state
        Received: #{inspect(other)}
        """

      {:noreply, %Driver{busy: new_busy} = driver, opts} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:noreply, driver, opts}

      {:noreply, other, _opts} ->
        raise """
        Driver callback handle_call must return a driver struct as the state
        Received: #{inspect(other)}
        """

      {:reply, reply, %Driver{busy: new_busy} = driver} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:reply, reply, driver}

      {:reply, _reply, other} ->
        raise """
        Driver callback handle_call must return a driver struct as the state
        Received: #{inspect(other)}
        """

      {:reply, reply, %Driver{busy: new_busy} = driver, opts} ->
        if old_busy && !new_busy, do: send(self(), @not_busy)
        {:reply, reply, driver, opts}

      {:reply, _reply, other, _opts} ->
        raise """
        Driver callback handle_call must return a driver struct as the state
        Received: #{inspect(other)}
        """

      response ->
        response
    end
  end

  # ============================================================================
  # internal handlers

  defp state_msg(name, data) do
    """
    Driver callback '#{name}' must return {:ok, driver}
    The 'driver' field must be a valid %Scenic.Driver{} struct.
    Received: #{inspect(data)}
    """
  end

  defp do_not_busy(%Driver{} = driver) do
    {:noreply, do_update(%{driver | busy: false})}
  end

  defp handle_do_update(%Driver{} = driver) do
    {:noreply, do_update(%{driver | update_ready: true})}
  end

  defp do_put_scripts([], driver), do: {:noreply, driver}

  defp do_put_scripts(ids, %Driver{dirty_ids: dirty_ids} = driver) do
    {:noreply, request_update(%{driver | dirty_ids: [ids | dirty_ids]})}
  end

  defp do_input_limit_expired(
         %Driver{viewport: vp, limit_ms: limit_ms, input_buffer: buffer} = driver
       ) do
    case buffer == %{} do
      true ->
        # no buffered input. End the rate limit.
        {:noreply, %{driver | input_limited: false}}

      false ->
        Process.send_after(self(), @input_limiter, limit_ms)
        Enum.each(buffer, fn {_, input} -> ViewPort.input(vp, input) end)
        {:noreply, %{driver | input_limited: true, input_buffer: %{}}}
    end
  end

  defp do_del_scripts(ids, %Driver{module: module} = driver) do
    case Kernel.function_exported?(module, :del_scripts, 2) do
      true ->
        case module.del_scripts(ids, driver) do
          {:ok, %Driver{} = driver} -> {:noreply, driver}
          other -> raise state_msg("del_scripts", other)
        end

      false ->
        {:noreply, driver}
    end
  end

  defp do_input_reqs(requested_inputs, %Driver{module: module} = driver) do
    # always update the inputs even if the callback isn't defined
    driver = %{driver | requested_inputs: requested_inputs}

    case Kernel.function_exported?(module, :request_input, 2) do
      true ->
        case module.request_input(requested_inputs, driver) do
          {:ok, %Driver{} = driver} -> {:noreply, driver}
          other -> raise state_msg("request_input", other)
        end

      false ->
        {:noreply, driver}
    end
  end

  defp do_reset_scene(%Driver{module: module} = driver) do
    driver =
      case Kernel.function_exported?(module, :reset_scene, 1) do
        true ->
          driver
          |> module.reset_scene()
          |> case do
            {:ok, %Driver{} = driver} -> driver
            other -> raise state_msg("reset", other)
          end

        false ->
          driver
      end
      |> Map.put(:dirty_ids, [@root_id])
      |> Map.put(:gated, false)

    # |> Map.put( :update_requested, false )
    # |> Map.put( :update_ready, false )

    {:noreply, driver}
  end

  defp do_gate_start(%Driver{} = driver) do
    {:noreply, %{driver | gated: true}}
  end

  defp do_gate_complete(%Driver{} = driver) do
    {:noreply, do_update(%{driver | gated: false})}
  end

  defp do_clear_color(color, %Driver{module: module} = driver) do
    color = Color.to_rgba(color)
    driver = %{driver | clear_color: color}

    driver =
      case Kernel.function_exported?(module, :clear_color, 2) do
        true ->
          case module.clear_color(color, driver) do
            {:ok, %Driver{} = driver} -> driver
            other -> raise state_msg("clear_color", other)
          end

        false ->
          driver
      end

    {:noreply, driver}
  end

  # --------------------------------------------------------
  # options validation
  @opts_schema [
    module: [required: true, type: :atom],
    name: [type: :atom]
  ]

  @doc false
  def validate([]), do: {:ok, []}

  def validate(drivers) do
    case Enum.reduce(drivers, [], &do_validate(&1, &2)) do
      opts when is_list(opts) -> {:ok, Enum.reverse(opts)}
      err -> err
    end
  end

  defp do_validate(opts, drivers) do
    opts = Enum.into(opts, [])

    core_opts =
      []
      |> put_set(:module, opts[:module])
      |> put_set(:name, opts[:name])

    driver_opts =
      opts
      |> Keyword.delete(:module)
      |> Keyword.delete(:name)

    with {:ok, core} <- NimbleOptions.validate(core_opts, @opts_schema),
         {:ok, opts} <- core[:module].validate_opts(driver_opts) do
      [core ++ opts | drivers]
    else
      {:error, %NimbleOptions.ValidationError{} = error} ->
        raise Exception.message(error)
        # err -> err
    end
  end

  defp put_set(opts, _, nil), do: opts
  defp put_set(opts, key, value), do: Keyword.put(opts, key, value)
end