Packages
mob
0.7.3
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/composite.ex
defmodule Mob.Composite do
@moduledoc """
Pure-Elixir composite components: the third expansion pass.
A composite is a TAG that expands to a built-in widget tree — no native
code. UI-kit authors register an expander per tag atom and users write
`<MishkaCombobox … />` in `~MOB`; this pass replaces the node with the
expander's output before `Mob.List.expand` / `Mob.Component.expand` run
(so a composite may itself emit `<List>` or `Mob.UI.native_view`).
## Registering
Via a plugin manifest (the `expand:` ui_components form, MOB_PLUGINS.md):
ui_components: [
%{tag: "MishkaCombobox", atom: :mishka_combobox,
expand: {Mishka.Combobox, :expand}}
]
…registered automatically at boot. Or at runtime (plain Hex UI kits with
no manifest — call from the host's `on_start/0`):
Mob.Composite.register(:mishka_combobox, {Mishka.Combobox, :expand})
## The expander contract
def expand(props, children, ctx)
`props` are the node's props with EVENT TARGETS AUTO-INJECTED: any `on_*`
prop written as a bare atom or string (`on_select="combo_select"`) arrives
as `{screen_pid, :combo_select}` — no `self()` threading. `children` are
the (already composite-expanded) child nodes; `ctx` is
`%{screen: pid, platform: platform}`. Return a node map or a list of nodes
(the `~MOB` sigil output). Output is re-expanded to a fixpoint (composites
can build on composites) with a depth guard of #{20}.
Composites are stateless by design — state lives in the screen (or in a
`Mob.Component` if a part of the tree needs its own process). Hot-pushable:
pure Elixir, same rule as any screen module.
"""
require Logger
@pt_key {__MODULE__, :expanders}
@max_depth 20
@doc """
Registers an expander for a composite tag atom. Overwrites any existing
registration for `atom`.
"""
@spec register(atom(), {module(), atom()}) :: :ok
def register(atom, {mod, fun}) when is_atom(atom) and is_atom(mod) and is_atom(fun) do
:persistent_term.put(@pt_key, Map.put(expanders(), atom, {mod, fun}))
:ok
end
@doc "The registered expanders (`%{atom => {module, function}}`)."
@spec expanders() :: %{atom() => {module(), atom()}}
def expanders, do: :persistent_term.get(@pt_key, %{})
@doc false
# Test seam: drop all registrations.
@spec reset() :: :ok
def reset do
:persistent_term.put(@pt_key, %{})
:ok
end
@doc """
The expansion pass. Walks the tree; nodes whose `:type` has a registered
expander are replaced by the expander output (recursively, to a fixpoint).
A crashing expander logs and renders nothing (an empty Column) rather than
taking the screen down.
"""
@spec expand(map() | [map()], pid()) :: map() | [map()]
def expand(tree, screen_pid) do
do_expand(tree, screen_pid, expanders(), 0)
end
defp do_expand(nodes, pid, exp, depth) when is_list(nodes) do
nodes
|> Enum.map(&do_expand(&1, pid, exp, depth))
|> List.flatten()
end
defp do_expand(%{type: type} = node, pid, exp, depth) do
case Map.fetch(exp, type) do
{:ok, {mod, fun}} when depth < @max_depth ->
node
|> run_expander(mod, fun, pid)
|> do_expand(pid, exp, depth + 1)
{:ok, _} ->
Logger.error(
"[mob_composite] #{inspect(type)} exceeded the expansion depth guard " <>
"(#{@max_depth}) — circular composite? Rendering nothing for this node."
)
empty_node()
:error ->
children = node |> Map.get(:children, []) |> do_expand(pid, exp, depth)
Map.put(node, :children, children)
end
end
defp do_expand(other, _pid, _exp, _depth), do: other
defp run_expander(node, mod, fun, pid) do
props = node |> Map.get(:props, %{}) |> inject_event_targets(pid)
children = Map.get(node, :children, [])
try do
apply(mod, fun, [props, children, %{screen: pid}])
rescue
e ->
Logger.error(
"[mob_composite] #{inspect(mod)}.#{fun}/3 for #{inspect(node.type)} crashed: " <>
Exception.format(:error, e, __STACKTRACE__)
)
empty_node()
end
end
# `on_*` props written as a bare atom or string become `{screen_pid, tag}` —
# the event-target shape every built-in widget expects — so composite users
# (and composite authors passing them through) never thread `self()`.
# Already-shaped `{pid, tag}` values pass through untouched.
@doc false
@spec inject_event_targets(map(), pid()) :: map()
def inject_event_targets(props, pid) when is_map(props) do
Map.new(props, fn
{key, value} = pair ->
if event_key?(key) do
case value do
v when is_atom(v) and not is_nil(v) and not is_boolean(v) -> {key, {pid, v}}
v when is_binary(v) -> {key, {pid, String.to_atom(v)}}
_ -> pair
end
else
pair
end
end)
end
defp event_key?(key) when is_atom(key),
do: key |> Atom.to_string() |> String.starts_with?("on_")
defp event_key?(_), do: false
defp empty_node, do: %{type: :column, props: %{}, children: []}
end