Hex.pm Docs

Easel allows you to interact and draw on a canvas. The API is a snake_cased version of the CanvasRenderingContext2D with the addition of set and call if you need to set a property or call a function not yet supported.

The idea is you create a canvas, apply the draw operations to it then send it off to the Browser, Wx, or terminal backend to render. This allows us to use Elixir to draw basically anything, further it comes with the following features:

  • Optional Phoenix LiveView components and hooks are available.
    • With support for layers.
    • Animations and Event handling
    • And templating and instancing of drawing, so you don't have to send all the draw commands on every frame, just the values that have changed.
  • Optional Wx Rendering for local art and speed using the same Canvas API!.
  • Experimental terminal rendering (Easel.Terminal) using wx off-screen rasterization + ASCII conversion.

Example

Build a set of draw operations:

canvas =
  Easel.new(300, 300)
  |> Easel.set_fill_style("blue")
  |> Easel.fill_rect(0, 0, 100, 100)
  |> Easel.set_line_width(10)
  |> Easel.stroke_rect(100, 100, 100, 100)

And render it

Easel.render(canvas)

This reverses the ops list and marks it as rendered. To actually display it, send it to a backend such as LiveView, wx, or Easel.Terminal.

Phoenix LiveView

Easel includes an optional Phoenix LiveView component with a colocated runtime hook. No JavaScript build step is required.

Template

<Easel.LiveView.canvas id="my-canvas" width={300} height={300} />

Drawing from a LiveView

def handle_event("draw", _, socket) do
  canvas =
    Easel.new(300, 300)
    |> Easel.set_fill_style("blue")
    |> Easel.fill_rect(0, 0, 100, 100)
    |> Easel.render()

  {:noreply, Easel.LiveView.draw(socket, "my-canvas", canvas)}
end

You can clear before drawing:

Easel.LiveView.draw(socket, "my-canvas", canvas, clear: true)

Or clear independently:

Easel.LiveView.clear(socket, "my-canvas")

Initial ops

Pass ops directly to render on mount:

<Easel.LiveView.canvas id="my-canvas" width={300} height={300} ops={@canvas.ops} />

Events

Enable mouse and keyboard events with boolean attributes:

<Easel.LiveView.canvas
  id="my-canvas"
  width={300}
  height={300}
  on_click
  on_mouse_move
  on_key_down
/>

Events are pushed to your LiveView as "<id>:<event>":

def handle_event("my-canvas:click", %{"x" => x, "y" => y}, socket) do
  IO.puts("Clicked at #{x}, #{y}")
  {:noreply, socket}
end

def handle_event("my-canvas:keydown", %{"key" => key}, socket) do
  IO.puts("Key pressed: #{key}")
  {:noreply, socket}
end

Available: on_click, on_mouse_down, on_mouse_up, on_mouse_move, on_key_down.

Key events include key, code, ctrl, shift, alt, and meta fields.

Layers

Use canvas_stack/1 to layer multiple canvases. Each layer is an independent <canvas> element stacked via CSS. Only layers whose assigns change get re-patched by LiveView — static layers like backgrounds are sent once:

<Easel.LiveView.canvas_stack id="game" width={800} height={600}>
  <:layer id="background" ops={@background.ops} />
  <:layer id="sprites" ops={@sprites.ops} templates={@sprites.templates} />
  <:layer id="ui" ops={@ui.ops} />
</Easel.LiveView.canvas_stack>

Event flags go on the layer that should receive them (typically the topmost):

<:layer id="sprites" ops={@sprites.ops} on_click />

Templates and Instances

For scenes with many similar shapes (particles, sprites, entities), define a template once and stamp out instances with per-instance transforms. Only the instance data is sent each frame while template ops are cached client-side.

Easel.instances/4 supports float quantization to reduce websocket payloads, and you can set defaults once on Easel.template/4:

canvas =
  Easel.new(800, 600)
  |> Easel.template(:boid, fn c ->
    c
    |> Easel.begin_path()
    |> Easel.move_to(12, 0)
    |> Easel.line_to(-4, -5)
    |> Easel.line_to(-4, 5)
    |> Easel.close_path()
    |> Easel.fill()
  end, x: 1, y: 1, rotate: 3)

Per-call overrides still work:

Easel.instances(canvas, :boid, instances, rotate: 2)

Internally, instance rows are sent in a compact columnar format (rows + cols) so unused fields are omitted instead of sending repeated nulls.

canvas =
  Easel.new(800, 600)
  |> Easel.template(:boid, fn c ->
    c
    |> Easel.begin_path()
    |> Easel.move_to(12, 0)
    |> Easel.line_to(-4, -5)
    |> Easel.line_to(-4, 5)
    |> Easel.close_path()
    |> Easel.fill()
  end)
  |> Easel.instances(:boid, Enum.map(boids, fn b ->
    angle = :math.atan2(b.vy, b.vx)
    hue = round(angle / :math.pi() * 180 + 180)
    %{x: b.x, y: b.y, rotate: angle, fill: "hsl(#{hue}, 70%, 60%)"}
  end))
  |> Easel.render()

Pass templates to the canvas component alongside ops:

<Easel.LiveView.canvas
  id="sprites"
  width={800}
  height={600}
  ops={@canvas.ops}
  templates={@canvas.templates}
/>

If templates are cached in one canvas and instances are emitted from another, carry template instance defaults over with Easel.with_template_opts/2:

frame_canvas
|> Easel.with_template_opts(template_canvas.template_opts)
|> Easel.instances(:boid, instances)

Each instance map may contain:

KeyDescriptionDefault
:x, :yTranslation0
:rotateRotation in radians0
:scale_x, :scale_yScale factors1
:fillFill style override
:strokeStroke style override
:alphaGlobal alpha override

For non-JS backends (wx, custom renderers), call Easel.expand/1 to flatten instances into plain Canvas 2D ops (save/translate/rotate/fill/restore):

canvas |> Easel.expand()  # __instances → plain ops

Payload comparison (100 boids):

ApproachOps/frameBytes/frame
Inline ops (no templates)~504~19 KB
Templates + instances1~7.8 KB

Animation

Run a server-side animation loop. Use :canvas_assign so the template re-renders with new ops each frame:

def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:balls, initial_balls())
    |> assign(:canvas, Easel.new(600, 400) |> Easel.render())
    |> Easel.LiveView.animate("my-canvas", :balls, fn balls ->
      new_balls = tick(balls)
      canvas = render_balls(new_balls)
      {canvas, new_balls}
    end, interval: 16, canvas_assign: :canvas)

  {:ok, socket}
end

def handle_info({:easel_tick, id}, socket) do
  {:noreply, Easel.LiveView.tick(socket, id)}
end

The template binds ops to the canvas assign:

<Easel.LiveView.canvas id="my-canvas" width={600} height={400} ops={@canvas.ops} />

The hook uses requestAnimationFrame to sync drawing with the browser's refresh rate. If multiple server updates arrive between frames, only the latest is drawn — no wasted renders.

To stop the animation:

Easel.LiveView.stop_animation(socket, "my-canvas")

Examples

This repo has two main example styles:

  • Phoenix/LiveView examples in examples/phx_demo

    • Includes static and animated browser demos
    • Static examples: Smiley, Chart, Starfield, Spiral, Fractal Tree, Mondrian, Sierpinski, Mandelbrot
    • Animated examples: Clock, Boids, Matrix, Game of Life, Lissajous, Flow Field, Wave Grid, Pathfinding (BFS/DFS/A*/Greedy)
    • Demo app entry: examples/phx_demo/lib/phx_demo_web/live/demo_live.ex
    • Drawing logic modules: examples/phx_demo/lib/phx_demo/examples/*.ex
    • Static per-example pages: /examples/:id
  • wx/native examples using Easel.WX

    • Use the same Easel Canvas API, but render to a native wx window instead of the browser
    • Includes both static renders and animated examples
    • Standalone scripts live under examples/wx/*.exs (for example boids)
  • terminal examples using Easel.Terminal

    • Experimental terminal rendering and animation scripts
    • Standalone scripts live under examples/term/*.exs

Run the Phoenix demo locally:

cd examples/phx_demo
mix phx.server

wx Backend

Easel includes an optional native rendering backend using Erlang's :wx (wxWidgets). This opens a native desktop window and draws your canvas operations without a browser.

Easel.new(400, 300)
|> Easel.set_fill_style("blue")
|> Easel.fill_rect(50, 50, 100, 100)
|> Easel.set_stroke_style("red")
|> Easel.set_line_width(3)
|> Easel.stroke_rect(50, 50, 100, 100)
|> Easel.render()
|> Easel.WX.render(title: "My Drawing")

Canvases with templates/instances are automatically expanded via Easel.expand/1 before rendering in wx.

Event handling

Both render/2 and animate/5 accept optional event handler callbacks:

# Static render — handlers receive (x, y) or (key_event)
Easel.WX.render(canvas,
  on_click: fn x, y -> IO.puts("Clicked at #{x}, #{y}") end,
  on_mouse_move: fn x, y -> IO.puts("Mouse at #{x}, #{y}") end,
  on_key_down: fn %{key: key} -> IO.puts("Key: #{key}") end
)

# Animation — handlers receive args + state, return new state
Easel.WX.animate(600, 400, initial_state, tick_fn,
  on_click: fn x, y, state -> %{state | target: {x, y}} end,
  on_key_down: fn %{key: ?r}, state -> reset(state) end
)

Available events: :on_click, :on_mouse_down, :on_mouse_up, :on_mouse_move, :on_key_down

Not all Canvas 2D operations are supported in wx. Unsupported ops (shadows, filters, gradients, image data, etc.) will raise Easel.WX.UnsupportedOpError. See the Easel.WX module docs for the full list of supported operations.

wx Prerequisites

Erlang must be compiled with wxWidgets support. If you use mise (or asdf), you'll need to ensure wxWidgets is installed and Erlang is built against it.

  1. Install wxWidgets (with compat-3.0 support, required by Erlang's wx):

    # macOS — edit the formula to add --enable-compat30
    brew edit wxwidgets
    # Add "--enable-compat30" to the args list in the formula, then:
    brew reinstall wxwidgets --build-from-source
    
    # Ubuntu/Debian
    sudo apt install libwxgtk3.2-dev
    
  2. Configure mise to build Erlang with wx support. In your .mise.toml:

    [tools]
    erlang = "latest"
    elixir = "latest"
    
    [env]
    KERL_CONFIGURE_OPTIONS = "--with-wx"
  3. Force rebuild Erlang (this takes a few minutes):

    mise install erlang@latest --force
    
  4. Verify wx works:

    erl -noshell -eval 'wx:new(), io:format("wx works!~n"), halt().'
    

Note: If you update wxWidgets (e.g. via brew upgrade), you'll need to rebuild Erlang with mise install erlang --force so it links against the new version.

Terminal Backend (Experimental)

Easel.Terminal renders Easel canvases in a terminal by:

  1. Rasterizing off-screen with Easel.WX.rasterize/2
  2. Extracting color silhouettes and fitting printable ASCII glyph masks per cell
  3. Writing frames through termite

Because it currently relies on wx for rasterization, it has the same wx runtime requirement as Easel.WX.

It also requires an interactive TTY (run from a real terminal session).

canvas =
  Easel.new(120, 80)
  |> Easel.set_fill_style("black")
  |> Easel.fill_rect(0, 0, 120, 80)
  |> Easel.set_fill_style("white")
  |> Easel.set_font("bold 24px monospace")
  |> Easel.fill_text("Easel", 20, 45)

Easel.Terminal.render(canvas, color: :ansi256, dpr: 2.0, samples: 3)
# press q to quit

Animation API mirrors Easel.WX.animate/5:

Easel.Terminal.animate(120, 80, state, fn state ->
  {canvas, next_state} = tick(state)
  {canvas, next_state}
end, fps: 30, color: :ansi256)

Press q (or Ctrl+C) to stop.

Tip: dpr and samples help a lot with text/edge quality.

By default, Easel.Terminal auto-selects characters using silhouette fitting, caches mask→glyph matches during a render/animation run (char_cache: true), and can adapt color luma to terminal theme (theme: :auto, auto_contrast: true) when needed. You can tune cache size with char_cache_size:.

For faster animation, reduce silhouette cell resolution with glyph_width: / glyph_height:.

If you want the old density-ramp mode, pass a manual charset: string.

Installation

Add easel to your list of dependencies in mix.exs:

def deps do
  [
    {:easel, "~> 0.3.3"},
    # optional, for LiveView support
    {:phoenix_live_view, "~> 1.0"},
    # optional, for Easel.Terminal
    {:termite, "~> 0.4.0"}
  ]
end

Then fetch your dependencies:

mix deps.get

Documentation is available on HexDocs.

Regenerating Canvas API wrappers (for contributors)

Easel ships with generated Canvas2D wrapper functions directly in lib/easel.ex.

If priv/easel.webidl or priv/compat.json changes, regenerate wrappers with:

mix easel.regen_canvas_api

This uses a script-local NimbleParsec parser template (lib/web_idl.ex.exs) and does not require NimbleParsec as a runtime dependency of the library.