Packages
mob
0.5.17
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/canvas.ex
defmodule Mob.Canvas do
@moduledoc """
Drawing-op constructors for `Mob.UI.canvas/1`.
Each function returns a plain map describing one draw operation. The
canvas widget takes a `:draw` list of these and renders them in order.
Color values can be theme tokens (e.g. `:primary`, `:on_surface`) or
raw strings ("#ff0000") — they are resolved by `Mob.Renderer` against
the active theme before serialisation to the native side.
All coordinates are canvas-local in points/dp, top-left origin
(matches SwiftUI `Canvas` and Jetpack Compose `Canvas` natively, no
translation cost).
## Op map equivalence
Helpers and raw maps produce identical output. These are the same:
Mob.Canvas.line(0, 0, 100, 100, color: :primary, width: 4)
%{op: :line, x1: 0, y1: 0, x2: 100, y2: 100, color: :primary, width: 4}
Use whichever you prefer; the renderer doesn't care.
## Available ops
* `line/5` — straight stroke between two points
* `circle/4` — circle (outline or filled)
* `ellipse/5` — ellipse with separate rx, ry
* `arc/6` — circular arc between two angles in degrees
* `rect/5` — rectangle (outline or filled, optional corner radius)
* `path/2` — sequence of points (open or closed; outline or filled)
* `text/4` — text at a point with anchor
* `image/5` — image from an asset name into a rect
## Common modifiers (accepted on every op where they make sense)
* `:opacity` — float 0.0–1.0
* `:width` — stroke width in points/dp (ignored on filled-only ops)
* `:dash` — list of [on, off] floats for dashed strokes, e.g. `[4, 4]`
* `:cap` — `:butt` | `:round` | `:square` (line/arc/path)
* `:join` — `:miter` | `:round` | `:bevel` (path/rect outline)
* `:fill` — boolean (circle/ellipse/rect/path); default false (stroke)
## Text-specific
* `:weight` — `:thin` | `:light` | `:regular` | `:medium` | `:semibold` | `:bold`
* `:family` — string font family name; platform default if omitted
* `:anchor` — `:start` | `:center` | `:end` (horizontal); default `:start`
"""
@line_opts [:width, :cap, :dash, :opacity]
@circle_opts [:width, :fill, :dash, :opacity]
@ellipse_opts [:width, :fill, :dash, :opacity]
@arc_opts [:width, :cap, :dash, :opacity]
@rect_opts [:width, :fill, :radius, :join, :dash, :opacity]
@path_opts [:width, :fill, :closed, :cap, :join, :dash, :opacity]
@text_opts [:weight, :family, :anchor, :opacity]
@image_opts [:opacity]
@doc """
Stroke a line from (x1, y1) to (x2, y2).
Mob.Canvas.line(0, 0, 100, 100, color: :primary, width: 4, cap: :round)
"""
@spec line(number(), number(), number(), number(), keyword() | map()) :: map()
def line(x1, y1, x2, y2, opts \\ []) do
base = %{op: :line, x1: x1, y1: y1, x2: x2, y2: y2, color: required(opts, :color, :line)}
Map.merge(base, take(opts, @line_opts))
end
@doc """
Draw a circle. Defaults to stroke; pass `fill: true` for a filled disc.
Mob.Canvas.circle(120, 120, 60, color: :primary)
Mob.Canvas.circle(120, 120, 60, color: :primary, fill: true)
"""
@spec circle(number(), number(), number(), keyword() | map()) :: map()
def circle(x, y, r, opts \\ []) do
base = %{op: :circle, x: x, y: y, r: r, color: required(opts, :color, :circle)}
Map.merge(base, take(opts, @circle_opts))
end
@doc """
Draw an ellipse with separate horizontal and vertical radii.
Mob.Canvas.ellipse(100, 80, 60, 30, color: :primary, fill: true)
"""
@spec ellipse(number(), number(), number(), number(), keyword() | map()) :: map()
def ellipse(x, y, rx, ry, opts \\ []) do
base = %{
op: :ellipse,
x: x,
y: y,
rx: rx,
ry: ry,
color: required(opts, :color, :ellipse)
}
Map.merge(base, take(opts, @ellipse_opts))
end
@doc """
Draw a circular arc centered at (x, y), radius r, from `start_deg`
sweeping clockwise to `end_deg`. 0° points to the right, 90° points
down (matching SwiftUI / Compose conventions).
Mob.Canvas.arc(100, 100, 50, 0, 90, color: :primary, width: 2)
"""
@spec arc(number(), number(), number(), number(), number(), keyword() | map()) :: map()
def arc(x, y, r, start_deg, end_deg, opts \\ []) do
base = %{
op: :arc,
x: x,
y: y,
r: r,
start_deg: start_deg,
end_deg: end_deg,
color: required(opts, :color, :arc)
}
Map.merge(base, take(opts, @arc_opts))
end
@doc """
Draw a rectangle. Defaults to stroke; pass `fill: true` for filled.
`radius:` rounds the corners (single value, all four corners).
Mob.Canvas.rect(10, 10, 100, 50, color: :primary, fill: true, radius: 8)
"""
@spec rect(number(), number(), number(), number(), keyword() | map()) :: map()
def rect(x, y, w, h, opts \\ []) do
base = %{op: :rect, x: x, y: y, w: w, h: h, color: required(opts, :color, :rect)}
Map.merge(base, take(opts, @rect_opts))
end
@doc """
Draw a path through a list of points. Points are 2-element lists or
2-tuples; tuples are normalised to lists for JSON serialisation.
Closed paths (`closed: true`) are wrapped back to the first point.
Filled paths (`fill: true`) are filled regardless of `:closed`.
Mob.Canvas.path([{0, 0}, {100, 0}, {50, 80}], color: :primary, closed: true)
"""
@spec path([{number(), number()} | [number()]], keyword() | map()) :: map()
def path(points, opts \\ []) when is_list(points) do
base = %{
op: :path,
points: Enum.map(points, &normalize_point/1),
color: required(opts, :color, :path)
}
Map.merge(base, take(opts, @path_opts))
end
@doc """
Draw text at (x, y). The anchor controls horizontal alignment of the
text relative to x; vertical baseline is ascender (text grows downward
from y, matching SwiftUI/Compose Canvas defaults).
Mob.Canvas.text(120, 50, "Hello", color: :on_surface, size: 18, anchor: :center)
"""
@spec text(number(), number(), String.t(), keyword() | map()) :: map()
def text(x, y, content, opts \\ []) when is_binary(content) do
base = %{
op: :text,
x: x,
y: y,
text: content,
color: required(opts, :color, :text),
size: required(opts, :size, :text)
}
Map.merge(base, take(opts, @text_opts))
end
@doc """
Draw an image into the rect at (x, y, w, h). `source` is an asset
name resolved by the platform (e.g. an iOS asset catalog name or
Android drawable name).
Mob.Canvas.image(0, 0, 100, 100, "logo")
"""
@spec image(number(), number(), number(), number(), String.t(), keyword() | map()) :: map()
def image(x, y, w, h, source, opts \\ []) when is_binary(source) do
base = %{op: :image, x: x, y: y, w: w, h: h, source: source}
Map.merge(base, take(opts, @image_opts))
end
# ── Internals ─────────────────────────────────────────────────────────
defp required(opts, key, op) do
case fetch(opts, key) do
{:ok, value} ->
value
:error ->
raise ArgumentError, "Mob.Canvas.#{op}/N missing required option `:#{key}`"
end
end
defp take(opts, keys) when is_list(opts) do
opts |> Map.new() |> Map.take(keys)
end
defp take(%{} = opts, keys), do: Map.take(opts, keys)
defp fetch(opts, key) when is_list(opts), do: Keyword.fetch(opts, key)
defp fetch(%{} = opts, key), do: Map.fetch(opts, key)
defp normalize_point({x, y}), do: [x, y]
defp normalize_point([x, y]), do: [x, y]
defp normalize_point(other) do
raise ArgumentError,
"Mob.Canvas.path expected a {x, y} tuple or [x, y] list, got: #{inspect(other)}"
end
end