defmodule Kino.Frame do
@moduledoc """
A placeholder for outputs.
A frame wraps outputs that can be dynamically updated at
any time.
Also see `Kino.animate/3` which offers a convenience on
top of this kino.
## Examples
frame = Kino.Frame.new() |> Kino.render()
for i <- 1..100 do
Kino.Frame.render(frame, i)
Process.sleep(50)
end
Or with a scheduled task in the background.
frame = Kino.Frame.new() |> Kino.render()
Kino.listen(50, fn i ->
Kino.Frame.render(frame, i)
end)
"""
@doc false
use GenServer, restart: :temporary
defstruct [:ref, :pid, :placeholder]
@opaque t :: %__MODULE__{
ref: String.t(),
pid: pid(),
placeholder: boolean()
}
@typedoc false
@type state :: %{outputs: list(Kino.Output.t())}
@doc """
Creates a new frame.
## Options
* `:placeholder` - whether to render a placeholder when the frame
is empty. Defaults to `true`
"""
@spec new(keyword()) :: t()
def new(opts \\ []) do
opts = Keyword.validate!(opts, placeholder: true)
ref = System.unique_integer() |> Integer.to_string()
{:ok, pid} = Kino.start_child({__MODULE__, ref})
%__MODULE__{ref: ref, pid: pid, placeholder: opts[:placeholder]}
end
@doc false
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@doc """
Renders the given term within the frame.
This works similarly to `Kino.render/1`, but the rendered
output replaces existing frame contents.
## Options
* `:to` - the client id to whom the update is directed. This
option is useful when updating frame in response to client
events, such as form submission
* `:temporary` - when `true`, the update is applied only to
the connected clients and doesn't become a part of frame
history. Defaults to `false`, unless `:to` is given. Direct
updates are never a part of frame history
"""
@spec render(t(), term(), keyword()) :: :ok
def render(frame, term, opts \\ []) do
opts = Keyword.validate!(opts, [:to, :temporary])
destination = update_destination_from_opts!(opts)
GenServer.call(frame.pid, {:render, term, destination})
end
defp update_destination_from_opts!(opts) do
if to = opts[:to] do
if opts[:temporary] == false do
raise ArgumentError,
"direct updates sent via :to are never part of the frame history," <>
" disabling :temporary is not supported"
end
{:client, to}
else
if Keyword.get(opts, :temporary, false) do
:clients
else
:default
end
end
end
@doc """
Renders and appends the given term to the frame.
## Options
* `:to` - the client id to whom the update is directed. This
option is useful when updating frame in response to client
events, such as form submission
* `:temporary` - when `true`, the update is applied only to
the connected clients and doesn't become a part of frame
history. Defaults to `false`, unless `:to` is given. Direct
updates are never a part of frame history
"""
@spec append(t(), term(), keyword()) :: :ok
def append(frame, term, opts \\ []) do
opts = Keyword.validate!(opts, [:to, :temporary])
destination = update_destination_from_opts!(opts)
GenServer.call(frame.pid, {:append, term, destination})
end
@doc """
Removes all outputs within the given frame.
## Options
* `:to` - the client id to whom the update is directed. This
option is useful when updating frame in response to client
events, such as form submission
* `:temporary` - when `true`, the update is applied only to
the connected clients and doesn't become a part of frame
history. Defaults to `false`, unless `:to` is given. Direct
updates are never a part of frame history
"""
@spec clear(t(), keyword()) :: :ok
def clear(frame, opts \\ []) do
opts = Keyword.validate!(opts, [:to, :temporary])
destination = update_destination_from_opts!(opts)
GenServer.cast(frame.pid, {:clear, destination})
end
@doc false
@spec get_outputs(t()) :: list(Kino.Output.t())
def get_outputs(frame) do
GenServer.call(frame.pid, :get_outputs)
end
@impl true
def init(ref) do
{:ok, %{ref: ref, outputs: []}}
end
@impl true
def handle_cast({:clear, destination}, state) do
put_update(destination, state.ref, [], :replace)
state = update_outputs(state, destination, fn _ -> [] end)
{:noreply, state}
end
@impl true
def handle_call({:render, term, destination}, _from, state) do
output = Kino.Render.to_livebook(term)
put_update(destination, state.ref, [output], :replace)
state = update_outputs(state, destination, fn _ -> [output] end)
{:reply, :ok, state}
end
def handle_call({:append, term, destination}, _from, state) do
output = Kino.Render.to_livebook(term)
put_update(destination, state.ref, [output], :append)
state = update_outputs(state, destination, &[output | &1])
{:reply, :ok, state}
end
def handle_call(:get_outputs, _from, state) do
{:reply, state.outputs, state}
end
defp update_outputs(state, :default, update_fun) do
update_in(state.outputs, update_fun)
end
defp update_outputs(state, _destination, _update_fun), do: state
defp put_update(destination, ref, outputs, type) do
output = %{type: :frame_update, ref: ref, update: {type, outputs}}
case destination do
:default -> Kino.Bridge.put_output(output)
{:client, to} -> Kino.Bridge.put_output_to(to, output)
:clients -> Kino.Bridge.put_output_to_clients(output)
end
end
end