README.md

# Easel

Easel allows you to interact and draw on a canvas. The API is a `snake_cased` version of the [CanvasRenderingContext2D](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D) with the addition of `set` and `call` if you need to set a property or call a function not yet supported.

## Example

Build a set of draw operations:

```elixir
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)
  |> Easel.render()
```

## Phoenix LiveView

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

### Template

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

### Drawing from a LiveView

```elixir
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:

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

Or clear independently:

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

### Initial ops

Pass ops directly to render on mount:

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

### Events

Enable mouse and keyboard events with boolean attributes:

```heex
<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>"`:

```elixir
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:

```heex
<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):

```heex
<: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 (position, rotation, color) is sent each frame — the
template ops are cached client-side.

```elixir
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:

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

Each instance map may contain:

| Key        | Description                          | Default |
|------------|--------------------------------------|---------|
| `:x`, `:y` | Translation                         | `0`     |
| `:rotate`  | Rotation in radians                  | `0`     |
| `:scale_x`, `:scale_y` | Scale factors              | `1`     |
| `:fill`    | Fill style override                  | —       |
| `:stroke`  | Stroke style override                | —       |
| `:alpha`   | Global 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):

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

**Payload comparison (100 boids):**

| Approach | Ops/frame | Bytes/frame |
|----------|-----------|-------------|
| Inline ops (no templates) | ~504 | ~19 KB |
| Templates + instances | 1 | ~7.8 KB |

### Animation

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

```elixir
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:

```heex
<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:

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

## 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.

```elixir
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:

```elixir
# 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](https://mise.jdx.dev)
(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):

   ```bash
   # 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`:

   ```toml
   [tools]
   erlang = "latest"
   elixir = "latest"

   [env]
   KERL_CONFIGURE_OPTIONS = "--with-wx"
   ```

3. Force rebuild Erlang (this takes a few minutes):

   ```bash
   mise install erlang@latest --force
   ```

4. Verify wx works:

   ```bash
   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.

## Installation

```elixir
def deps do
  [
    {:easel, "~> 0.1.0"},
    # optional, for LiveView support
    {:phoenix_live_view, "~> 1.0"}
  ]
end
```