Search documentation of Plushie

# Canvas

Copy Markdown View Source

Canvas is a different paradigm from the widget tree. Instead of composing layout containers and input widgets, you draw shapes on a 2D surface: rectangles, circles, lines, paths, and text. Shapes can be grouped into interactive elements with click handlers, hover effects, and accessibility annotations.

In this chapter we will learn canvas by building a custom save button -- a styled, interactive canvas widget that replaces the plain `button("save")` in the pad. Along the way we will cover shapes, interactive elements, style overrides, and how canvas composes with the rest of the widget tree.

## Shapes

All shapes are plain function calls that return typed structs. They live inside `layer` blocks within a canvas. Note that `text`, `image`, and `svg` automatically resolve to their canvas shape variants inside canvas blocks -- the compiler handles this, so you use the same names without qualification.

Variables assigned in one line of a layer or group block are visible in subsequent lines. Standard Elixir scoping rules apply:

    canvas "demo", width: 200, height: 100 do
      layer "bg" do
        bg_fill = "#f0f0f0"
        rect(0, 0, 200, 100, fill: bg_fill, radius: 8)
        circle(100, 50, 20, fill: "#3b82f6")
        line(10, 90, 190, 90, stroke: stroke("#ccc", 1))
        text(100, 50, "Hello", fill: "#333", size: 14)
      end
    end

`rect` draws a rectangle, `circle` a circle, `line` a line segment, and `text` renders text at a position. Each accepts optional `fill`, `stroke`, and `opacity`.

### Strokes

The `stroke/3` helper creates stroke descriptors:

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

Shape do-blocks support nested do-block types, so you can declare stroke options inline:

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

### Gradients

`linear_gradient/3` creates a gradient for use as a fill:

    rect(0, 0, 100, 36,
      fill: linear_gradient({0, 0}, {100, 0}, [
        {0.0, "#3b82f6"},
        {1.0, "#1d4ed8"}
      ]),
      radius: 6
    )

### Paths

For arbitrary shapes, use `path/2` with a list of commands:

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

See the Canvas reference for the full list of path commands (`bezier_to`, `quadratic_to`, `arc`, `arc_to`, `ellipse`, `rounded_rect`).

## SVG and images

For complex visuals, you can embed SVG content directly in a canvas layer. Design your icons, illustrations, or controls in a vector editor (Figma, Inkscape, Illustrator), export to SVG, and render them at any position and size:

    layer "icons" do
      svg(File.read!("priv/icons/save.svg"), 10, 8, 20, 20)
    end

The first argument is the SVG source string. Combined with `interactive`, this lets you build fully custom controls from externally designed assets:

    interactive "save", on_click: true, cursor: :pointer do
      svg(File.read!("priv/icons/save.svg"), 0, 0, 36, 36)
    end

`image/5` works the same way for raster images (PNG, JPEG). SVG is generally preferred for UI elements because it scales without pixelation.

## Interactive elements

The `interactive` macro creates canvas elements that respond to user input. It requires a string id as the first argument. You can make any collection of shapes clickable, hoverable, or draggable:

    interactive "my-btn", on_click: true, on_hover: true, cursor: :pointer do
      rect(0, 0, 100, 36, fill: "#3b82f6", radius: 6)
      text(50, 11, "Click me", fill: "#fff", size: 14)
    end

### Hover and press styles

`hover_style` and `pressed_style` change the visual appearance during interaction. No event handling needed; the renderer applies them automatically:

    interactive "my-btn",
      on_click: true,
      cursor: :pointer,
      hover_style: %{fill: "#2563eb"},
      pressed_style: %{fill: "#1d4ed8"} do
      rect(0, 0, 100, 36, fill: "#3b82f6", radius: 6)
      text(50, 11, "Save", fill: "#fff", size: 14)
    end

On hover, the rectangle fill shifts to a darker blue. On press, it gets darker still. The text stays white because the style override only affects the properties you specify.

### Accessibility

Built-in widgets like `button` and `text_input` have accessibility roles and labels built in (a button announces itself as a button automatically). Canvas is a raw drawing surface, so the renderer has no way to know that a collection of shapes is meant to be a "button." You tell it with `a11y` annotations:

    interactive "save-btn",
      on_click: true,
      cursor: :pointer,
      focusable: true,
      a11y: %{role: :button, label: "Save experiment"} do
      # shapes...
    end

`focusable: true` allows keyboard navigation (Tab to focus, Space/Enter to activate). The `a11y` map provides the role and label for screen readers. See the Accessibility reference for the full set of available annotations.

## Building the save button

Let us put it all together. Here is a custom save button with a gradient fill, rounded corners, hover/press feedback, and accessibility:

    defp save_button do
      canvas "save-canvas", width: 100, height: 36 do
        layer "button" do
          interactive "save",
            on_click: true,
            cursor: :pointer,
            focusable: true,
            a11y: %{role: :button, label: "Save experiment"},
            hover_style: %{fill: "#2563eb"},
            pressed_style: %{fill: "#1d4ed8"} do

            rect(0, 0, 100, 36,
              fill: linear_gradient({0, 0}, {100, 0}, [
                {0.0, "#3b82f6"},
                {1.0, "#2563eb"}
              ]),
              radius: 6
            )
            text(50, 11, "Save", fill: "#ffffff", size: 14)
          end
        end
      end
    end

### Applying it: replace the plain save button

In the pad's view, replace `button("save", "Save")` with the canvas version:

    row padding: 4, spacing: 8 do
      save_button()
      checkbox("auto-save", model.auto_save)
      text("auto-label", "Auto-save")
    end

The canvas button emits a regular `:click` event with the canvas ID in scope. Update the save handler to match:

    def update(model, %WidgetEvent{type: :click, id: "save", scope: ["save-canvas" | _]}) do
      case compile_preview(model.source) do
        {:ok, tree} ->
          if model.active_file, do: save_experiment(model.active_file, model.source)
          %{model | preview: tree, error: nil}
        {:error, msg} ->
          %{model | error: msg, preview: nil}
      end
    end

The `id` is `"save"` (the group ID), and it arrives with `scope: ["save-canvas", window_id]`. You can match on scope to disambiguate canvas element clicks from regular widget clicks with the same ID.

You now have a custom-drawn, gradient-filled, hover-responsive save button in your pad. This is the same technique you would use to build custom controls, charts, diagrams, or any visual element that goes beyond the built-in widgets.

## Layers

Layers control drawing order. Earlier layers are behind, later layers are on top:

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

      layer "foreground" do
        circle(100, 50, 30, fill: "#3b82f6")
      end
    end

## Transforms and clips

Transforms apply to groups, not individual shapes. Three transforms are available: `translate/2`, `rotate/1` (degrees by default), and `scale/1` or `scale/2`.

    group x: 100, y: 50 do
      rotate(45)
      rect(0, 0, 40, 40, fill: "#ef4444")
    end

`rotate/1` accepts degrees by default. For explicit units, use tuple form: `rotate({45, :deg})` or `rotate({0.785, :rad})`.

The `x:` and `y:` shorthand desugars to a leading `translate`.

`clip/4` restricts drawing to a rectangular region (one per group):

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

## Canvas events

Canvas events arrive as `Plushie.Event.WidgetEvent` structs:

Canvas-level (requires `on_press`/`on_release`/`on_move`/`on_scroll` on the canvas). These use the unified pointer event model. Mouse, touch, and pen input all produce the same event types. The `pointer` field identifies the device, and `finger` carries the touch finger ID:

    # Mouse click
    %WidgetEvent{type: :press, value: %{x: 150.0, y: 75.0, button: :left, pointer: :mouse, modifiers: %KeyModifiers{}}}

    # Touch press
    %WidgetEvent{type: :press, value: %{x: 150.0, y: 75.0, button: :left, pointer: :touch, finger: 0, modifiers: %KeyModifiers{}}}

Element-level (requires `on_click`/`on_hover`/`draggable` on the group):

    %WidgetEvent{type: :click, id: "save", scope: ["save-canvas", "main"], window_id: "main"}
    %WidgetEvent{type: :enter, id: "save", scope: ["save-canvas", "main"], window_id: "main"}
    %WidgetEvent{type: :exit, id: "save", scope: ["save-canvas", "main"], window_id: "main"}

See the Canvas reference for the full event list.

## Composing canvas with widgets

Canvas is just another widget in the tree. It composes with layout containers like anything else:

    column do
      row spacing: 8 do
        save_button()          # canvas widget
        button("clear", "Clear")  # regular widget
      end

      canvas "chart", width: :fill, height: 200 do
        # ...
      end
    end

Mix canvas and regular widgets freely. Use canvas when you need custom visuals; use widgets for standard controls.

## Verify it

Test the canvas save button, including its accessibility annotations:

    test "canvas save button works and has correct a11y" do
      click("#save-canvas/save")
      assert_text("#preview/greeting", "Hello, Plushie!")

      assert_role("#save-canvas/save", "button")
      assert_a11y("#save-canvas/save", %{label: "Save experiment"})
    end

This exercises the canvas click event, verifies compilation still works, and confirms the accessibility annotations are present on the canvas element, something that built-in widgets provide automatically but canvas elements need explicitly.

## Try it

Write canvas experiments in your pad:

- Draw a few shapes: rectangles with gradients, circles with strokes, dashed lines.
- Build a bar chart: for each data point, draw a `rect` with height proportional to the value. Add `tooltip:` for hover labels.
- Create a path: draw a triangle, a star, or a curved shape.
- Add an interactive element with `on_hover: true` and `hover_style`. Watch it highlight when you mouse over it.
- Try transforms: rotate a group, clip a shape to a smaller region.

The Canvas reference has the complete shape catalog, all path commands, and full interactive element props. For extracting reusable canvas visuals as typed, composable elements, see the Custom Canvas Elements reference.

In the next chapter, we will extract reusable components from the pad as custom widgets.

Next: Custom Widgets

← Previous Page Async and Effects
Next Page → Custom Widgets