defmodule Kino.Control do
@moduledoc """
Various widgets for user interactions.
Each widget is a UI control element that the user interacts
with, consequently producing an event stream.
Those widgets are often useful paired with `Kino.Frame` for
presenting content that changes upon user interactions.
## Examples
First, create a control and make sure it is rendered,
either by placing it at the end of a code cell or by
explicitly rendering it with `Kino.render/1`.
button = Kino.Control.button("Hello")
Next, events need to be received from the control. This can
be done either by subscribing a process to the control with
`subscribe/2` or by creating an event stream using `stream/1`
or `tagged_stream/1` and then registering a callback using
`Kino.listen/2`.
Here, we'll subscribe the current process to events:
Kino.Control.subscribe(button, :hello)
As the user clicks the button, the subscribed process
receives events:
IEx.Helpers.flush()
#=> {:hello, %{origin: "client1"}}
#=> {:hello, %{origin: "client1"}}
"""
defstruct [:ref, :destination, :attrs]
@opaque t :: %__MODULE__{
ref: Kino.Output.ref(),
destination: Process.dest(),
attrs: map()
}
@opaque interval :: {:interval, milliseconds :: non_neg_integer()}
@type event_source :: t() | Kino.Input.t() | interval() | Kino.JS.Live.t()
defp new(attrs) do
ref = Kino.Output.random_ref()
subscription_manager = Kino.SubscriptionManager.cross_node_name()
Kino.Bridge.reference_object(ref, self())
Kino.Bridge.monitor_object(ref, subscription_manager, {:clear_topic, ref})
%__MODULE__{ref: ref, destination: subscription_manager, attrs: attrs}
end
@doc """
Creates a new button.
## Examples
Create the widget:
button = Kino.Control.button("Hello")
Listen to events:
Kino.listen(button, fn event ->
...
end)
Or subscribe to them in a separate process:
Kino.Control.subscribe(button, :keyboard)
"""
@spec button(String.t()) :: t()
def button(label) when is_binary(label) do
new(%{type: :button, label: label})
end
@doc """
Creates a new keyboard control.
This widget is represented as button that toggles interception
mode, in which the given keyboard events are captured.
> #### Keyboard shortcut {:.info}
>
> As of Livebook v0.11, keyboard controls can be toggled by
> focusing the cell and pressing `ctrl + k` (or `⌘ + k` on
> MacOS).
## Options
Note that these options require Livebook v0.11 or later.
* `:default_handlers` - controls Livebook's default keyboard
shortcut handlers while the keyboard control is enabled.
Must be one of:
* `:off` (default) - all Livebook keyboard shortcuts are disabled
* `:on` - all Livebook keyboard shortcuts are enabled
* `:disable_only` - Livebook keyboard shortcuts are off except
for the shortcut to toggle (disable) the control
## Event info
In addition to standard properties, all events include additional
properties.
### Key events
* `:type` - either `:keyup` or `:keydown`
* `:key` - the value matching the browser [KeyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key)
### Status event
* `:type` - either `:status`
* `:enabled` - whether the keyboard is activated
## Examples
Create the widget:
keyboard = Kino.Control.keyboard([:keyup, :keydown, :status])
Listen to events:
Kino.listen(keyboard, fn event ->
...
end)
Or subscribe to them in a separate process:
Kino.Control.subscribe(keyboard, :keyboard)
As the user types events are streamed:
IEx.Helpers.flush()
#=> {:keyboard, %{enabled: true, origin: "client1", type: :status}
#=> {:keyboard, %{key: "o", origin: "client1", type: :keydown}}
#=> {:keyboard, %{key: "k", origin: "client1", type: :keydown}}
#=> {:keyboard, %{key: "o", origin: "client1", type: :keyup}}
#=> {:keyboard, %{key: "k", origin: "client1", type: :keyup}}
"""
@spec keyboard(list(:keyup | :keydown | :status), opts) :: t()
when opts: [default_handlers: :off | :on | :disable_only]
def keyboard(events, opts \\ []) when is_list(events) do
opts = Keyword.validate!(opts, default_handlers: :off)
if events == [] do
raise ArgumentError, "expected at least one event, got: []"
end
for event <- events do
unless event in [:keyup, :keydown, :status] do
raise ArgumentError,
"expected event to be either :keyup, :keydown or :status, got: #{inspect(event)}"
end
end
unless opts[:default_handlers] in [:off, :on, :disable_only] do
raise ArgumentError,
"when passed, :default_handlers must be one of :off, :on or :disable_only, got: #{inspect(opts[:default_handlers])}"
end
new(%{type: :keyboard, events: events, default_handlers: opts[:default_handlers]})
end
@doc """
Creates a new form.
A form is composed of regular inputs from the `Kino.Input` module,
however in a form input values are not synchronized between users.
Consequently, the form is another control for producing user-specific
events.
Either `:submit` or `:report_changes` must be specified.
## Options
* `:submit` - specifies the label to use for the submit button
and enables submit events
* `:report_changes` - whether to send new form value whenever any
of the input changes. Defaults to `false`
* `:reset_on_submit` - a list of fields to revert to their default
values once the form is submitted. Use `true` to indicate all
fields. Defaults to `[]`
## Event info
In addition to standard properties, all events include additional
properties.
* `:type` - either `:submit` or `:change`
* `:data` - a map with field values, matching the field list
## Examples
Create a form out of inputs:
form =
Kino.Control.form(
[
name: Kino.Input.text("Name"),
message: Kino.Input.textarea("Message")
],
submit: "Send"
)
Listen to events:
Kino.listen(form, fn event ->
...
end)
Or subscribe to them in a separate process:
Kino.Control.subscribe(form, :chat_form)
As users submit the form the payload is sent:
IEx.Helpers.flush()
#=> {:chat_form,
#=> %{
#=> data: %{message: "Hola", name: "Amy"},
#=> origin: "client1",
#=> type: :submit
#=> }}
#=> {:chat_form,
#=> %{
#=> data: %{message: "Hey!", name: "Jake"},
#=> origin: "client2",
#=> type: :submit
#=> }}
"""
@spec form(list({atom(), Kino.Input.t()}), keyword()) :: t()
def form(fields, opts \\ []) when is_list(fields) do
if fields == [] do
raise ArgumentError, "expected at least one field, got: []"
end
for {field, input} <- fields do
unless is_atom(field) do
raise ArgumentError,
"expected each field key to be an atom, got: #{inspect(field)}"
end
unless is_struct(input, Kino.Input) do
raise ArgumentError,
"expected each field to be a Kino.Input widget, got: #{inspect(input)} for #{inspect(field)}"
end
end
unless opts[:submit] || opts[:report_changes] do
raise ArgumentError, "expected either :submit or :report_changes option to be enabled"
end
fields =
Enum.map(fields, fn {field, input} ->
# Make sure we use this input only in the form and nowhere else
input = Kino.Input.duplicate(input)
{field, Kino.Render.to_livebook(input)}
end)
submit = Keyword.get(opts, :submit, nil)
report_changes =
if Keyword.get(opts, :report_changes, false) do
Map.new(fields, fn {field, _} -> {field, true} end)
else
%{}
end
reset_on_submit =
case Keyword.get(opts, :reset_on_submit, []) do
true -> Keyword.keys(fields)
false -> []
fields -> fields
end
new(%{
type: :form,
fields: fields,
submit: submit,
report_changes: report_changes,
reset_on_submit: reset_on_submit
})
end
@doc """
Subscribes the calling process to control or input events.
This is an alternative API to `stream/1`, such that event
messages are consumed via process messages instead of streams.
The events are sent as `{tag, info}`, where info is a map with
event details. In particular, it always includes `:origin`, which
is an opaque identifier of the client that triggered the event.
"""
@spec subscribe(t() | Kino.Input.t(), term()) :: :ok
def subscribe(source, tag)
when is_struct(source, Kino.Control) or is_struct(source, Kino.Input) do
Kino.SubscriptionManager.subscribe(source.ref, self(), tag)
end
@doc """
Unsubscribes the calling process from control or input events.
"""
@spec unsubscribe(t() | Kino.Input.t()) :: :ok
def unsubscribe(source)
when is_struct(source, Kino.Control) or is_struct(source, Kino.Input) do
Kino.SubscriptionManager.unsubscribe(source.ref, self())
end
@doc """
Returns a new interval event source.
This can be used as event source for `stream/1` and `tagged_stream/1`.
The events are emitted periodically with an increasing value, starting
from 0 and have the form:
%{type: :interval, iteration: non_neg_integer()}
"""
@spec interval(non_neg_integer()) :: interval()
def interval(milliseconds) when is_number(milliseconds) and milliseconds > 0 do
{:interval, milliseconds}
end
@doc """
Merges several inputs and controls into a single `stream` of events.
It accepts a single source or a list of sources, where each
source is either of:
* `%Kino.Control{}` - emitting value on relevant interaction
* `%Kino.Input{}` - emitting value on value change
* `%Kino.JS.Live{}` - emitting value programmatically
* `t:interval/0` - emitting value periodically, see `interval/1`
You can then consume the stream to access its events.
The stream is typically consumed via `Kino.listen/2`.
## Example
button = Kino.Control.button("Hello")
input = Kino.Input.checkbox("Check")
interval = Kino.Control.interval(1000)
[button, input, interval]
|> Kino.Control.stream()
|> Kino.listen(fn event ->
IO.inspect(event)
end)
#=> %{type: :interval, iteration: 0}
#=> %{origin: "client1", type: :click}
#=> %{origin: "client1", type: :change, value: true}
"""
@spec stream(event_source() | list(event_source())) :: Enumerable.t()
def stream(source)
def stream(sources) when is_list(sources) do
{tagged_topics, tagged_intervals} =
for source <- sources, reduce: {[], []} do
{tagged_topics, tagged_intervals} ->
assert_stream_source!(source)
case source do
%struct{ref: ref} when struct in [Kino.Control, Kino.Input] ->
{[{nil, ref} | tagged_topics], tagged_intervals}
%Kino.JS.Live{ref: ref} ->
{[{nil, ref} | tagged_topics], tagged_intervals}
{:interval, ms} ->
{tagged_topics, [{nil, ms} | tagged_intervals]}
end
end
# Preserve original intervals order as it impacts the events order
build_stream(tagged_topics, Enum.reverse(tagged_intervals), fn nil, event -> event end)
end
def stream(source) do
stream([source])
end
@doc """
Same as `stream/1`, but attaches custom tag to every stream item.
## Example
button = Kino.Control.button("Hello")
input = Kino.Input.checkbox("Check")
[hello: button, check: input]
|> Kino.Control.tagged_stream()
|> Kino.listen(fn event ->
IO.inspect(event)
end)
#=> {:hello, %{origin: "client1", type: :click}}
#=> {:check, %{origin: "client1", type: :change, value: true}}
"""
@spec tagged_stream(keyword(event_source())) :: Enumerable.t()
def tagged_stream(entries) when is_list(entries) do
{tagged_topics, tagged_intervals} =
for entry <- entries, reduce: {[], []} do
{tagged_topics, tagged_intervals} ->
case entry do
{tag, source} when is_atom(tag) ->
assert_stream_source!(source)
_other ->
raise ArgumentError, "expected a keyword list, got: #{inspect(entries)}"
end
{tag, source} = entry
case source do
%struct{ref: ref} when struct in [Kino.Control, Kino.Input] ->
{[{tag, ref} | tagged_topics], tagged_intervals}
%Kino.JS.Live{ref: ref} ->
{[{tag, ref} | tagged_topics], tagged_intervals}
{:interval, ms} ->
{tagged_topics, [{tag, ms} | tagged_intervals]}
end
end
build_stream(tagged_topics, Enum.reverse(tagged_intervals), fn tag, event -> {tag, event} end)
end
defp assert_stream_source!(%Kino.Control{}), do: :ok
defp assert_stream_source!(%Kino.Input{}), do: :ok
defp assert_stream_source!(%Kino.JS.Live{}), do: :ok
defp assert_stream_source!({:interval, ms}) when is_number(ms) and ms > 0, do: :ok
defp assert_stream_source!(item) do
raise ArgumentError,
"expected source to be either %Kino.Control{}, %Kino.Input{}, %Kino.JS.Live{} or {:interval, ms}, got: #{inspect(item)}"
end
defp build_stream(tagged_topics, tagged_intervals, mapper) do
Stream.resource(
fn ->
ref = make_ref()
for {tag, topic} <- tagged_topics do
Kino.SubscriptionManager.subscribe(topic, self(), {ref, tag}, notify_clear: true)
end
for {tag, ms} <- tagged_intervals do
Process.send_after(self(), {{ref, tag}, :__interval__, ms, 0}, ms)
end
topics = Enum.map(tagged_topics, &elem(&1, 1))
{ref, topics}
end,
fn {ref, topics} ->
receive do
{{^ref, tag}, event} ->
{[mapper.(tag, event)], {ref, topics}}
{{^ref, _tag}, :topic_cleared, topic} ->
case topics -- [topic] do
[] -> {:halt, {ref, []}}
topics -> {[], {ref, topics}}
end
{{^ref, tag}, :__interval__, ms, i} ->
Process.send_after(self(), {{ref, tag}, :__interval__, ms, i + 1}, ms)
event = %{type: :interval, iteration: i}
{[mapper.(tag, event)], {ref, topics}}
end
end,
fn {_ref, topics} ->
for topic <- topics do
Kino.SubscriptionManager.unsubscribe(topic, self())
end
end
)
end
end
defimpl Enumerable, for: Kino.Control do
def reduce(control, acc, fun), do: Enumerable.reduce(Kino.Control.stream([control]), acc, fun)
def member?(_control, _value), do: {:error, __MODULE__}
def count(_control), do: {:error, __MODULE__}
def slice(_control), do: {:error, __MODULE__}
end