Packages
mob
0.4.0
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/renderer.ex
defmodule Mob.Renderer do
@moduledoc """
Serializes a component tree to JSON and passes it to the platform NIF in
a single call. Compose (Android) and SwiftUI (iOS) handle diffing and
rendering internally.
## Node format
%{
type: :column,
props: %{padding: :space_md, background: :surface},
children: [
%{type: :text, props: %{text: "Hello", text_size: :xl, text_color: :on_surface}, children: []},
%{type: :button, props: %{text: "Tap", on_tap: self()}, children: []}
]
}
## Token resolution
Atom values for color props, spacing props, radius props, and text sizes are
resolved at render time through the active `Mob.Theme` and the base palette.
**Color props** (`:background`, `:text_color`, `:border_color`, `:color`,
`:placeholder_color`): resolved via theme semantic tokens first, then the
base palette. E.g. `:primary` → theme's primary → `:blue_500` → `0xFF2196F3`.
**Spacing props** (`:padding`, `:padding_top`, etc., `:gap`): accept spacing
tokens (`:space_xs`, `:space_sm`, `:space_md`, `:space_lg`, `:space_xl`)
that are scaled by `theme.space_scale`.
**Radius props** (`:corner_radius`): accept `:radius_sm`, `:radius_md`,
`:radius_lg`, `:radius_pill` from the active theme.
**Text size props** (`:text_size`, `:font_size`): token atoms (`:xl`, `:lg`,
etc.) are multiplied by `theme.type_scale`.
## Component defaults
When a component's props omit styling keys, the renderer injects sensible
defaults from the active theme. Explicit props always win over defaults.
# Gets primary background, white text, medium radius automatically:
%{type: :button, props: %{text: "Save", on_tap: {self(), :save}}, children: []}
## Style structs
A `%Mob.Style{}` value under the `:style` key is merged into the node's
own props before serialisation. Inline props override style values.
## Platform blocks
Props scoped to one platform are silently ignored on the other:
props: %{padding: 12, ios: %{padding: 20}}
# iOS sees padding: 20; Android sees padding: 12
## Injecting a mock NIF
Mob.Renderer.render(tree, :android, MockNIF)
"""
alias Mob.{Style, Theme}
@default_nif :mob_nif
# ── Base palette ──────────────────────────────────────────────────────────
# Raw named colors. Semantic tokens (:primary, :surface, etc.) resolve
# through the active Mob.Theme first, then fall through to this table.
@colors %{
# Semantic fallbacks (used when no theme is configured or as base values)
primary: 0xFF2196F3,
surface: 0xFFFFFFFF,
on_primary: 0xFFFFFFFF,
on_surface: 0xFF212121,
error: 0xFFF44336,
# Basic
white: 0xFFFFFFFF,
black: 0xFF000000,
transparent: 0x00000000,
# Grays
gray_50: 0xFFFAFAFA,
gray_100: 0xFFF5F5F5,
gray_200: 0xFFEEEEEE,
gray_300: 0xFFE0E0E0,
gray_400: 0xFFBDBDBD,
gray_500: 0xFF9E9E9E,
gray_600: 0xFF757575,
gray_700: 0xFF616161,
gray_800: 0xFF424242,
gray_900: 0xFF212121,
gray_950: 0xFF121212,
# Blues
blue_100: 0xFFBBDEFB,
blue_300: 0xFF64B5F6,
blue_500: 0xFF2196F3,
blue_700: 0xFF1976D2,
blue_900: 0xFF0D47A1,
# Greens
green_400: 0xFF66BB6A,
green_500: 0xFF4CAF50,
green_700: 0xFF388E3C,
emerald_400: 0xFF34D399,
emerald_500: 0xFF10B981,
emerald_700: 0xFF047857,
# Reds
red_400: 0xFFEF5350,
red_500: 0xFFF44336,
red_700: 0xFFD32F2F,
# Oranges / Amber
orange_400: 0xFFFFA726,
orange_500: 0xFFFF9800,
amber_400: 0xFFFBBF24,
amber_500: 0xFFF59E0B,
amber_700: 0xFFF57C00,
# Limes / Yellows
lime_300: 0xFFBEF264,
lime_400: 0xFFA3E635,
lime_500: 0xFF84CC16,
lime_600: 0xFF65A30D,
yellow_400: 0xFFFACC15,
yellow_500: 0xFFEAB308,
# Purples / Indigo / Violet
purple_500: 0xFF9C27B0,
purple_700: 0xFF7B1FA2,
indigo_500: 0xFF3F51B5,
deep_purple_700: 0xFF512DA8,
violet_400: 0xFFA78BFA,
violet_500: 0xFF8B5CF6,
violet_600: 0xFF7C3AED,
violet_700: 0xFF6D28D9,
# Teals / Cyans
teal_500: 0xFF009688,
cyan_500: 0xFF00BCD4,
# Stone / Warm neutrals
stone_100: 0xFFF5F5F4,
stone_200: 0xFFE7E5E4,
stone_400: 0xFFA8A29E,
stone_500: 0xFF78716C,
stone_600: 0xFF57534E,
stone_800: 0xFF292524,
# Warm browns
brown_400: 0xFFA0785A,
brown_600: 0xFF7C4A1E,
brown_800: 0xFF3E2010,
# Pinks / Roses
pink_500: 0xFFE91E63,
rose_500: 0xFFF43F5E,
}
@text_sizes %{
xs: 12.0,
sm: 14.0,
base: 16.0,
lg: 18.0,
xl: 20.0,
"2xl": 24.0,
"3xl": 30.0,
"4xl": 36.0,
"5xl": 48.0,
"6xl": 60.0,
}
# Props whose atom values are resolved as colors
@color_props ~w(background text_color border_color color placeholder_color)a
# Props whose atom values are resolved as spacing or radius tokens
@spacing_props ~w(padding padding_top padding_right padding_bottom padding_left gap)a
@radius_props ~w(corner_radius)a
# Props whose atom values are resolved as text sizes (scaled by type_scale)
@size_props ~w(text_size font_size)a
# ── Component defaults ────────────────────────────────────────────────────
# Injected for missing styling props. Use semantic tokens so they inherit
# the active theme automatically. Explicit props always win.
@component_defaults %{
button: %{
background: :primary,
text_color: :on_primary,
padding: :space_md,
corner_radius: :radius_md,
text_size: :base,
font_weight: "medium",
fill_width: true,
text_align: :center,
},
text_field: %{
background: :surface_raised,
text_color: :on_surface,
placeholder_color: :muted,
border_color: :border,
padding: :space_sm,
corner_radius: :radius_sm,
text_size: :base,
},
divider: %{
color: :border,
},
progress: %{
color: :primary,
},
}
# ── Public API ────────────────────────────────────────────────────────────
@doc """
Render a component tree for the given platform.
Loads the active `Mob.Theme`, clears the tap registry, serialises the tree
to JSON, and calls `set_root/1` on the NIF. Returns `{:ok, :json_tree}`.
`transition` is an atom (`:push`, `:pop`, `:reset`, `:none`) for the nav
animation. Defaults to `:none` (instant swap).
"""
@spec render(map(), atom(), module() | atom(), atom()) :: {:ok, :json_tree} | {:error, term()}
def render(tree, platform, nif \\ @default_nif, transition \\ :none) do
theme = Theme.current()
ctx = %{
colors: Theme.color_map(theme),
spacing: Theme.spacing_map(theme),
radii: Theme.radius_map(theme),
type_scale: theme.type_scale,
}
nif.clear_taps()
nif.set_transition(transition)
json =
tree
|> prepare(nif, platform, ctx)
|> :json.encode()
|> IO.iodata_to_binary()
nif.set_root(json)
{:ok, :json_tree}
end
@doc "Return the full color palette map (token → ARGB integer)."
def colors, do: @colors
@doc "Return the text-size scale map (token → float sp)."
def text_sizes, do: @text_sizes
# ── Tree preparation ──────────────────────────────────────────────────────
defp prepare(%{type: type, props: props, children: children}, nif, platform, ctx) do
defaults = Map.get(@component_defaults, type, %{})
with_defaults = Map.merge(defaults, props)
%{
"type" => Atom.to_string(type),
"props" => prepare_props(with_defaults, nif, platform, ctx),
"children" => Enum.map(children, &prepare(&1, nif, platform, ctx))
}
end
defp prepare_props(props, nif, platform, ctx) do
# 1. Merge any %Mob.Style{} under the :style key (inline props win)
{style, base} = Map.pop(props, :style)
merged =
case style do
%Style{props: sp} -> Map.merge(sp, base)
nil -> base
end
# 2. Resolve platform blocks (:ios / :android)
ios_extras = Map.get(merged, :ios, %{})
android_extras = Map.get(merged, :android, %{})
platform_extra = if platform == :ios, do: ios_extras, else: android_extras
final =
merged
|> Map.delete(:ios)
|> Map.delete(:android)
|> Map.merge(platform_extra)
# 3. Serialize: convert atom keys, register taps/changes, resolve tokens.
# on_tap with a tagged tuple also emits "accessibility_id" so test tooling
# can locate elements by tag name without relying on screen coordinates.
final
|> Enum.flat_map(fn
{:on_tap, pid} when is_pid(pid) ->
[{"on_tap", nif.register_tap(pid)}]
{:on_tap, {pid, tag}} when is_pid(pid) and is_atom(tag) ->
[{"on_tap", nif.register_tap({pid, tag})}, {"accessibility_id", Atom.to_string(tag)}]
{:on_tap, {pid, tag}} when is_pid(pid) ->
[{"on_tap", nif.register_tap({pid, tag})}]
{:on_change, {pid, tag}} when is_pid(pid) ->
[{"on_change", nif.register_tap({pid, tag})}]
{:on_focus, {pid, tag}} when is_pid(pid) ->
[{"on_focus", nif.register_tap({pid, tag})}]
{:on_blur, {pid, tag}} when is_pid(pid) ->
[{"on_blur", nif.register_tap({pid, tag})}]
{:on_submit, {pid, tag}} when is_pid(pid) ->
[{"on_submit", nif.register_tap({pid, tag})}]
{:on_end_reached, {pid, tag}} when is_pid(pid) ->
[{"on_end_reached", nif.register_tap({pid, tag})}]
{:on_tab_select, {pid, tag}} when is_pid(pid) ->
[{"on_tab_select", nif.register_tap({pid, tag})}]
{key, value} ->
[{Atom.to_string(key), resolve_token(key, value, ctx)}]
end)
|> Map.new()
end
# ── Token resolution ──────────────────────────────────────────────────────
# Color props — two-step: theme semantic map → base palette
defp resolve_token(key, value, ctx) when is_atom(value) and key in @color_props do
resolve_color(value, ctx.colors)
end
# Text size props — scale table value by type_scale
defp resolve_token(key, value, ctx) when is_atom(value) and key in @size_props do
case Map.get(@text_sizes, value) do
nil -> value
size -> size * ctx.type_scale
end
end
# Spacing props — spacing tokens first, then radius tokens, then pass through
defp resolve_token(key, value, ctx) when key in @spacing_props do
cond do
is_atom(value) ->
case Map.get(ctx.spacing, value) do
nil -> value
v -> v
end
true -> value
end
end
# Radius props — radius tokens from theme
defp resolve_token(key, value, ctx) when is_atom(value) and key in @radius_props do
Map.get(ctx.radii, value, value)
end
defp resolve_token(_key, value, _ctx), do: value
# Two-step color resolution:
# 1. Check theme semantic map (:primary → :blue_500)
# 2. Check base palette (:blue_500 → 0xFF2196F3)
# 3. Pass through (unknown atoms serialise as strings)
defp resolve_color(value, theme_colors) when is_atom(value) do
case Map.get(theme_colors, value) do
nil ->
Map.get(@colors, value, value)
palette_atom when is_atom(palette_atom) ->
Map.get(@colors, palette_atom, palette_atom)
raw_int when is_integer(raw_int) ->
raw_int
end
end
defp resolve_color(value, _theme_colors), do: value
end