lib/desktop/menu.ex

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

  Menus are defined similar 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">> -> Desktop.OS.launch_default_browser(\'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 menus .ex file.

  # XML Structure

  These items are defined:

  ## `<menubar>...menus...</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

  """

  use GenServer

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

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

  @type t() :: %Menu{
          __adapter__: any(),
          app: nil,
          prev_assigns: %{},
          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()) :: Phoenix.LiveView.Rendered.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))

    # Checking for pre LiveView 0.18.0
    if Version.compare(Desktop.live_view_version(), "0.18.0") == :lt do
      quote do
        @behaviour Desktop.Menu
        import Desktop.Menu, only: [assign: 2, connected?: 1]
        import Phoenix.HTML, only: [sigil_e: 2, sigil_E: 2]
        import Phoenix.LiveView.Helpers, only: [sigil_L: 2, sigil_H: 2]
        alias Desktop.Menu

        @before_compile Desktop.Menu
      end
    else
      quote do
        @behaviour Desktop.Menu
        import Desktop.Menu, only: [assign: 2, connected?: 1]
        import Phoenix.HTML, only: [sigil_e: 2, sigil_E: 2]
        import Phoenix.LiveView.Helpers, only: [sigil_L: 2]
        import Phoenix.Component, only: [sigil_H: 2]
        alias Desktop.Menu

        @before_compile Desktop.Menu
      end
    end
  end

  defmacro __before_compile__(env) do
    render? = Module.defines?(env.module, {:render, 1})
    root = Path.dirname(env.file)
    filename = template_filename(env)
    templates = Phoenix.Template.find_all(root, filename)

    case {render?, templates} do
      {true, [template | _]} ->
        IO.warn(
          "ignoring template #{inspect(template)} because the Menu " <>
            "#{inspect(env.module)} defines a render/1 function",
          Macro.Env.stacktrace(env)
        )

        :ok

      {true, []} ->
        :ok

      {false, [template]} ->
        ext = template |> Path.extname() |> String.trim_leading(".") |> String.to_atom()
        engine = Map.fetch!(Phoenix.Template.engines(), ext)
        ast = engine.compile(template, filename)

        quote do
          @file unquote(template)
          @external_resource unquote(template)
          def render(var!(assigns)) when is_map(var!(assigns)) do
            unquote(ast)
          end
        end

      {false, [_ | _]} ->
        IO.warn(
          "multiple templates were found for #{inspect(env.module)}: #{inspect(templates)}",
          Macro.Env.stacktrace(env)
        )

        :ok

      {false, []} ->
        template = Path.join(root, filename <> ".heex")

        message = ~s'''
        render/1 was not implemented for #{inspect(env.module)}.

        Make sure to either explicitly define a render/1 clause with a Menu template:

            def render(assigns) do
              ~H"""
              ...
              """
            end

        Or create a file at #{inspect(template)} with the Menu template.
        '''

        IO.warn(message, Macro.Env.stacktrace(env))

        quote do
          @external_resource unquote(template)
          def render(_assigns) do
            raise unquote(message)
          end
        end
    end
  end

  defp template_filename(env) do
    env.module
    |> Module.split()
    |> List.last()
    |> Macro.underscore()
    |> Kernel.<>(".html")
  end

  def connected?(_menu), do: true
  def assign(menu, properties \\ [])

  def assign(menu, properties) when is_list(properties) do
    assign(menu, Map.new(properties))
  end

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

  def assign(menu, property, value) when is_atom(property) do
    assign(menu, %{property => value})
  end

  def assign_new(menu = %Menu{assigns: assigns}, property, fun)
      when is_atom(property) and is_function(fun) do
    %Menu{menu | assigns: Map.put_new_lazy(assigns, 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(Menu, 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 trigger_event(menu_pid, event) do
    GenServer.cast(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{pid: 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{pid: 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
    GenServer.cast(menu_pid, {:set_icon, icon})
  end

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

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

  @impl true
  def handle_cast({:set_icon, icon}, menu) do
    case set_adapter_icon(menu, icon) do
      {:ok, menu} -> {:noreply, menu}
      _error -> {:noreply, menu}
    end
  end

  def handle_cast({:trigger_event, event}, menu = %{module: module}) do
    menu =
      case invoke_module_func(module, :handle_event, [event, menu]) do
        {:ok, {:noreply, menu}} -> update_dom(menu)
        _ -> menu
      end

    {:noreply, menu}
  end

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

  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

  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

  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 = %Menu{module: module}) do
    case invoke_module_func(module, :mount, [menu]) do
      {:ok, {:ok, menu}} -> update_dom(menu)
      _ -> menu
    end
  end

  defp proxy_handle_info(msg, menu = %Menu{module: module}) do
    case invoke_module_func(module, :handle_info, [msg, menu]) do
      {:ok, {:noreply, menu}} -> update_dom(menu)
      _ -> menu
    end
  end

  @spec update_dom(menu :: t()) :: menu :: t()
  defp update_dom(
         menu = %Menu{
           __adapter__: adapter,
           module: module,
           dom: dom,
           assigns: assigns,
           prev_assigns: prev_assigns
         }
       ) do
    menu = %{menu | prev_assigns: assigns}

    with true <- assigns != prev_assigns,
         {:ok, new_dom} <- invoke_render(module, assigns),
         true <- new_dom != dom do
      adapter = Adapter.update_dom(adapter, new_dom)
      %{menu | __adapter__: adapter, dom: new_dom, last_render: DateTime.utc_now()}
    else
      _ -> menu
    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 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