# Async and Effects

The pad saves experiments to the local filesystem, but so far it uses
synchronous file I/O directly in `update/2`. In this chapter we add
asynchronous commands for background work, platform effects for native
dialogs, and multi-window support. By the end, the pad will have
import/export via file dialogs, clipboard integration, and the ability to
detach an experiment into its own window.

## Async commands

`Plushie.Command.async/2` runs a function in a separate process and delivers
the result as an event:

```elixir
alias Plushie.Command

def update(model, %WidgetEvent{type: :click, id: "fetch"}) do
  cmd = Command.async(fn ->
    # This runs in a Task, not in the runtime process
    {:ok, fetch_data_from_api()}
  end, :data_loaded)

  {%{model | status: :loading}, cmd}
end
```

The second argument, `:data_loaded`, is a **tag** that identifies this task.
The result arrives as a `Plushie.Event.AsyncEvent` struct with the same tag:

```elixir
alias Plushie.Event.AsyncEvent

def update(model, %AsyncEvent{tag: :data_loaded, result: {:ok, data}}) do
  %{model | status: :done, data: data}
end

def update(model, %AsyncEvent{tag: :data_loaded, result: {:error, reason}}) do
  %{model | status: :error, error: inspect(reason)}
end
```

A few things to know about async:

- **One task per tag.** Starting a new async with the same tag kills the
  previous one. This prevents stale results from a superseded request.
- **Results are nonce-checked.** If a task is killed and its result arrives
  late, the runtime discards it silently.
- **The function runs in a linked Task.** Exceptions in the function become
  `{:error, reason}` results, where `reason` is the exception struct.

## Platform effects

Effects are asynchronous requests to the renderer for platform operations:
file dialogs, clipboard access, and notifications. Unlike async commands
(which run Elixir code), effects are handled by the renderer binary.

### File dialogs

Every effect takes an atom tag as its first argument. The tag identifies the
effect in result matching, so there is no need to store request IDs in your model.

```elixir
alias Plushie.Effect

def update(model, %WidgetEvent{type: :click, id: "import"}) do
  {model, Effect.file_open(:import, title: "Import Experiment", filters: [{"Elixir", "*.ex"}])}
end
```

The result arrives as a `Plushie.Event.EffectEvent` struct. Match on the tag:

```elixir
alias Plushie.Event.EffectEvent

def update(model, %EffectEvent{tag: :import, result: {:ok, %{path: path}}}) do
  source = File.read!(path)
  # ... load the experiment
end

def update(model, %EffectEvent{tag: :import, result: :cancelled}) do
  model  # user closed the dialog
end

def update(model, %EffectEvent{tag: :import, result: {:error, reason}}) do
  %{model | error: inspect(reason)}
end
```

Available file dialogs:

- `Effect.file_open/2` - single file selection
- `Effect.file_open_multiple/2` - multiple file selection
- `Effect.file_save/2` - save dialog
- `Effect.directory_select/2` - directory selection
- `Effect.directory_select_multiple/2` - multiple directories

### Clipboard

```elixir
# Copy text to clipboard
{model, Effect.clipboard_write(:copy, model.source)}

# Read from clipboard
{model, Effect.clipboard_read(:paste)}
# Result: %EffectEvent{tag: :paste, result: {:ok, %{text: content}}}
```

Also available: `clipboard_read_html/1`, `clipboard_write_html/3`,
`clipboard_clear/1`. On Linux, `clipboard_read_primary/1` and
`clipboard_write_primary/2` access the middle-click selection buffer.

### Notifications

```elixir
{model, Effect.notification(:exported, "Exported", "Experiment saved to #{path}")}
```

Options include `:icon`, `:timeout`, and `:urgency` (`:low`, `:normal`,
`:critical`).

### Effect timeouts

Effects have default timeouts: 120 seconds for file dialogs (the user may
browse for a while), 5 seconds for clipboard and notifications. If the
renderer does not respond in time, you get `{:error, :timeout}`.

### Applying it: import and export

Add import and export buttons to the pad toolbar:

```elixir
row padding: {4, 8}, spacing: 8 do
  button("save", "Save", style: :primary)
  button("import", "Import")
  button("export", "Export")
  checkbox("auto-save", model.auto_save)
  text("auto-label", "Auto-save")
end
```

Handle the events. Each effect gets a distinct tag, so matching the
result is straightforward:

```elixir
def update(model, %WidgetEvent{type: :click, id: "import"}) do
  {model, Effect.file_open(:import, title: "Import Experiment")}
end

def update(model, %WidgetEvent{type: :click, id: "export"}) do
  {model, Effect.file_save(:export, title: "Export Experiment")}
end

def update(model, %EffectEvent{tag: :import, result: {:ok, %{path: path}}}) do
  source = File.read!(path)
  %{model | source: source}
end

def update(model, %EffectEvent{tag: :export, result: {:ok, %{path: path}}}) do
  File.write!(path, model.source)
  model
end

def update(model, %EffectEvent{tag: tag, result: :cancelled}) when tag in [:import, :export] do
  model
end
```

### Applying it: copy code

Add a "Copy" button that copies the current experiment source to the
clipboard:

```elixir
def update(model, %WidgetEvent{type: :click, id: "copy"}) do
  {model, Effect.clipboard_write(:copy, model.source)}
end
```

## Streaming and cancellation

For long-running work that produces intermediate results:

```elixir
cmd = Command.stream(fn emit ->
  for {line, n} <- Enum.with_index(File.stream!("large.csv"), 1) do
    emit.(%{line: n, data: parse(line)})
  end
  :done
end, :csv_import)
```

Each `emit.()` call delivers a `Plushie.Event.StreamEvent` struct. The final
return value arrives as a `Plushie.Event.AsyncEvent` struct, same as regular async.

To cancel a running async or stream:

```elixir
{model, Command.cancel(:csv_import)}
```

For one-shot delayed events (not recurring like subscriptions):

```elixir
{model, Command.send_after(3000, {:clear_status})}
```

See the [Commands reference](../reference/commands.md) for details.

## Batching

Combine multiple commands from a single update:

```elixir
{model, Command.batch([
  Effect.clipboard_write(:copy, model.source),
  Effect.notification(:copied, "Copied", "Source copied to clipboard"),
  Command.focus("editor")
])}
```

Commands in a batch execute in order.

## Multi-window

A Plushie app can have multiple windows. Return a list of `window` nodes
from `view/1`:

```elixir
def view(model) do
  windows = [
    window "main", title: "Plushie Pad" do
      # ... main pad UI
    end
  ]

  if model.detached do
    windows ++ [
      window "experiment", title: "Experiment: #{model.active_file}" do
        container "detached-preview", padding: 16 do
          model.preview
        end
      end
    ]
  else
    windows
  end
end
```

A few important things about multi-window:

**Window IDs must be stable strings.** If a window ID changes between
renders, the renderer closes the old window and opens a new one. Use
consistent, predictable IDs.

**`exit_on_close_request`** is a per-window prop that controls whether
closing the window triggers app exit. There is no "primary window" concept --
each window independently decides its close behaviour:

```elixir
window "main", title: "App", exit_on_close_request: true do
  # Closing this window exits the app
end

window "settings", title: "Settings", exit_on_close_request: false do
  # Closing this window just closes the window
end
```

**`close_requested` vs `closed`:** When the user clicks the window's close
button, the renderer sends a `:close_requested` event, not a `:closed` event.
Your app decides whether to actually close. If `exit_on_close_request` is
true (the default), the runtime exits. Otherwise, the event arrives in
`update/2` and you handle it:

```elixir
alias Plushie.Event.WindowEvent

def update(model, %WindowEvent{type: :close_requested, window_id: "experiment"}) do
  %{model | detached: false}
end
```

**Daemon mode** keeps the app running after the last window closes. Set
`:daemon` in `Plushie.start_link/2` options. When all windows close, you
receive `%SystemEvent{type: :all_windows_closed}` in `update/2`.

### Applying it: detach experiment

Add a "Detach" button that opens the experiment in its own window:

```elixir
def update(model, %WidgetEvent{type: :click, id: "detach"}) do
  %{model | detached: true}
end

def update(model, %WindowEvent{type: :close_requested, window_id: "experiment"}) do
  %{model | detached: false}
end
```

The view conditionally adds a second window when `model.detached` is true.
Closing the experiment window sets the flag back to false, and the experiment
returns to the preview pane.

## Error handling patterns

Always handle both success and failure for async and effects:

```elixir
def update(model, %AsyncEvent{tag: :export, result: {:ok, _}}) do
  %{model | status: "Exported"}
end

def update(model, %AsyncEvent{tag: :export, result: {:error, reason}}) do
  %{model | error: "Export failed: #{inspect(reason)}"}
end

def update(model, %EffectEvent{tag: _tag, result: :cancelled}) do
  model  # user cancelled the dialog, not an error
end
```

The `:cancelled` result is distinct from `{:error, reason}`. A user
cancelling a file dialog is expected behaviour, not a failure.

## Verify it

Effect stubs let you control what the renderer returns for platform
operations. Register a stub before triggering the effect, and the renderer
responds with your stub instead of executing the real operation:

```elixir
test "import loads experiment from file" do
  register_effect_stub("file_open", {:ok, %{path: "/tmp/test.ex"}})
  click("#import")
  # The stub responds with the path; verify the source was loaded
  assert model().source != ""
end

test "detach button opens second window" do
  click("#detach")
  assert model().detached == true
end
```

The full effect stubbing API is covered in [chapter 15](15-testing.md).

## Try it

Write experiments to try these concepts:

- Build a button that triggers `Command.async` with a simulated slow
  operation (`Process.sleep(2000)`). Show a loading indicator while the
  task runs, then display the result.
- Try `Command.stream` to deliver progress updates. Show a progress bar
  that fills as chunks arrive.
- Try `Command.batch` to combine a clipboard write with a notification.
- Build a two-window experiment: a main window and a secondary window
  that opens conditionally. Close the secondary window and see it disappear
  from the view.

In the next chapter, we will explore the canvas system for custom 2D drawing.

---

Next: [Canvas](12-canvas.md)
