lib/desktop/menu.ex

defmodule Desktop.Menu do
  @moduledoc """
  Menu module used to create and handle menus in Desktop

  Menues are defined similiar to Live View using a callback module an XML:

  ```
    defmodule ExampleMenuBar do
      use Desktop.Menu

      @impl true
      def mount(menu) do
        menu = assign(menu, items: ExampleRepo.all_items())
        {:ok, menu}
      end

      @impl true
      def handle_event(command, menu) do
        case command do
          <<"open">> -> :not_implemented
          <<"quit">> -> Desktop.Window.quit()
          <<"help">> -> :wx_misc.launchDefaultBrowser(\'https://google.com\')
          <<"about">> -> :not_implemented
        end

        {:noreply, menu}
      end

      @impl true
      def render(assigns) do
        ~E\"""
        <menubar>
          <menu label="<%= gettext "File" %>">
              <item onclick="open"><%= gettext "Open" %></item>
              <hr/>
              <item onclick="quit"><%= gettext "Quit" %></item>
          </menu>
          <menu label="<%= gettext "Items" %>">
            <%= for item <- @items do %>
              <item><%= item.name %></item>
            <% end %>
          </menu>
          <menu label="<%= gettext "Help" %>">
              <item onclick="help"><%= gettext "Show Documentation" %></item>
              <item onclick="about"><%= gettext "About" %></item>
          </menu>
        </menubar>
        \"""
      end
    end
  ```

  # Template

  As in live view the template can either be embedded in the `def render(assigns)`
  method or it can be side loaded as a .eex file next to the menues .ex file.

  # XML Structure

  These items are defined:

  ## `<menubar>...menues...</menubar>`

  For an application (window) menubar this must be the root element. When
  passing a menubar to `Desktop.Window` start parameters this has to be the root element.
  It has no attributes

  ## `<menu label="Label">...items...</menu>`

  For an icon menu `menu` must be the root element. Menu elements can contain multiple
  children of type `menu`, `item` and `hr`

  ### Attributes

  * `label` - the label that should be displayed on the menu

  ## `<item ...>Label</item>`

  This is an entry in the menu with a text a type and an onclick action

  ### Attributes

  * `onclick` - an event name that should be fired when this item is clicked. It will cause `handle_event/2` to be called
  * `type`        - the type of the item. The default is `normal`, but it can be either
    * `normal`    - a normal text item
    * `radio`     - a radio button
    * `checkbox`  - a checkbox item
  * `checked` - whether the `checkbox` or `radio` button should be checked. `nil`, `false` and `0` are treated
  as false values, every other value is treated as true.
  * `disabled` - whether the item should be disabled. `nil`, `false` and `0` are treated
  as false values, every other value is treated as true.

  ## `<hr />`

  A separator item

  # Escaping

  Within the XML document it will be neccesary to escape dynamic content. Within for normal text content the `escape(text)` function is safe
  to use. For xml attributes `escape_attribute(value)` can be used:

  ```
    <menu label="escape_attribute(@menu_label)">
        <item onclick="open"><%= escape(@user_input) %></item>
    </menu>
  ```
  """

  use GenServer

  require Logger
  alias Desktop.Menu
  alias Desktop.Menu.{Adapter, Parser}

  defstruct [
    :__adapter__,
    :app,
    :assigns,
    :module,
    :dom,
    :pid,
    :last_render
  ]

  @type t() :: %__MODULE__{
          __adapter__: any(),
          app: nil,
          assigns: %{},
          module: module,
          dom: any(),
          pid: nil | pid(),
          last_render: nil | DateTime.t()
        }

  @callback mount(assigns :: map()) :: {:ok, map()}
  @callback handle_event(event_name :: String.t(), assigns :: map()) :: {:noreply, map()}
  @callback handle_info(any(), assigns :: map()) :: {:noreply, map()}
  @callback render(Keyword.t()) :: String.t()

  @doc false
  defmacro __using__(opts) do
    Module.register_attribute(__CALLER__.module, :is_menu_server, persist: true, accumulate: false)

    Module.put_attribute(__CALLER__.module, :is_menu_server, Keyword.get(opts, :server, true))

    quote do
      @behaviour Desktop.Menu
      import Desktop.Menu, only: [assign: 2, escape: 1, escape_attribute: 1]
      import Phoenix.HTML, only: [sigil_e: 2, sigil_E: 2]
      alias Desktop.Menu

      if not Keyword.get(unquote(opts), :skip_render, false) do
        require EEx
        @template Path.basename(__ENV__.file, ".ex") <> ".eex"
        EEx.function_from_file(:def, :render, "#{__DIR__}/#{@template}", [:assigns])
        defoverridable render: 1
      end
    end
  end

  def escape(string) do
    Parser.escape(string)
  end

  def escape_attribute(string) do
    Parser.escape_attribute(string)
  end

  def assign(assigns = %{}) do
    assigns
  end

  def assign(assigns = %{}, properties) when is_list(properties) do
    assign(assigns, Map.new(properties))
  end

  def assign(assigns = %{}, properties) when is_map(properties) do
    assigns
    |> Map.merge(properties)
  end

  def assign(assigns = %{}, property, value) when is_atom(property) do
    assigns
    |> Map.put(property, value)
  end

  def assign_new(assigns = %{}, property, value) when is_atom(property) do
    assigns
    |> Map.put_new(property, value)
  end

  def assign_new(assigns = %{}, property, fun) when is_function(fun) do
    assigns
    |> Map.put_new_lazy(property, fun)
  end

  # GenServer implementation

  def start!(init_opts \\ [], opts \\ [])

  def start!(init_opts, opts) do
    case start_link(init_opts, opts) do
      {:ok, pid} -> pid
      {:error, {:already_started, pid}} -> pid
      {:error, reason} -> raise reason
      :ignore -> nil
    end
  end

  @spec start_link(keyword(), keyword()) :: GenServer.on_start()
  def start_link(init_opts \\ [], opts \\ [])

  def start_link(init_opts, opts) do
    GenServer.start_link(__MODULE__, init_opts, opts)
  end

  @impl true
  def init(init_opts) do
    menu_pid = self()
    module = Keyword.get(init_opts, :module)
    dom = Keyword.get(init_opts, :dom, [])
    app = Keyword.get(init_opts, :app, nil)

    adapter_module =
      case Keyword.get(init_opts, :adapter, Adapter.Wx) do
        mod when mod in [Adapter.Wx, Adapter.DBus] -> mod
        _ -> Adapter.Wx
      end

    adapter_opts =
      init_opts
      |> Keyword.drop([:dom, :adapter])
      |> Keyword.put(:menu_pid, menu_pid)

    adapter =
      adapter_opts
      |> adapter_module.new()
      |> Adapter.create(dom)

    menu =
      %Menu{
        __adapter__: adapter,
        app: app,
        module: module,
        dom: dom,
        assigns: %{},
        pid: menu_pid
      }
      |> do_mount()

    if is_module_server?(module) do
      Process.register(menu_pid, module)
    end

    {:ok, menu}
  end

  # def mount(menu_pid) do
  #   GenServer.cast(menu_pid, :mount)
  # end

  def trigger_event(menu_pid, event) do
    GenServer.call(menu_pid, {:trigger_event, event})
  end

  def popup_menu(menu_pid) do
    GenServer.call(menu_pid, :popup_menu)
  end

  def menubar(menu_pid) do
    GenServer.call(menu_pid, :menubar)
  end

  def get_icon(%{__menu__: menu_pid}) when is_pid(menu_pid) do
    get_icon(menu_pid)
  end

  def get_icon(menu_pid) when is_pid(menu_pid) do
    GenServer.call(menu_pid, :get_icon)
  end

  def set_icon(%{__menu__: menu_pid}, icon) when is_pid(menu_pid) do
    set_icon(menu_pid, icon)
  end

  def set_icon(menu_pid, icon) when is_pid(menu_pid) do
    if menu_pid == self() do
      spawn_link(__MODULE__, :set_icon, [menu_pid, icon])
    else
      GenServer.call(menu_pid, {:set_icon, icon})
    end
  end

  @impl true
  def handle_call(:menubar, _from, menu = %{__adapter__: adapter}) do
    {:reply, Adapter.menubar(adapter), menu}
  end

  @impl true
  def handle_call(:get_icon, _from, menu) do
    {:reply, get_adapter_icon(menu), menu}
  end

  @impl true
  def handle_call({:set_icon, icon}, _from, menu) do
    case set_adapter_icon(menu, icon) do
      {:ok, menu} -> {:reply, get_adapter_icon(menu), menu}
      error -> {:reply, error, menu}
    end
  end

  @impl true
  def handle_call({:trigger_event, event}, _from, menu = %{module: module}) do
    assigns = build_assigns(menu)

    menu =
      case invoke_module_func(module, :handle_event, [event, assigns]) do
        {:ok, {:ok, assigns}} -> maybe_update_dom(menu, assigns)
        {:ok, {:noreply, assigns}} -> maybe_update_dom(menu, assigns)
        _ -> menu
      end

    {:reply, build_assigns(menu), menu}
  end

  @impl true
  def handle_cast(:popup_menu, menu = %{__adapter__: adapter}) do
    adapter = Adapter.popup_menu(adapter)
    {:noreply, %{menu | __adapter__: adapter}}
  end

  @impl true
  def handle_cast(:recreate_menu, menu = %{__adapter__: adapter, dom: dom}) do
    # This is called from within the Adapter
    adapter = Adapter.recreate_menu(adapter, dom)
    {:noreply, %{menu | __adapter__: adapter}}
  end

  @impl true
  def handle_cast(:mount, menu) do
    {:noreply, do_mount(menu)}
  end

  @impl true
  def handle_info(event, menu = %{__adapter__: adapter = %{__struct__: adapter_module}})
      when is_tuple(event) and elem(event, 0) == :wx do
    {:noreply, adapter} = adapter_module.handle_info(event, adapter)

    {:noreply, %{menu | __adapter__: adapter}}
  end

  @impl true
  def handle_info(msg, menu) do
    {:noreply, proxy_handle_info(msg, menu)}
  end

  # Private functions

  defp get_adapter_icon(%{__adapter__: adapter}) do
    Adapter.get_icon(adapter)
  end

  defp set_adapter_icon(menu = %{app: app}, {:file, icon}) do
    with {:ok, wx_icon} <- Desktop.Image.new_icon(app, icon),
         ret = {:ok, _menu} <- set_adapter_icon(menu, wx_icon) do
      # Destroy the :wxIcon
      Desktop.Image.destroy(wx_icon)
      # Now return the result
      ret
    end
  end

  defp set_adapter_icon(menu = %{__adapter__: adapter}, icon) do
    with {:ok, adapter} <- Adapter.set_icon(adapter, icon) do
      menu = %{menu | __adapter__: adapter}
      {:ok, menu}
    end
  end

  defp do_mount(menu = %__MODULE__{module: module}) do
    assigns = build_assigns(menu)

    case invoke_module_func(module, :mount, [assigns]) do
      {:ok, {:ok, assigns}} ->
        case update_dom(%{menu | assigns: assigns}) do
          {:ok, _updated?, menu} -> menu
          _error -> menu
        end

      _ ->
        menu
    end
  end

  defp proxy_handle_info(msg, menu = %__MODULE__{module: module}) do
    assigns = build_assigns(menu)

    case invoke_module_func(module, :handle_info, [msg, assigns]) do
      {:ok, {:ok, assigns}} -> maybe_update_dom(menu, assigns)
      {:ok, {:noreply, assigns}} -> maybe_update_dom(menu, assigns)
      _ -> menu
    end
  end

  defp maybe_update_dom(menu, assigns = %{__menu__: _}) do
    maybe_update_dom(menu, Map.delete(assigns, :__menu__))
  end

  defp maybe_update_dom(menu = %{assigns: assigns}, assigns) do
    menu
  end

  defp maybe_update_dom(menu = %{}, assigns) do
    case update_dom(%{menu | assigns: assigns}) do
      {:ok, _updated?, menu} ->
        menu

      _error ->
        menu
    end
  end

  defp maybe_update_dom(menu, _) do
    menu
  end

  @spec update_dom(menu :: t()) :: {:ok, updated :: boolean(), menu :: t()} | {:error, binary()}
  defp update_dom(
         menu = %__MODULE__{__adapter__: adapter, module: module, dom: dom, assigns: assigns}
       ) do
    with {:ok, new_dom} <- invoke_render(module, assigns) do
      if new_dom != dom do
        adapter = Adapter.update_dom(adapter, new_dom)
        {:ok, true, %{menu | __adapter__: adapter, dom: new_dom, last_render: DateTime.utc_now()}}
      else
        {:ok, false, menu}
      end
    end
  end

  @spec invoke_render(module :: module(), assigns :: map()) ::
          {:ok, any()} | {:error, binary()}
  defp invoke_render(module, assigns) do
    with {:ok, str_render} <- invoke_module_func(module, :render, [assigns]) do
      {:ok, Parser.parse(str_render)}
    end
  end

  @spec invoke_module_func(module :: module(), func :: atom(), args :: list(any())) ::
          {:error, binary()} | {:ok, any()}
  defp invoke_module_func(module, func, args) do
    try do
      Kernel.apply(module, func, args)
    rescue
      error ->
        Logger.error(Exception.format(:error, error, __STACKTRACE__))
        {:error, "Failed to invoke #{module}.#{func}/#{Enum.count(args)}"}
    else
      return -> {:ok, return}
    end
  end

  defp build_assigns(%__MODULE__{assigns: assigns, pid: menu_pid}) do
    assigns
    |> Map.merge(%{__menu__: menu_pid})
  end

  defp is_module_server?(module) do
    try do
      case Keyword.get(module.__info__(:attributes), :is_menu_server, false) do
        [true] -> true
        _ -> false
      end
    rescue
      _error -> false
    end
  end
end