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(:keys)
]
endThe 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/1 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(:keys)]
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}
endThe Plushie.Event.KeyEvent struct has these key fields:
type-:pressor:releasekey- the key that was pressed. Named keys are atoms (:escape,:enter,:tab,:backspace,:arrow_up, etc.). Single characters are strings ("s","a","1").modifiers- aPlushie.KeyModifiersstruct with boolean fields:ctrl,shift,alt,logo(Windows/Super key), andcommand(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/1 if you need to track
key-up events.
Applying it: pad keyboard shortcuts
Add keyboard shortcuts to the pad:
def subscribe(_model) do
[Plushie.Subscription.on_key_press(:keys)]
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}
endThese 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()}
endThe 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 for management only and does not appear in the event.
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(:keys)]
if model.auto_save and model.dirty do
[Plushie.Subscription.every(1000, :auto_save) | subs]
else
subs
end
endWhen 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(:keys)]
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
endWhen 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/1,on_pointer_button/1,on_pointer_scroll/1,on_pointer_touch/1. These deliverWidgetEventstructs withidset to the window ID andscopeset to[]. Thedatamap includespointer(:mouseor:touch) andmodifiers(current modifier key state). - Window lifecycle:
on_window_close/1,on_window_resize/1,on_window_event/1,on_window_open/1,on_window_focus/1,on_window_unfocus/1,on_window_move/1 - IME:
on_ime/1for input method editor events - System:
on_theme_change/1,on_animation_frame/1,on_file_drop/1(Note: renderer-side transitions run independently and do not requireon_animation_frameor timer subscriptions.) - Catch-all:
on_event/1for any renderer event
Each returns its corresponding event struct in update/2. The tag argument
is for managing subscriptions (diffing, starting, stopping). For renderer
subscriptions, the tag does not appear in the delivered event. Timer
subscriptions are the exception: the tag is embedded in the %TimerEvent{} event.
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(:mouse)
|> 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(:mouse, max_rate: 30)Rate limiting works at three levels, from most to least specific:
- Per-widget:
event_rate:prop on individual widgets - Per-subscription:
max_rateon subscription specs - Global:
default_event_rateinPlushie.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(:settings_keys)
])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")
endThis 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(:keys)and log key names in a list. Press modifier keys and see howmodifierschanges. - 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