Packages
mob
0.7.9
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.
**Border** (currently honored on `:box` only): set both `:border_color`
(a color token like `:primary` or `:border`) and `:border_width` (an
integer pt/dp value, e.g. `1`). When width is 0 or color is unset, no
border draws.
**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,
flags: Theme.flags_map(theme),
platform: platform
}
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)."
@spec colors() :: %{atom() => non_neg_integer()}
def colors, do: @colors
@doc "Return the text-size scale map (token → float sp)."
@spec text_sizes() :: %{atom() => float()}
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)
with_theme_flags = inject_theme_flags(type, with_defaults, ctx)
%{
"type" => Atom.to_string(type),
"props" => prepare_props(with_theme_flags, nif, platform, ctx),
"children" => Enum.map(children, &prepare(&1, nif, platform, ctx))
}
end
# When the active theme has `glass: true`, mark surface-style nodes so the
# native side can swap solid fills for a translucent material (Liquid Glass
# on iOS 26+; ultraThinMaterial on iOS 17–25). Only iOS reads the flag
# today; Android receives it but ignores it (Material 3 doesn't have a
# first-class glassy surface yet).
#
# A node is "surface-style" if it has a `background:` set — that's what
# the user perceives as a card / sheet. Other nodes (text, scroll, etc.)
# pass through untouched.
defp inject_theme_flags(:box, props, %{flags: %{glass: true}}) do
if Map.has_key?(props, :background), do: Map.put(props, :glass, true), else: props
end
defp inject_theme_flags(_type, props, _ctx), do: props
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})}]
# IME composition — fires for languages with multi-stage input (CJK,
# Korean, Vietnamese, accent input). Phase atom is :began | :updating
# | :committed | :cancelled. Apps that need commit-only behaviour
# combine on_change + on_compose: ignore on_change while a composition
# is active, replace text on :committed.
{:on_compose, {pid, tag}} when is_pid(pid) ->
[{"on_compose", 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})}]
# Generic selection event — used by pickers, menus, segmented controls.
# Lists use a structured tag (see Mob.List) and emit on_tap; this is for
# widgets where "selection" is the only meaningful interaction.
{:on_select, {pid, tag}} when is_pid(pid) ->
[{"on_select", nif.register_tap({pid, tag})}]
# ── Gestures (Batch 4) ────────────────────────────────────────────────
# Each maps to a UIGestureRecognizer (iOS) / GestureDetector (Android).
# The native side fires the registered handle when the gesture ends in
# the recognized state. Per-widget opt-in — most widgets don't carry
# gesture overhead by default.
{:on_long_press, {pid, tag}} when is_pid(pid) ->
[{"on_long_press", nif.register_tap({pid, tag})}]
{:on_double_tap, {pid, tag}} when is_pid(pid) ->
[{"on_double_tap", nif.register_tap({pid, tag})}]
{:on_swipe, {pid, tag}} when is_pid(pid) ->
[{"on_swipe", nif.register_tap({pid, tag})}]
{:on_swipe_left, {pid, tag}} when is_pid(pid) ->
[{"on_swipe_left", nif.register_tap({pid, tag})}]
{:on_swipe_right, {pid, tag}} when is_pid(pid) ->
[{"on_swipe_right", nif.register_tap({pid, tag})}]
{:on_swipe_up, {pid, tag}} when is_pid(pid) ->
[{"on_swipe_up", nif.register_tap({pid, tag})}]
{:on_swipe_down, {pid, tag}} when is_pid(pid) ->
[{"on_swipe_down", nif.register_tap({pid, tag})}]
# ── Batch 5: high-frequency events ────────────────────────────────────
# `on_scroll` is the prototype: native side throttles + delta-thresholds
# before any enif_send. Default is 30 Hz / 1 px (see Mob.Event.Throttle).
# Forms accepted:
# on_scroll: {pid, tag} # default throttle
# on_scroll: {pid, tag, throttle: 100} # 10 Hz
# on_scroll: {pid, tag, debounce: 200} # only after stillness
# on_scroll: {pid, tag, throttle: 0} # raw (escape hatch)
# The throttle config is serialised as a sibling prop "scroll_config"
# which the native side reads alongside the registered handle.
{:on_scroll, {pid, tag}} when is_pid(pid) ->
[{"on_scroll", nif.register_tap({pid, tag})}]
{:on_scroll, {pid, tag, opts}} when is_pid(pid) and is_list(opts) ->
cfg = Mob.Event.Throttle.parse(:scroll, opts)
[{"on_scroll", nif.register_tap({pid, tag})}, {"scroll_config", encode_throttle(cfg)}]
{:on_drag, {pid, tag}} when is_pid(pid) ->
[{"on_drag", nif.register_tap({pid, tag})}]
{:on_drag, {pid, tag, opts}} when is_pid(pid) and is_list(opts) ->
cfg = Mob.Event.Throttle.parse(:drag, opts)
[{"on_drag", nif.register_tap({pid, tag})}, {"drag_config", encode_throttle(cfg)}]
{:on_pinch, {pid, tag}} when is_pid(pid) ->
[{"on_pinch", nif.register_tap({pid, tag})}]
{:on_pinch, {pid, tag, opts}} when is_pid(pid) and is_list(opts) ->
cfg = Mob.Event.Throttle.parse(:pinch, opts)
[{"on_pinch", nif.register_tap({pid, tag})}, {"pinch_config", encode_throttle(cfg)}]
{:on_rotate, {pid, tag}} when is_pid(pid) ->
[{"on_rotate", nif.register_tap({pid, tag})}]
{:on_rotate, {pid, tag, opts}} when is_pid(pid) and is_list(opts) ->
cfg = Mob.Event.Throttle.parse(:rotate, opts)
[{"on_rotate", nif.register_tap({pid, tag})}, {"rotate_config", encode_throttle(cfg)}]
{:on_pointer_move, {pid, tag}} when is_pid(pid) ->
[{"on_pointer_move", nif.register_tap({pid, tag})}]
{:on_pointer_move, {pid, tag, opts}} when is_pid(pid) and is_list(opts) ->
cfg = Mob.Event.Throttle.parse(:pointer_move, opts)
[
{"on_pointer_move", nif.register_tap({pid, tag})},
{"pointer_config", encode_throttle(cfg)}
]
# ── Batch 5 Tier 2: semantic scroll events (single-fire, no payload) ──
{:on_scroll_began, {pid, tag}} when is_pid(pid) ->
[{"on_scroll_began", nif.register_tap({pid, tag})}]
{:on_scroll_ended, {pid, tag}} when is_pid(pid) ->
[{"on_scroll_ended", nif.register_tap({pid, tag})}]
{:on_scroll_settled, {pid, tag}} when is_pid(pid) ->
[{"on_scroll_settled", nif.register_tap({pid, tag})}]
{:on_top_reached, {pid, tag}} when is_pid(pid) ->
[{"on_top_reached", nif.register_tap({pid, tag})}]
# `on_scrolled_past` requires a threshold; native side fires once when
# scroll y crosses the boundary (latched: re-emits only after going back
# below and past again).
{:on_scrolled_past, {pid, tag, threshold}} when is_pid(pid) and is_number(threshold) ->
[
{"on_scrolled_past", nif.register_tap({pid, tag})},
{"scrolled_past_threshold", threshold}
]
# ── Batch 5 Tier 3: native-side scroll-driven UI primitives ───────────
# These never round-trip to BEAM during scroll. Native side wires them
# directly. Pass-through for the renderer; consumed by the platform
# layer (SwiftUI .scrollPosition observer / Compose snapshotFlow).
{:parallax, %{} = config} ->
[{"parallax", encode_native_config(config)}]
{:fade_on_scroll, %{} = config} ->
[{"fade_on_scroll", encode_native_config(config)}]
{:sticky_when_scrolled_past, %{} = config} ->
[{"sticky_when_scrolled_past", encode_native_config(config)}]
# Canvas draw ops — list of op maps. Each op may carry a `:color` field
# holding a theme token (`:primary`, `:on_surface`, …) that needs the
# same two-step resolution as top-level color props. Walk the list,
# resolve color per op, leave everything else untouched.
{:draw, ops} when is_list(ops) ->
[{"draw", Enum.map(ops, &encode_canvas_op(&1, ctx))}]
{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
# A `src` of the form `plugin://<plugin>/<file>` (e.g. an Image shipped by a
# tier-3 plugin) resolves to the absolute on-device bundle path the native
# Image loader reads. A non-plugin src (URL, file path, theme atom) is left
# untouched.
defp resolve_token(:src, "plugin://" <> _ = value, _ctx) do
case Mob.Plugins.resolve_image(value) do
{:ok, path} -> path
_ -> value
end
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
# ── Batch 5 helpers ─────────────────────────────────────────────────────
# Encode a Mob.Event.Throttle config for the native side. We use string
# keys + plain numeric/boolean values so the JSON serialiser sends a flat
# map across the NIF boundary. Native side reads these and stores per-handle.
defp encode_throttle(%{} = cfg) do
%{
"throttle_ms" => cfg.throttle_ms,
"debounce_ms" => cfg.debounce_ms,
"delta_threshold" => cfg.delta_threshold,
"leading" => cfg.leading,
"trailing" => cfg.trailing
}
end
# Encode a Tier-3 native-side scroll-driven config (parallax, fade_on_scroll,
# sticky_when_scrolled_past). Atoms are stringified; nested structures
# passed through as-is for native consumption.
defp encode_native_config(%{} = config) do
Map.new(config, fn
{k, v} when is_atom(k) -> {Atom.to_string(k), encode_native_value(v)}
{k, v} -> {k, encode_native_value(v)}
end)
end
defp encode_native_value(v) when is_atom(v) and not is_boolean(v) and not is_nil(v),
do: Atom.to_string(v)
defp encode_native_value(v), do: v
# Encode one Mob.Canvas op map for the wire. The `:op` atom becomes a
# string ("line"/"circle"/...) so the native dispatch is a string switch.
# `:color` is resolved through the same two-step theme path as top-level
# color props. Other atom-valued enums (cap, join, anchor, weight) are
# stringified for native consumption. Numeric/boolean/string values pass
# through. Nested points lists in `:path` ops are already plain [x, y]
# arrays after Mob.Canvas.path/2.
defp encode_canvas_op(%{op: op_name} = op, ctx) do
op
|> Map.delete(:op)
|> Enum.map(fn
{:color, value} -> {"color", resolve_color(value, ctx.colors)}
{key, value} when is_atom(key) -> {Atom.to_string(key), encode_canvas_value(value)}
{key, value} -> {key, encode_canvas_value(value)}
end)
|> Map.new()
|> Map.put("op", Atom.to_string(op_name))
end
defp encode_canvas_value(v) when is_atom(v) and not is_boolean(v) and not is_nil(v),
do: Atom.to_string(v)
defp encode_canvas_value(v), do: v
end