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