Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Kino.Controls for user interactions #50

Merged
merged 17 commits into from
Dec 2, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions lib/kino.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ defmodule Kino do

Kino.Ecto.new(Weather, Repo)

### Kino.Frame

`Kino.Frame` is a placeholder for static outptus that can
be dynamically updated.

widget = Kino.Frame.new() |> tap(&Kino.render/1)

for i <- 1..100 do
Kino.Frame.render(widget, i)
Process.sleep(50)
end

Also see `Kino.animate/3`.

### User interactions

`Kino.Input` and `Kino.Controls` provide a set of widgets for
entering data and capturing user events.

### All others

All other data structures are rendered as text using Elixir's
Expand Down Expand Up @@ -193,4 +212,20 @@ defmodule Kino do
def nothing() do
:"do not show this result in output"
end

@doc """
Ties the given process lifetime to the caller.

When used directly in a Livebook cell, the process is killed
on re-evaluation.

When used from another process, the given process is killed
as soon as the parent terminates.
"""
@spec bind_process(pid()) :: :ok | {:error, atom()}
def bind_process(pid) do
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
with :ok <- Kino.Bridge.object_add_pointer(pid) do
Kino.Bridge.object_add_release_hook(pid, fn -> Process.exit(pid, :shutdown) end)
end
end
end
3 changes: 2 additions & 1 deletion lib/kino/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ defmodule Kino.Application do

def start(_type, _args) do
children = [
{DynamicSupervisor, strategy: :one_for_one, name: Kino.WidgetSupervisor}
{DynamicSupervisor, strategy: :one_for_one, name: Kino.WidgetSupervisor},
Kino.SubscriptionManager
]

opts = [strategy: :one_for_one, name: Kino.Supervisor]
Expand Down
24 changes: 24 additions & 0 deletions lib/kino/bridge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ defmodule Kino.Bridge do
with {:ok, reply} <- io_request({:livebook_get_input_value, input_id}), do: reply
end

@doc """
Adds the calling process as pointer to the given object.
"""
@spec object_add_pointer(term()) :: :ok | {:error, atom()}
def object_add_pointer(object_id) do
case io_request({:livebook_object_add_pointer, object_id, self()}) do
{:ok, :ok} -> :ok
{:error, error} -> {:error, error}
end
end

@doc """
Adds a hook to be executed on object release.
"""
@spec object_add_release_hook(term(), (() -> any())) :: :ok | {:error, atom()}
def object_add_release_hook(object_id, hook) do
case io_request({:livebook_object_add_release_hook, object_id, hook}) do
{:ok, :ok} -> :ok
{:error, error} -> {:error, error}
end
end

defp io_request(request) do
gl = Process.group_leader()
ref = Process.monitor(gl)
Expand All @@ -46,6 +68,8 @@ defmodule Kino.Bridge do

result =
receive do
{:io_reply, ^ref, {:error, {:request, _}}} -> {:error, :unsupported}
{:io_reply, ^ref, {:error, :request}} -> {:error, :unsupported}
{:io_reply, ^ref, reply} -> {:ok, reply}
{:DOWN, ^ref, :process, _object, _reason} -> {:error, :terminated}
end
Expand Down
128 changes: 128 additions & 0 deletions lib/kino/control.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
defmodule Kino.Control do
@moduledoc """
Various widgets for user interactions.

Each widget is a UI control element that the user interacts
with, consequenty 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, to receive events from the control, a process needs to
subscribe to it and specify pick a name to distinguish the
events.

Kino.Control.subscribe(button, :hello)
josevalim marked this conversation as resolved.
Show resolved Hide resolved
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved

As the user interacts with the button, the subscribed process
receives corresponding events.

IEx.Helpers.flush()
#=> {:event, :hello, %{origin: #PID<10895.9854.0>}}
#=> {:event, :hello, %{origin: #PID<10895.9854.0>}}
"""

defstruct [:attrs]

@type t :: %__MODULE__{attrs: Kino.Output.control_attrs()}

defp new(attrs) do
ref = make_ref()
subscription_manager = Kino.SubscriptionManager.cross_node_name()

attrs = Map.merge(attrs, %{ref: ref, destination: subscription_manager})

Kino.Bridge.object_add_pointer(ref)

Kino.Bridge.object_add_release_hook(ref, fn ->
Kino.SubscriptionManager.clear_topic(ref)
end)

%__MODULE__{attrs: attrs}
end

@doc """
Creates a new button.
"""
@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.

## Event info

In addition to standard properties, all events inlcude:

* `:type` - either `:keyup` or `:keydown`

* `:key` - the value matching the browser [KeyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key)

## Examples

Create the widget:

keyboard = Kino.Control.keyboard([:keyup, :keydown])

Subscribe to events:

Kino.Control.subscribe(keyboard, :keyboard)

As the user types events are streamed:

IEx.Helpers.flush()
#=> {:event, :keyboard, %{key: "o", origin: #PID<10895.9854.0>, type: :keydown}}
#=> {:event, :keyboard, %{key: "k", origin: #PID<10895.9854.0>, type: :keydown}}
#=> {:event, :keyboard, %{key: "o", origin: #PID<10895.9854.0>, type: :keyup}}
#=> {:event, :keyboard, %{key: "k", origin: #PID<10895.9854.0>, type: :keyup}}
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
"""
@spec keyboard(list(:keyup | :keydown)) :: t()
def keyboard(events) when is_list(events) do
if events == [] do
raise ArgumentError, "expected at least one event, got: []"
end

for event <- events do
unless event in [:keyup, :keydown] do
raise ArgumentError,
"expected event to be either :keyup or :keydown, got: #{inspect(event)}"
end
end

new(%{type: :keyboard, events: events})
end

@doc """
Subscribes the calling process to control events.

The events are sent as `{:event, 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(), term()) :: :ok
def subscribe(control, tag) do
Kino.SubscriptionManager.subscribe(control.attrs.ref, self(), tag)
end

@doc """
Unsubscribes the calling process from control events.
"""
@spec unsubscribe(t()) :: :ok
def unsubscribe(control) do
Kino.SubscriptionManager.unsubscribe(control.attrs.ref, self())
end
end
18 changes: 6 additions & 12 deletions lib/kino/data_table.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ defmodule Kino.DataTable do

@typedoc false
@type state :: %{
parent_monitor_ref: reference(),
data: Enum.t(),
total_rows: non_neg_integer()
total_rows: non_neg_integer(),
keys: list(term()),
sorting_enabled: boolean(),
show_underscored: boolean()
}

@doc """
Expand All @@ -62,21 +64,21 @@ defmodule Kino.DataTable do
def new(data, opts \\ []) do
validate_data!(data)

parent = self()
keys = opts[:keys]
sorting_enabled = Keyword.get(opts, :sorting_enabled, is_list(data))
show_underscored = Keyword.get(opts, :show_underscored, false)

opts = [
data: data,
parent: parent,
keys: keys,
sorting_enabled: sorting_enabled,
show_underscored: show_underscored
]

{:ok, pid} = DynamicSupervisor.start_child(Kino.WidgetSupervisor, {__MODULE__, opts})

Kino.bind_process(pid)

%__MODULE__{pid: pid}
end

Expand Down Expand Up @@ -125,18 +127,14 @@ defmodule Kino.DataTable do
@impl true
def init(opts) do
data = Keyword.fetch!(opts, :data)
parent = Keyword.fetch!(opts, :parent)
keys = Keyword.fetch!(opts, :keys)
sorting_enabled = Keyword.fetch!(opts, :sorting_enabled)
show_underscored = Keyword.fetch!(opts, :show_underscored)

parent_monitor_ref = Process.monitor(parent)

total_rows = Enum.count(data)

{:ok,
%{
parent_monitor_ref: parent_monitor_ref,
data: data,
total_rows: total_rows,
keys: keys,
Expand Down Expand Up @@ -186,10 +184,6 @@ defmodule Kino.DataTable do
{:noreply, state}
end

def handle_info({:DOWN, ref, :process, _object, _reason}, %{parent_monitor_ref: ref} = state) do
{:stop, :shutdown, state}
end

defp get_records(data, rows_spec) do
sorted_data =
if order_by = rows_spec[:order_by] do
Expand Down
15 changes: 4 additions & 11 deletions lib/kino/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ defmodule Kino.Ecto do

@typedoc false
@type state :: %{
parent_monitor_ref: reference(),
repo: Ecto.Repo.t(),
queryable: Ecto.Queryable.t()
}
Expand All @@ -46,11 +45,12 @@ defmodule Kino.Ecto do
"expected a term implementing the Ecto.Queryable protocol, got: #{inspect(queryable)}"
end

parent = self()
opts = [repo: repo, queryable: queryable, parent: parent]
opts = [repo: repo, queryable: queryable]

{:ok, pid} = DynamicSupervisor.start_child(Kino.WidgetSupervisor, {__MODULE__, opts})

Kino.bind_process(pid)

%__MODULE__{pid: pid}
end

Expand All @@ -67,11 +67,8 @@ defmodule Kino.Ecto do
def init(opts) do
repo = Keyword.fetch!(opts, :repo)
queryable = Keyword.fetch!(opts, :queryable)
parent = Keyword.fetch!(opts, :parent)

parent_monitor_ref = Process.monitor(parent)

{:ok, %{parent_monitor_ref: parent_monitor_ref, repo: repo, queryable: queryable}}
{:ok, %{repo: repo, queryable: queryable}}
end

@impl true
Expand Down Expand Up @@ -117,10 +114,6 @@ defmodule Kino.Ecto do
{:noreply, state}
end

def handle_info({:DOWN, ref, :process, _object, _reason}, %{parent_monitor_ref: ref} = state) do
{:stop, :shutdown, state}
end

defp get_records(repo, queryable, rows_spec) do
count = repo.aggregate(queryable, :count)
query = prepare_query(queryable, rows_spec)
Expand Down
15 changes: 4 additions & 11 deletions lib/kino/ets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ defmodule Kino.ETS do

@typedoc false
@type state :: %{
parent_monitor_ref: reference(),
tid: :ets.tid()
}

Expand All @@ -46,11 +45,12 @@ defmodule Kino.ETS do
:ok
end

parent = self()
opts = [tid: tid, parent: parent]
opts = [tid: tid]

{:ok, pid} = DynamicSupervisor.start_child(Kino.WidgetSupervisor, {__MODULE__, opts})

Kino.bind_process(pid)

%__MODULE__{pid: pid}
end

Expand All @@ -66,11 +66,8 @@ defmodule Kino.ETS do
@impl true
def init(opts) do
tid = Keyword.fetch!(opts, :tid)
parent = Keyword.fetch!(opts, :parent)

parent_monitor_ref = Process.monitor(parent)

{:ok, %{parent_monitor_ref: parent_monitor_ref, tid: tid}}
{:ok, %{tid: tid}}
end

@impl true
Expand Down Expand Up @@ -108,10 +105,6 @@ defmodule Kino.ETS do
{:noreply, state}
end

def handle_info({:DOWN, ref, :process, _object, _reason}, %{parent_monitor_ref: ref} = state) do
{:stop, :shutdown, state}
end

defp get_records(tid, rows_spec) do
query = :ets.table(tid)
cursor = :qlc.cursor(query)
Expand Down
Loading