Search documentation of Plushie

# Canvas

Copy Markdown View Source

The canvas system provides 2D drawing with typed shape structs, transforms, interactive elements, and accessibility support. Unlike layout widgets that compose children, canvas draws shapes on a surface: rectangles, circles, lines, paths, text, images, and SVG.

For a narrative introduction, see the Canvas guide.

## Canvas widget

`Plushie.Widget.Canvas`

The canvas is a widget that contains named layers of shapes. It participates in the layout system like any other widget (has width, height, background) but its content is drawn shapes, not child widgets.

### Props

Prop | Type | Default | Purpose
--- | --- | --- | ---
`width` | Length | `:fill` | Canvas width
`height` | Length | `200` | Canvas height (pixels)
`background` | Color | n/a | Canvas background colour
`on_press` | boolean | `false` | Emit `:press` events
`on_release` | boolean | `false` | Emit `:release` events
`on_move` | boolean | `false` | Emit `:move` events
`on_scroll` | boolean | `false` | Emit `:scroll` events
`event_rate` | integer | n/a | Max events/sec for canvas-level events
`a11y` | map | n/a | Accessibility overrides. See Accessibility.

## Layers

Canvas content is organised into named layers. Each layer is drawn independently:

    canvas "chart", width: 400, height: 200 do
      layer "background" do
        rect(0, 0, 400, 200, fill: "#f5f5f5")
      end

      layer "data" do
        rect(10, 50, 80, 150, fill: "#3b82f6")
        rect(110, 100, 80, 100, fill: "#22c55e")
      end

      layer "labels" do
        text(50, 190, "A", fill: "#333", size: 12)
        text(150, 190, "B", fill: "#333", size: 12)
      end
    end

Drawing order: layers are drawn in alphabetical order by name. `"background"` draws before `"data"` which draws before `"labels"`. This is the z-ordering mechanism. Name your layers to control which appears on top.

Independent caching: each layer maps to a separate cache on the renderer side. When a layer's shapes change, only that layer is re-tessellated. Unchanged layers are drawn from cache. For canvases with many shapes, splitting into layers (static background, dynamic data, interactive controls) significantly reduces rendering work.

## Shape catalog

All shapes are builder functions that return typed structs (e.g. `Plushie.Canvas.Rect`, `Plushie.Canvas.Circle`). Use them inside `layer` or `group` blocks. The `Plushie.Canvas.Shape` module re-exports all builder functions for programmatic use outside canvas blocks.

Function | Struct | Required args | Key options
--- | --- | --- | ---
`rect/4` | `Rect` | x, y, w, h | `fill`, `stroke`, `opacity`, `radius` (uniform number or per-corner map)
`circle/3` | `Circle` | x, y, r | `fill`, `stroke`, `opacity`
`line/4` | `Line` | x1, y1, x2, y2 | `stroke`, `opacity`
`text/3` | `CanvasText` | x, y, content | `fill`, `size`, `font`, `align_x`, `align_y`, `opacity`
`path/1` | `Path` | commands | `fill`, `stroke`, `opacity`, `fill_rule`
`image/5` | `CanvasImage` | source, x, y, w, h | `rotation`, `opacity`
`svg/5` | `CanvasSvg` | source, x, y, w, h | n/a

Common shape options:

- `fill`: hex colour string, named atom, or gradient (`linear_gradient`). Fills the shape interior.
- `stroke`: a stroke descriptor (see Strokes). Draws the shape outline.
- `opacity`: 0.0 (transparent) to 1.0 (opaque).
- `radius` (rect only): corner radius for rounded rectangles. Accepts a single number for uniform corners, or a map with `:top_left`, `:top_right`, `:bottom_right`, `:bottom_left` keys for per-corner control.

## Path commands

Functions that return path command data for use with `path/1`:

Function | Arguments | Description
--- | --- | ---
`move_to/2` | x, y | Move pen without drawing
`line_to/2` | x, y | Straight line to point
`bezier_to/6` | cp1x, cp1y, cp2x, cp2y, x, y | Cubic bezier curve
`quadratic_to/4` | cpx, cpy, x, y | Quadratic bezier curve
`arc/5` | cx, cy, r, start_angle, end_angle | Arc by centre (angles in degrees)
`arc_to/5` | x1, y1, x2, y2, radius | Tangent arc
`ellipse/7` | cx, cy, rx, ry, rotation, start, end | Ellipse arc (angles in degrees)
`rounded_rect/5` | x, y, w, h, radius | Rounded rectangle path
`close/0` | n/a | Close the current subpath

Angle parameters accept bare numbers (degrees), `{value, :deg}`, or `{value, :rad}` tuples. Degrees are converted to radians for the wire.

    path([
      move_to(10, 0),
      line_to(20, 20),
      line_to(0, 20),
      close()
    ], fill: "#22c55e")

## Strokes

`stroke/3` creates a stroke descriptor:

    stroke("#333", 2)                        # colour and width
    stroke("#333", 2, cap: :round)           # with line cap
    stroke("#333", 2, dash: {[5, 3], 0})     # dashed line

Option | Values | Default | Purpose
--- | --- | --- | ---
`cap:` | `:butt`, `:round`, `:square` | `:butt` | Line end style
`join:` | `:miter`, `:round`, `:bevel` | `:miter` | Corner join style
`dash:` | `{segments, offset}` | n/a | Dash pattern (segment lengths + initial offset)

Strokes support the do-block syntax:

    rect(0, 0, 100, 50) do
      fill "#3b82f6"
      stroke do
        color "#333"
        width 2
        cap :round
      end
    end

See `Plushie.Canvas.Stroke` and `Plushie.Canvas.Dash`.

## Gradients (canvas)

`linear_gradient/3` creates a gradient for use as a fill in canvas shapes. This is different from `Plushie.Type.Gradient.linear/3` (which uses an angle for widget backgrounds). Canvas gradients use coordinate pairs:

    rect(0, 0, 200, 50,
      fill: linear_gradient({0, 0}, {200, 0}, [
        {0.0, "#3b82f6"},
        {1.0, "#1d4ed8"}
      ])
    )

The first argument is the start point `{x, y}`, the second is the end point `{x, y}`. Stops are `{offset, colour}` tuples where offset is 0.0-1.0.

See `Plushie.Canvas.Gradient`.

## SVG and images

Embed external visuals in canvas layers:

    layer "icons" do
      svg(File.read!("priv/icons/save.svg"), 10, 8, 20, 20)
      image("priv/images/logo.png", 50, 8, 32, 32, opacity: 0.8)
    end

`svg/5` takes an SVG source string (not a file path; read the file first). `image/5` takes a file path string.

Combined with `interactive`, SVG content can be made clickable, hoverable, and keyboard-accessible:

    interactive "save", on_click: true, cursor: :pointer,
      focusable: true, a11y: %{role: :button, label: "Save"} do
      svg(File.read!("priv/icons/save.svg"), 0, 0, 36, 36)
    end

## Transforms

Transforms apply to groups only, not individual shapes. They are applied in declaration order.

Function | Arguments | Description
--- | --- | ---
`translate/2` | x, y | Move the group
`rotate/1` | angle | Rotate around origin (degrees by default)
`rotate/1` | `{n, :deg}` or `{n, :rad}` | Explicit unit
`scale/1` | factor | Uniform scale
`scale/2` | x, y | Non-uniform scale

    group x: 100, y: 50 do
      rotate(45)                    # 45 degrees
      rotate({0.785, :rad})          # explicit radians
      rect(0, 0, 40, 40, fill: "#ef4444")
    end

The `x:` and `y:` keyword options on groups desugar to a leading `translate`.

## Clips

`clip/4` restricts drawing to a rectangular region. One clip per group.

    group do
      clip(0, 0, 80, 80)
      circle(40, 40, 60, fill: "#3b82f6")  # clipped to 80x80 square
    end

See `Plushie.Canvas.Clip`.

## Interactive elements

`Plushie.Canvas.Interactive` makes any collection of shapes clickable, hoverable, draggable, or keyboard-focusable. It requires an explicit string id as the first argument. On the wire it encodes as a `"group"` (the split is SDK-side only).

### Interaction props

Prop | Type | Default | Purpose
--- | --- | --- | ---
`on_click` | boolean | `false` | Enable `:click` events (scoped under canvas ID)
`on_hover` | boolean | `false` | Enable `:enter`/`:exit` events
`draggable` | boolean | `false` | Enable `:drag`/`:drag_end` events
`drag_axis` | `:x` / `:y` / `:both` | n/a | Constrain drag direction (unconstrained when not set)
`drag_bounds` | DragBounds | n/a | Limit drag region (`%{min_x, max_x, min_y, max_y}`)
`focusable` | boolean | `false` | Add to Tab order for keyboard navigation
`cursor` | atom/string | n/a | Cursor style on hover (`:pointer`, `:grab`, etc.)
`tooltip` | string | n/a | Tooltip text on hover
`hit_rect` | HitRect | n/a | Custom hit testing region (`%{x, y, w, h}`)

### Visual feedback props

Prop | Type | Purpose
--- | --- | ---
`hover_style` | ShapeStyle | Override `fill`, `stroke`, `opacity` on hover
`pressed_style` | ShapeStyle | Override while pressed
`focus_style` | ShapeStyle | Override when keyboard-focused
`show_focus_ring` | boolean | Show/hide the default focus indicator
`focus_ring_radius` | number | Corner radius for the focus ring

ShapeStyle accepts: `fill` (colour or gradient), `stroke` (colour or stroke descriptor), `opacity` (0.0-1.0). Only specified fields are overridden; others inherit from the shape's base values.

### Accessibility

Canvas is a raw drawing surface with no inherent semantic knowledge. Interactive elements need explicit `a11y` annotations for screen reader and keyboard support:

    interactive "hue-ring",
      on_click: true,
      focusable: true,
      a11y: %{role: :slider, label: "Hue", value: "#{round(hue)} degrees"} do
      # ... shapes
    end

Without `a11y` annotations, interactive elements are invisible to assistive technology. See the Accessibility reference for the full set of fields and roles.

## Canvas events

All canvas events arrive as `Plushie.Event.WidgetEvent` structs.

### Canvas-level events

Require `on_press`/`on_release`/`on_move`/`on_scroll` props on the canvas widget. These use the unified pointer event model with device type and modifier information. Mouse, touch, and pen input all produce the same event types with full hit testing, drag, and click support. The `pointer` field identifies the device, `finger` carries the touch finger ID (nil for mouse), and `modifiers` carries the current modifier key state:

Event type | Data fields
--- | ---
`:press` | `x`, `y`, `button`, `pointer`, `finger`, `modifiers`
`:release` | `x`, `y`, `button`, `pointer`, `finger`, `modifiers`
`:move` | `x`, `y`, `pointer`, `finger`, `modifiers`
`:scroll` | `x`, `y`, `delta_x`, `delta_y`, `pointer`, `modifiers`

Touch events use `pointer: :touch` with `button: :left` and include a `finger` integer identifying the touch point:

    # Handle touch press on canvas
    def update(model, %WidgetEvent{type: :press, id: "drawing",
        value: %{x: x, y: y, pointer: :touch, finger: 0}}) do
      start_stroke(model, x, y)
    end

    # Handle touch drag
    def update(model, %WidgetEvent{type: :move, id: "drawing",
        value: %{x: x, y: y, pointer: :touch, finger: 0}}) do
      continue_stroke(model, x, y)
    end

### Element-level events

Require interaction props on interactive elements:

Event type | Trigger | Data fields
--- | --- | ---
`:click` | `on_click: true` | Scoped under canvas ID (see below)
`:enter` | `on_hover: true` | n/a
`:exit` | `on_hover: true` | n/a
`:drag` | `draggable: true` | `x`, `y`, `delta_x`, `delta_y`
`:drag_end` | `draggable: true` | `x`, `y`
`:key_press` | `focusable: true` | `key`, `modifiers`, `text`
`:key_release` | `focusable: true` | `key`, `modifiers`
`:focused` | `focusable: true` | n/a
`:blurred` | `focusable: true` | n/a

Canvas element clicks are regular `:click` events. The renderer emits them with the element's scoped ID (e.g., `"my-canvas/handle"`), and the SDK's scoped ID system splits this into `id: "handle"` with `scope: ["my-canvas", window_id]`. Match them like any scoped click:

    def update(model, %WidgetEvent{type: :click, id: "handle", scope: ["my-canvas" | _]}) do
      # handle canvas element click
    end

Other element events (enter, leave, drag, key, focus) use standard generic event families shared across all widget types.

### Pointer events in custom widgets

Canvas-level pointer events (`:press`, `:release`, `:move`, `:scroll`) are delivered through the widget handler pipeline like any other event. If a custom widget's `handle_event/2` does not intercept them, they reach the parent app's `update/2`.

To transform a pointer event before it reaches `update/2`, handle it in `handle_event/2` and emit a new event via `{:emit, family, data}`.

## Element scoping

Canvas element IDs participate in the standard scoped ID system. The canvas widget's ID creates a scope, and interactive element IDs within it are scoped under it:

    canvas "drawing"                      ->  "drawing"
      interactive "handle", ... do ... end  ->  "drawing/handle"

Events arrive with the element's local ID, the canvas in the scope, and the window ID at the end:

    %WidgetEvent{type: :click, id: "handle", scope: ["drawing", "main"], window_id: "main"}

## Examples

### Toggle switch

An interactive element with state-driven thumb position and accessibility annotations:

    canvas "switch", width: 64, height: 32 do
      layer "track" do
        interactive "toggle", on_click: true, cursor: :pointer,
          a11y: %{role: :switch, label: "Dark mode", toggled: model.dark} do
          rect(0, 0, 64, 32, fill: if(model.dark, do: "#3b82f6", else: "#ddd"), radius: 16)
          circle(if(model.dark, do: 44, else: 20), 16, 12, fill: "#fff")
        end
      end
    end

### Bar chart

Focusable bars with accessibility annotations for screen reader navigation. Each bar reports its position in the data set:

    canvas "chart", width: 300, height: 200 do
      layer "bars" do
        for {value, i} <- Enum.with_index(model.data) do
          x = i * 40 + 10
          h = value * 2

          interactive "bar-#{i}", x: x, y: 200 - h, focusable: true,
            tooltip: "#{value}",
            a11y: %{role: :image, label: "Value: #{value}",
                   position_in_set: i + 1, size_of_set: length(model.data)} do
            rect(0, 0, 30, h, fill: "#3b82f6")
          end
        end
      end
    end

### Canvas with widget overlay

Layer a canvas (custom visuals) under a text_input (standard widget) using `stack`. The transparent background on the input lets the canvas decoration show through:

    stack width: 200, height: 40 do
      canvas "bg", width: 200, height: 40 do
        layer "decor" do
          rect(0, 0, 200, 40, fill: "#f5f5f5", radius: 8)
        end
      end

      text_input("input", model.value,
        placeholder: "Search...",
        style: StyleMap.new() |> StyleMap.background(:transparent)
      )
    end

## Custom canvas elements

For reusable, typed canvas components, see the Custom Canvas Elements reference. Canvas elements use `use Plushie.Canvas.Element` with typed field declarations, and can decompose into built-in shapes via a `view/2` callback.

## See also

- `Plushie.Canvas.Shape` - builder functions for programmatic use
- `Plushie.Canvas.Rect`, `Plushie.Canvas.Circle`, etc. - typed shape structs
- `Plushie.Widget.Canvas` - canvas widget props
- Canvas guide - building a canvas button for the pad
- Custom Canvas Elements reference - reusable typed canvas components
- Custom Widgets guide - canvas-based custom widgets
- Accessibility reference - canvas accessibility annotations
- Styling reference - `Plushie.Type.Gradient.linear/3` for widget background gradients (different from canvas `linear_gradient/3`)

← Previous Page Built-in Widgets
Next Page → Commands and Effects