Search documentation of Plushie

# Subscriptions

Copy Markdown View Source

So far, every event in the pad has come from direct widget interaction: a button click, a text input keystroke. But some events come from outside the widget tree: keyboard shortcuts, timers, window events, pointer movement. These are delivered through subscriptions.

## What are subscriptions?

Subscriptions are declarative event sources. You implement the optional `Plushie.App.subscribe/1` callback, which receives the current model and returns a list of subscription specs:

    def subscribe(model) do
      [
        Plushie.Subscription.on_key_press()
      ]
    end

The runtime calls `subscribe/1` after every update cycle and diffs the returned list against the active subscriptions. New specs start new event sources; removed specs stop them. You never start or stop subscriptions manually. You describe what you want, and the runtime manages the lifecycle.

This is the same declarative approach as the view: the list is a function of the model. When the model changes, the active subscriptions change with it.

## Keyboard subscriptions

`Plushie.Subscription.on_key_press/0` subscribes to keyboard events. It delivers `Plushie.Event.KeyEvent` structs to `update/2`:

    alias Plushie.Event.KeyEvent

    def subscribe(_model) do
      [Plushie.Subscription.on_key_press()]
    end

    def update(model, %KeyEvent{key: "s", modifiers: %{command: true}}) do
      # Ctrl+S (or Cmd+S on macOS)
      save_current(model)
    end

    def update(model, %KeyEvent{key: :escape}) do
      # Escape key
      %{model | error: nil}
    end

The `Plushie.Event.KeyEvent` struct has these key fields:

- `type` - `:press` or `:release`
- `key` - the key that was pressed. Named keys are atoms (`:escape`, `:enter`, `:tab`, `:backspace`, `:arrow_up`, etc.). Single characters are strings (`"s"`, `"a"`, `"1"`).
- `modifiers` - a `Plushie.KeyModifiers` struct with boolean fields: `ctrl`, `shift`, `alt`, `logo` (Windows/Super key), and `command` (platform-aware: Ctrl on Linux/Windows, Cmd on macOS).

The `command` field is particularly useful. Matching on `command: true` gives you Ctrl+S on Linux/Windows and Cmd+S on macOS without platform checks.

There is also `Plushie.Subscription.on_key_release/0` if you need to track key-up events.

`Plushie.Subscription.on_modifiers_changed/0` tracks modifier key state changes (Shift, Ctrl, Alt, etc.) without requiring a regular key press. It delivers `Plushie.Event.ModifiersEvent` structs:

    def subscribe(_model) do
      [
        Plushie.Subscription.on_key_press(),
        Plushie.Subscription.on_modifiers_changed()
      ]
    end

    def update(model, %Plushie.Event.ModifiersEvent{modifiers: %{shift: true}}) do
      %{model | shift_held: true}
    end

This is useful for UI that changes appearance based on held modifier keys (e.g., showing alternate button labels when Shift is held).

### Applying it: pad keyboard shortcuts

Add keyboard shortcuts to the pad:

    def subscribe(_model) do
      [Plushie.Subscription.on_key_press()]
    end

    def update(model, %KeyEvent{key: "s", modifiers: %{command: true}}) do
      # Ctrl+S / Cmd+S: save and compile
      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

    def update(model, %KeyEvent{key: "n", modifiers: %{command: true}}) do
      # Ctrl+N / Cmd+N: focus the new experiment input
      {model, Plushie.Command.focus("new-name")}
    end

    def update(model, %KeyEvent{key: :escape}) do
      # Escape: clear error display
      %{model | error: nil}
    end

These are real features for the pad. Ctrl+S saves, Ctrl+N focuses the new experiment input, and Escape dismisses errors.

## Timer subscriptions

`Plushie.Subscription.every/2` fires on a recurring interval:

    Plushie.Subscription.every(1000, :tick)

This delivers a `Plushie.Event.TimerEvent` struct every 1000 milliseconds:

    alias Plushie.Event.TimerEvent

    def update(model, %TimerEvent{tag: :tick}) do
      %{model | time: DateTime.utc_now()}
    end

The `tag` field in the TimerEvent matches the tag you gave the subscription. This is different from renderer subscriptions (like `on_key_press`) where the tag is not part of the API (renderer subscriptions take no tag).

### Conditional subscriptions

Because `subscribe/1` is a function of the model, you can activate subscriptions conditionally:

    def subscribe(model) do
      subs = [Plushie.Subscription.on_key_press()]

      if model.auto_save and model.dirty do
        [Plushie.Subscription.every(1000, :auto_save) | subs]
      else
        subs
      end
    end

When `auto_save` is false or the content has not changed, the timer is not in the list, so the runtime stops it. When the conditions are met, the timer starts. No manual start/stop logic needed.

### Applying it: wire up auto-save

In chapter 6 we added the auto-save checkbox but did not wire it up. Now we can. We need a `dirty` flag that tracks whether the source has changed since the last save:

    # In update/2, when editor content changes:
    def update(model, %WidgetEvent{type: :input, id: "editor", value: source}) do
      %{model | source: source, dirty: true}
    end

    # In subscribe/1:
    def subscribe(model) do
      subs = [Plushie.Subscription.on_key_press()]

      if model.auto_save and model.dirty do
        [Plushie.Subscription.every(1000, :auto_save) | subs]
      else
        subs
      end
    end

    # Handle the timer:
    def update(model, %TimerEvent{tag: :auto_save}) 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, dirty: false}

        {:error, msg} ->
          %{model | error: msg, preview: nil}
      end
    end

When auto-save is checked and the content has changed, a timer fires every second. The handler compiles, saves, and clears the dirty flag. Once the flag is cleared, the subscription disappears from the list and the timer stops, until the next edit.

## Other subscriptions

Plushie provides subscriptions for many event sources beyond keyboard and timers:

- Pointer: `on_pointer_move/0`, `on_pointer_button/0`, `on_pointer_scroll/0`, `on_pointer_touch/0`. These deliver `WidgetEvent` structs with `id` set to the window ID and `scope` set to `[]`. The `data` map includes `pointer` (`:mouse` or `:touch`) and `modifiers` (current modifier key state).
- Window lifecycle: `on_window_close/0`, `on_window_resize/0`, `on_window_event/0`, `on_window_open/0`, `on_window_focus/0`, `on_window_unfocus/0`, `on_window_move/0`
- IME: `on_ime/0` for input method editor events
- System: `on_theme_change/0`, `on_animation_frame/0`, `on_file_drop/0` (Note: renderer-side transitions run independently and do not require `on_animation_frame` or timer subscriptions.)
- Catch-all: `on_event/0` for any renderer event

Each returns its corresponding event struct in `update/2`. Renderer subscriptions take no tag; they are keyed by `{kind, window_id}` for lifecycle management. Timer subscriptions are different: the tag is embedded in the `%TimerEvent{}` event and is required.

See the Subscriptions reference for the complete list and details.

## Rate limiting

High-frequency events like pointer movement can call `update/2` unnecessarily often, potentially hundreds of times per second when you only need the position at 30fps. This is especially wasteful over networked connections where each update generates wire traffic. `Plushie.Subscription.max_rate/2` throttles delivery:

    Plushie.Subscription.on_pointer_move()
    |> Plushie.Subscription.max_rate(30)

This caps delivery to 30 events per second. The renderer coalesces intermediate events, delivering only the latest state at each interval.

You can also set `max_rate` as a constructor option:

    Plushie.Subscription.on_pointer_move(max_rate: 30)

Rate limiting works at three levels, from most to least specific:

1. Per-widget: `event_rate:` prop on individual widgets
2. Per-subscription: `max_rate` on subscription specs
3. Global: `default_event_rate` in `Plushie.App.settings/0`

More specific settings override less specific ones. See the Subscriptions reference for details.

## Window-scoped subscriptions

In multi-window apps, you can scope subscriptions to a specific window:

    Plushie.Subscription.for_window("settings", [
      Plushie.Subscription.on_key_press()
    ])

This delivers key events only from the "settings" window.

## Verify it

Test that the Ctrl+S shortcut compiles the preview:

    test "ctrl+s saves and compiles" do
      press("ctrl+s")
      assert_exists("#preview/greeting")
    end

This verifies the full subscription pipeline: the key subscription is active, the runtime delivers the `%KeyEvent{}` event, your handler compiles the source and updates the preview.

## Try it

Write a subscription experiment in your pad:

- Build a clock: subscribe to `every(1000, :tick)` and display the current time. Watch the display update every second.
- Subscribe to `on_key_press()` and log key names in a list. Press modifier keys and see how `modifiers` changes.
- Try a conditional subscription: subscribe to a timer only when a checkbox is checked. Toggle the checkbox and observe the timer starting and stopping.

In the next chapter, we will add file dialogs, clipboard integration, and multi-window support to the pad.

Next: Async and Effects

← Previous Page Animation and Transitions
Next Page → Async and Effects