Every interaction in a Plushie app produces an event. A button click, a keystroke in a text input, a checkbox toggle. Each one arrives in your update/2 as a typed struct. Understanding what events look like and how to match on them is essential for building anything beyond a static layout.

In this chapter we take a closer look at events and add an event log to the pad that shows every event as it happens. This turns the pad into a teaching tool: interact with a widget, see the event it produces.

WidgetEvent

Most events from user interaction arrive as Plushie.Event.WidgetEvent structs. Here are the key fields:

FieldTypeDescription
typeatomThe kind of interaction: :click, :input, :toggle, :submit, :select, :slide, etc.
idstringThe widget's local ID
scopelist of stringsAncestor container IDs (nearest parent first, window ID last)
valueterm or nilScalar payload: text content for :input, boolean for :toggle, number for :slide
datamap or nilStructured payload for events with multiple fields
window_idstringWhich window the event came from

You pattern match on these fields in update/2:

# Match by widget ID
def update(model, %WidgetEvent{type: :click, id: "save"}) do
  save(model)
end

# Match with a value
def update(model, %WidgetEvent{type: :input, id: "search", value: text}) do
  %{model | query: text}
end

# Match a boolean toggle
def update(model, %WidgetEvent{type: :toggle, id: "dark_mode", value: on?}) do
  %{model | dark_mode: on?}
end

Always include a catch-all clause at the end of your update/2 to handle events you do not care about:

def update(model, _event), do: model

Scope: identifying widgets in lists

When multiple widgets share the same local ID (like a "delete" button in each row of a list), the scope field tells you which container they belong to. We will use this extensively in chapter 6 when building the file list. For now, know that scope exists and carries the container ancestry.

Other event types

Not all events are WidgetEvent. Plushie also delivers:

Pointer events (mouse, touch, pen) from subscriptions and widgets like pointer_area are also delivered as WidgetEvent structs using the unified pointer event types (:press, :release, :move, :scroll, :enter, :exit). There are no separate MouseEvent or TouchEvent structs. The pointer field in the event data identifies the input device (:mouse, :touch, :pen).

We will cover each of these in the chapters where they are introduced. For now, WidgetEvent is the one you use most. See the Events reference for the full taxonomy.

Adding an event log to the pad

The best way to learn events is to see them. We will add an event log at the bottom of the pad that shows every event from the preview pane.

Updating the model

Add an event_log field:

def init(_opts) do
  model = %{
    source: @starter_code,
    preview: nil,
    error: nil,
    event_log: []       # new
  }

  case compile_preview(model.source) do
    {:ok, tree} -> %{model | preview: tree}
    {:error, msg} -> %{model | error: msg}
  end
end

Logging events

The catch-all clause at the bottom of update/2 is the perfect place to log events. Anything not handled by a specific clause gets logged:

def update(model, event) do
  entry = format_event(event)
  %{model | event_log: Enum.take([entry | model.event_log], 20)}
end

This replaces the old def update(model, _event), do: model catch-all. It prepends the formatted event to the log and caps it at 20 entries.

Formatting events

We format events as struct literals so you can see exactly what to pattern match on:

defp format_event(%mod{} = event) do
  name = mod |> Module.split() |> List.last()

  fields =
    event
    |> Map.from_struct()
    |> Enum.map(fn {k, v} -> "#{k}: #{inspect(v)}" end)
    |> Enum.join(", ")

  "%#{name}{#{fields}}"
end

This works for any event type, not just WidgetEvent. When you click a button in the preview, the log shows all fields:

%WidgetEvent{type: :click, id: "btn", value: nil, data: nil, scope: ["preview", "main"], window_id: "main"}

When you type in a text input:

%WidgetEvent{type: :input, id: "name", value: "hello", ...}

The event log view

Add a scrollable log below the toolbar:

# In view/1, after the toolbar row:
scrollable "log", height: 120 do
  column spacing: 2, padding: 4 do
    for {entry, i} <- Enum.with_index(model.event_log) do
      text("log-#{i}", entry, size: 12, font: :monospace)
    end
  end
end

The complete pad with event log

Here is the full module:

defmodule PlushiePad do
  use Plushie.App

  import Plushie.UI

  alias Plushie.Event.WidgetEvent

  @starter_code """
  defmodule Pad.Experiments.Hello do
    import Plushie.UI

    def view do
      column padding: 16, spacing: 8 do
        text("greeting", "Hello, Plushie!", size: 24)
        button("btn", "Click Me")
      end
    end
  end
  """

  def init(_opts) do
    model = %{
      source: @starter_code,
      preview: nil,
      error: nil,
      event_log: []
    }

    case compile_preview(model.source) do
      {:ok, tree} -> %{model | preview: tree}
      {:error, msg} -> %{model | error: msg}
    end
  end

  def update(model, %WidgetEvent{type: :input, id: "editor", value: source}) do
    %{model | source: source}
  end

  def update(model, %WidgetEvent{type: :click, id: "save"}) do
    case compile_preview(model.source) do
      {:ok, tree} -> %{model | preview: tree, error: nil}
      {:error, msg} -> %{model | error: msg, preview: nil}
    end
  end

  # Log everything else
  def update(model, event) do
    entry = format_event(event)
    %{model | event_log: Enum.take([entry | model.event_log], 20)}
  end

  def view(model) do
    window "main", title: "Plushie Pad" do
      column width: :fill, height: :fill do
        row width: :fill, height: :fill do
          text_editor "editor", model.source do
            width {:fill_portion, 1}
            height :fill
            highlight_syntax "ex"
            font :monospace
          end

          container "preview", width: {:fill_portion, 1}, height: :fill, padding: 16 do
            if model.error do
              text("error", model.error, color: :red)
            else
              if model.preview do
                model.preview
              else
                text("placeholder", "Press Save to compile and preview")
              end
            end
          end
        end

        row padding: 8 do
          button("save", "Save")
        end

        scrollable "log", height: 120 do
          column spacing: 2, padding: 4 do
            for {entry, i} <- Enum.with_index(model.event_log) do
              text("log-#{i}", entry, size: 12, font: :monospace)
            end
          end
        end
      end
    end
  end

  defp compile_preview(source) do
    case Code.string_to_quoted(source) do
      {:error, {meta, message, token}} ->
        line = Keyword.get(meta, :line, "?")
        {:error, "Line #{line}: #{message}#{token}"}

      {:ok, _ast} ->
        try do
          Code.put_compiler_option(:ignore_module_conflict, true)
          [{module, _}] = Code.compile_string(source)

          if function_exported?(module, :view, 0) do
            {:ok, module.view()}
          else
            {:error, "Module must export a view/0 function"}
          end
        rescue
          e -> {:error, Exception.message(e)}
        after
          Code.put_compiler_option(:ignore_module_conflict, false)
        end
    end
  end

  defp format_event(%mod{} = event) do
    name = mod |> Module.split() |> List.last()

    fields =
      event
      |> Map.from_struct()
      |> Enum.map(fn {k, v} -> "#{k}: #{inspect(v)}" end)
      |> Enum.join(", ")

    "%#{name}{#{fields}}"
  end
end

Verify it

Test that clicking a preview widget produces an event log entry:

test "clicking preview button logs an event" do
  click("#preview/btn")
  assert_exists("#log-0")
end

After clicking the button in the preview, the event log should contain at least one entry. This test proves the full chain: click -> event -> scope match -> format -> display.

Try replacing the editor content with a gallery of common widgets:

defmodule Pad.Experiments.Gallery do
  import Plushie.UI

  def view do
    column padding: 16, spacing: 12 do
      text("title", "Widget Gallery", size: 20)

      button("btn", "Button")
      checkbox("check", false)
      text_input("input", "", placeholder: "Type here...")
      slider("slide", {0, 100}, 50)
      progress_bar("progress", {0, 100}, 75)
      toggler("toggle", false)
    end
  end
end

Click Save. Each widget renders in the preview. Interact with them and watch the event log:

  • Click the button: :click with id: "btn"
  • Toggle the checkbox: :toggle with value: true or false
  • Type in the input: :input with the current text as value
  • Drag the slider: :slide with the numeric value
  • Toggle the toggler: :toggle with the value

The event log shows you exactly what to pattern match on in update/2.

Try it

  • Write an experiment with a text_input that has on_submit: true. Type some text and press Enter. Watch for the :submit event in the log. It carries the submitted text as value.
  • Add a pick_list with a few options. Select one and see the :select event.
  • Write an experiment with two buttons that have the same label but different IDs. Click each one and notice the id field distinguishes them.

The event log is your best teacher from here on. Every new widget you encounter produces events, and the log shows you their shape without having to check the documentation.

In the next chapter, we will add a file list to the pad so you can save multiple experiments and switch between them.


Next: Lists and Inputs