diff --git a/lib/kino.ex b/lib/kino.ex index a49f419f..4b67df45 100644 --- a/lib/kino.ex +++ b/lib/kino.ex @@ -104,10 +104,10 @@ defmodule Kino do Also see `Kino.animate/3`. - ### Kino.Controls + ### User interactions - `Kino.Controls` is a set of UI elements that allow for user - interactions via message passing. + `Kino.Input` and `Kino.Controls` provide a set of widgets for + entering data and capturing user events. ### All others diff --git a/lib/kino/application.ex b/lib/kino/application.ex index b8b5d860..4ad39add 100644 --- a/lib/kino/application.ex +++ b/lib/kino/application.ex @@ -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] diff --git a/lib/kino/control.ex b/lib/kino/control.ex new file mode 100644 index 00000000..41b7ba07 --- /dev/null +++ b/lib/kino/control.ex @@ -0,0 +1,126 @@ +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) + + 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 + token = Kino.Bridge.generate_token() + id = {token, attrs} |> :erlang.phash2() |> Integer.to_string() + + attrs = + Map.merge(attrs, %{ + id: id, + destination: Kino.SubscriptionManager.cross_node_name() + }) + + %__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}} + """ + @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, receive_as, 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, receive_as) do + Kino.SubscriptionManager.subscribe(control.attrs.id, self(), receive_as) + end + + @doc """ + Unsubscribes the calling process from control events. + """ + @spec unsubscribe(t()) :: :ok + def unsubscribe(control) do + Kino.SubscriptionManager.unsubscribe(control.attrs.id, self()) + end +end diff --git a/lib/kino/controls.ex b/lib/kino/controls.ex deleted file mode 100644 index 8a3d244b..00000000 --- a/lib/kino/controls.ex +++ /dev/null @@ -1,227 +0,0 @@ -defmodule Kino.Controls do - @moduledoc """ - A widget for user interactions. - - This widget consists of a number of UI controls that the user - interacts with, consequenty producing an event stream. - - This widget is often useful paired with `Kino.Frame` for - presenting content that changes upon user interactions. - - ## Examples - - Create the widget with the desired set of controls. - - widget = Kino.Controls.new([ - %{type: :keyboard, events: [:keyup, :keydown]}, - %{type: :button, event: :hello, label: "Hello"} - ]) - - Next, to receive events from those controls, a process just - needs to subscribe to the widget. - - Kino.Controls.subscribe(widget) - - As the user interacts with the controls, the subscribed - process receives corresponding events. - - IEx.Helpers.flush() - #=> {:control_event, %{origin: #PID<10895.7340.0>, type: :hello}} - #=> {:control_event, %{key: "o", origin: #PID<10895.7340.0>, type: :keydown}} - #=> {:control_event, %{key: "o", origin: #PID<10895.7340.0>, type: :keyup}} - #=> {:control_event, %{key: "k", origin: #PID<10895.7340.0>, type: :keydown}} - #=> {:control_event, %{key: "k", origin: #PID<10895.7340.0>, type: :keyup}} - #=> {:control_event, %{origin: #PID<10895.7501.0>, type: :client_join}} - #=> {:control_event, %{origin: #PID<10895.7501.0>, type: :client_leave}} - - ## Events - - As shown above, the events are delivered in a `{:control_event, event}` tuple. - Every event includes the following properties: - - * `:type` - the event type, depending on the control either a predefiend such - as `:keydown` or a custm one - - * `:origin` - a term that identifies the event source, different sources imply - different clients interacting with the controls - - Specific events may include additional properties on top of that. - - ### Keyboard events - - Keyboard events have `:type` set to either `:keydown` or `:keyup`. - - * `:key` - the value matching the browser [KeyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) - - ### Button events - - Button events have custom `:type`, as specified in the control. - - ### Client events - - Client events have `:type` set to either `:client_join` or `:client_leave` and - their `:origin` matches the origin in the specific events. - """ - - @doc false - use GenServer, restart: :temporary - - defstruct [:pid] - - @type t :: %__MODULE__{pid: pid()} - - @typedoc false - @type state :: %{ - parent_monitor_ref: reference(), - controls: list(control()), - client_pids: list(pid()), - subscriber_pids: list(pid()) - } - - @type control :: keyboard_control() | button_control() - @type keyboard_control :: %{type: :keyboard, events: list(:keyup | :keydown)} - @type button_control :: %{type: :button, event: atom(), label: String.t()} - - @doc """ - Starts a widget process with the given controls. - """ - @spec new(list(control())) :: t() - def new(controls) do - validate_controls!(controls) - - parent = self() - opts = [parent: parent, controls: controls] - - {:ok, pid} = DynamicSupervisor.start_child(Kino.WidgetSupervisor, {__MODULE__, opts}) - - %__MODULE__{pid: pid} - end - - defp validate_controls!(controls) do - for control <- controls do - unless valid_control?(control) do - raise ArgumentError, "invalid control specification: #{inspect(control)}" - end - end - - if Enum.count(controls, &(&1.type == :keyboard)) > 1 do - raise ArgumentError, "controls may include only one :keyboard item" - end - end - - defp valid_control?(%{type: :keyboard, events: events}) do - is_list(events) and events != [] and Enum.all?(events, &(&1 in [:keyup, :keydown])) - end - - defp valid_control?(%{type: :button, event: event, label: label}) do - is_atom(event) and is_binary(label) - end - - defp valid_control?(_), do: false - - @doc false - def start_link(opts) do - GenServer.start_link(__MODULE__, opts) - end - - @doc """ - Subscribes to control events. - """ - @spec subscribe(t()) :: :ok - def subscribe(widget) do - GenServer.cast(widget.pid, {:subscribe, self()}) - end - - @doc """ - Unsubscribes from control events. - """ - @spec unsubscribe(t()) :: :ok - def unsubscribe(widget) do - GenServer.cast(widget.pid, {:unsubscribe, self()}) - end - - @impl true - def init(opts) do - parent = Keyword.fetch!(opts, :parent) - controls = Keyword.fetch!(opts, :controls) - - parent_monitor_ref = Process.monitor(parent) - - {:ok, - %{ - parent_monitor_ref: parent_monitor_ref, - controls: controls, - client_pids: [], - subscriber_pids: [] - }} - end - - @impl true - def handle_cast({:subscribe, pid}, state) do - {:noreply, add_subscriber(state, pid)} - end - - def handle_cast({:unsubscribe, pid}, state) do - {:noreply, remove_subscriber(state, pid)} - end - - @impl true - def handle_info({:connect, pid}, state) do - Process.monitor(pid) - - send(pid, {:connect_reply, %{controls: state.controls}}) - - {:noreply, handle_client_join(state, pid)} - end - - def handle_info({:event, event}, state) do - broadcast_event(state, event) - - {:noreply, state} - end - - def handle_info({:DOWN, ref, :process, _object, _reason}, %{parent_monitor_ref: ref} = state) do - {:stop, :shutdown, state} - end - - def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do - state = - cond do - pid in state.client_pids -> handle_client_leave(state, pid) - pid in state.subscriber_pids -> remove_subscriber(state, pid) - true -> state - end - - {:noreply, state} - end - - defp add_subscriber(state, pid) do - update_in(state.subscriber_pids, fn pids -> - if pid in pids, do: pids, else: [pid | pids] - end) - end - - defp remove_subscriber(state, pid) do - update_in(state.subscriber_pids, &List.delete(&1, pid)) - end - - defp handle_client_join(state, pid) do - event = %{type: :client_join, origin: pid} - broadcast_event(state, event) - - %{state | client_pids: [pid | state.client_pids]} - end - - defp handle_client_leave(state, pid) do - event = %{type: :client_leave, origin: pid} - broadcast_event(state, event) - - %{state | client_pids: List.delete(state.client_pids, pid)} - end - - defp broadcast_event(state, event) do - for pid <- state.subscriber_pids do - send(pid, {:control_event, event}) - end - end -end diff --git a/lib/kino/input.ex b/lib/kino/input.ex index 6a944b95..f46d3dd8 100644 --- a/lib/kino/input.ex +++ b/lib/kino/input.ex @@ -17,12 +17,18 @@ defmodule Kino.Input do defstruct [:attrs] - @type t :: %__MODULE__{attrs: map()} + @type t :: %__MODULE__{attrs: Kino.Output.input_attrs()} defp new(attrs) do token = Kino.Bridge.generate_token() id = {token, attrs} |> :erlang.phash2() |> Integer.to_string() - attrs = Map.put(attrs, :id, id) + + attrs = + Map.merge(attrs, %{ + id: id, + destination: Kino.SubscriptionManager.cross_node_name() + }) + %__MODULE__{attrs: attrs} end @@ -120,16 +126,11 @@ defmodule Kino.Input do def select(label, options, opts \\ []) when is_binary(label) and is_list(options) and is_list(opts) do if options == [] do - raise ArgumentError, "expected at least on option, got: []" + raise ArgumentError, "expected at least one option, got: []" end - options = - options - |> Enum.map(fn {key, val} -> {key, to_string(val)} end) - |> Map.new() - + options = Enum.map(options, fn {key, val} -> {key, to_string(val)} end) values = Enum.map(options, &elem(&1, 0)) - default = Keyword.get_lazy(opts, :default, fn -> hd(values) end) if default not in values do @@ -239,4 +240,24 @@ defmodule Kino.Input do raise "failed to read input value, reason: #{inspect(reason)}" end end + + @doc """ + Subscribes the calling process to input changes. + + The events are sent as `{:event, receive_as, info}`. + + See `Kino.Control.subscribe/2` for more details. + """ + @spec subscribe(t(), term()) :: :ok + def subscribe(input, receive_as) do + Kino.SubscriptionManager.subscribe(input.attrs.id, self(), receive_as) + end + + @doc """ + Unsubscribes the calling process from input events. + """ + @spec unsubscribe(t()) :: :ok + def unsubscribe(input) do + Kino.SubscriptionManager.unsubscribe(input.attrs.id, self()) + end end diff --git a/lib/kino/output.ex b/lib/kino/output.ex index e18daba0..74fc6be9 100644 --- a/lib/kino/output.ex +++ b/lib/kino/output.ex @@ -17,7 +17,7 @@ defmodule Kino.Output do | table_dynamic() | frame_dynamic() | input() - | controls_dynamic() + | control() @typedoc """ An empty output that should be ignored whenever encountered. @@ -154,7 +154,7 @@ defmodule Kino.Output do All inputs have the following properties: - * `:type` - one of the recognisd input types + * `:type` - one of the recognised input types * `:id` - a unique input identifier, in Livebook must be reevaluation-safe @@ -163,6 +163,8 @@ defmodule Kino.Output do * `:default` - the initial input value + * `:destination` - the process to send event messages to + On top of that, each input type may have additional attributes. """ @type input :: {:input, attrs :: input_attrs()} @@ -174,50 +176,58 @@ defmodule Kino.Output do type: :text, id: input_id(), label: String.t(), - default: String.t() + default: String.t(), + destination: Process.dest() } | %{ type: :textarea, id: input_id(), label: String.t(), - default: String.t() + default: String.t(), + destination: Process.dest() } | %{ type: :password, id: input_id(), label: String.t(), - default: String.t() + default: String.t(), + destination: Process.dest() } | %{ type: :number, id: input_id(), label: String.t(), - default: number() | nil + default: number() | nil, + destination: Process.dest() } | %{ type: :url, id: input_id(), label: String.t(), - default: String.t() | nil + default: String.t() | nil, + destination: Process.dest() } | %{ type: :select, id: input_id(), label: String.t(), default: term(), + destination: Process.dest(), options: list({value :: term(), label :: String.t()}) } | %{ type: :checkbox, id: input_id(), label: String.t(), - default: boolean() + default: boolean(), + destination: Process.dest() } | %{ type: :range, id: input_id(), label: String.t(), default: number(), + destination: Process.dest(), min: number(), max: number(), step: number() @@ -226,33 +236,47 @@ defmodule Kino.Output do type: :color, id: input_id(), label: String.t(), - default: String.t() + default: String.t(), + destination: Process.dest() } @typedoc """ - Controls output. + A control widget. - This output points to a server process that clients can talk to. + All controls have the following properties: - ## Communication protocol + * `:type` - one of the recognised control types - A client process should connect to the server process by sending: + * `:id` - a unique control identifier - {:connect, pid()} + * `:destination` - the process to send event messages to - And expect the following reply: + On top of that, each control type may have additional attributes. - {:connect_reply, %{controls: list(Kino.Control.control())}} + ## Events - The client can then keep sending events, with `:type` identifying - the event kind, `:origin` being the client pid and optionally - additional properties specific to the given event. + All control events are sent to `:destination` as `{:event, id, info}`, + where info is a map including additional details. In particular, it + always includes `:origin`, which is an opaque identifier of the client + that triggered the event. + """ + @type control :: {:control, attrs :: control_attrs()} - @type event :: %{type: atom(), origin: pid(), optional(atom()) => any()} + @type control_id :: String.t() - {:event, event()} - """ - @type controls_dynamic :: {:controls_dynamic, pid()} + @type control_attrs :: + %{ + type: :keyboard, + id: control_id(), + destination: Process.dest(), + events: list(:keyup | :keydown) + } + | %{ + type: :button, + id: control_id(), + destination: Process.dest(), + label: String.t() + } @doc """ See `t:text_inline/0`. @@ -327,11 +351,11 @@ defmodule Kino.Output do end @doc """ - See `t:controls_dynamic/0`. + See `t:control/0`. """ - @spec controls_dynamic(pid()) :: t() - def controls_dynamic(pid) when is_pid(pid) do - {:controls_dynamic, pid} + @spec control(control_attrs()) :: t() + def control(attrs) when is_map(attrs) do + {:control, attrs} end @doc """ diff --git a/lib/kino/render.ex b/lib/kino/render.ex index 0b0b78c2..f819e49f 100644 --- a/lib/kino/render.ex +++ b/lib/kino/render.ex @@ -68,9 +68,9 @@ defimpl Kino.Render, for: Kino.Input do end end -defimpl Kino.Render, for: Kino.Controls do - def to_livebook(widget) do - Kino.Output.controls_dynamic(widget.pid) +defimpl Kino.Render, for: Kino.Control do + def to_livebook(control) do + Kino.Output.control(control.attrs) end end diff --git a/lib/kino/subscription_manager.ex b/lib/kino/subscription_manager.ex new file mode 100644 index 00000000..e4a7dde1 --- /dev/null +++ b/lib/kino/subscription_manager.ex @@ -0,0 +1,95 @@ +defmodule Kino.SubscriptionManager do + @moduledoc false + + # The primary process responsible for subscribing to + # and broadcasting input/control events. + + use GenServer + + @name __MODULE__ + + @type state :: %{ + subscribers_by_topic: %{(topic :: term()) => {teraget :: pid(), receive_as :: term()}} + } + + def cross_node_name() do + {@name, node()} + end + + @doc """ + Starts the manager + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: @name) + end + + @doc """ + Subscribes the given process to events under `topic`. + + All events are sent as `{:event, receive_as, info}`, + where `receive_as` is the given term. + """ + @spec subscribe(term(), pid(), term()) :: :ok + def subscribe(topic, pid, receive_as) do + GenServer.cast(@name, {:subscribe, topic, pid, receive_as}) + end + + @doc """ + Unsubscribes the given process from events under `topic`. + """ + @spec unsubscribe(term(), pid()) :: :ok + def unsubscribe(topic, pid) do + GenServer.cast(@name, {:unsubscribe, topic, pid}) + end + + @impl true + def init(_opts) do + {:ok, %{subscribers_by_topic: %{}}} + end + + @impl true + def handle_cast({:subscribe, topic, pid, receive_as}, state) do + Process.monitor(pid) + + state = + update_in(state.subscribers_by_topic[topic], fn + nil -> [{pid, receive_as}] + subscribers -> [{pid, receive_as} | remove_pid(subscribers, pid)] + end) + + {:noreply, state} + end + + def handle_cast({:unsubscribe, topic, pid}, state) do + state = + update_in(state.subscribers_by_topic[topic], fn + nil -> nil + subscribers -> remove_pid(subscribers, pid) + end) + + {:noreply, state} + end + + @impl true + def handle_info({:event, topic, event}, state) do + for {pid, receive_as} <- state.subscribers_by_topic[topic] || [] do + send(pid, {:event, receive_as, event}) + end + + {:noreply, state} + end + + def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do + subscribers_by_topic = + state.subscribers_by_topic + |> Enum.map(fn {topic, subscribers} -> {topic, remove_pid(subscribers, pid)} end) + |> Enum.filter(&match?({_, []}, &1)) + |> Map.new() + + {:noreply, %{state | subscribers_by_topic: subscribers_by_topic}} + end + + defp remove_pid(subscribers, pid) do + Enum.reject(subscribers, &match?({^pid, _receive_as}, &1)) + end +end diff --git a/test/kino/control_test.exs b/test/kino/control_test.exs new file mode 100644 index 00000000..a5a5d07d --- /dev/null +++ b/test/kino/control_test.exs @@ -0,0 +1,30 @@ +defmodule Kino.ControlTest do + use ExUnit.Case, async: true + + describe "keyboard/1" do + test "raises an error for empty option list" do + assert_raise ArgumentError, "expected at least one event, got: []", fn -> + Kino.Control.keyboard([]) + end + end + + test "raises an error when an invalid event is given" do + assert_raise ArgumentError, "expected event to be either :keyup or :keydown, got: :keyword", fn -> + Kino.Control.keyboard([:keyword]) + end + end + end + + describe "subscribe/2" do + test "subscribes to control events" do + button = Kino.Control.button("Name") + + Kino.Control.subscribe(button, :name) + + info = %{origin: self()} + send(button.attrs.destination, {:event, button.attrs.id, info}) + + assert_receive {:event, :name, ^info} + end + end +end diff --git a/test/kino/controls_test.exs b/test/kino/controls_test.exs deleted file mode 100644 index 73995201..00000000 --- a/test/kino/controls_test.exs +++ /dev/null @@ -1,89 +0,0 @@ -defmodule Kino.ControlsTest do - use ExUnit.Case, async: true - - describe "new/1" do - test "raises an error when control misses a required key" do - assert_raise ArgumentError, - "invalid control specification: %{type: :button}", - fn -> - Kino.Controls.new([%{type: :button}]) - end - end - - test "raises an error on multiple keyboard controls" do - assert_raise ArgumentError, - "controls may include only one :keyboard item", - fn -> - Kino.Controls.new([ - %{type: :keyboard, events: [:keyup]}, - %{type: :keyboard, events: [:keydown]} - ]) - end - end - end - - @controls [ - %{type: :keyboard, events: [:keyup, :keydown]}, - %{type: :button, event: :hello, label: "Hello"} - ] - - describe "connecting" do - test "connect reply contains the defined controls" do - widget = Kino.Controls.new(@controls) - - send(widget.pid, {:connect, self()}) - - assert_receive {:connect_reply, %{controls: @controls}} - end - end - - describe "subscribe/1" do - test "subscriber receives client join and leave" do - widget = Kino.Controls.new(@controls) - - Kino.Controls.subscribe(widget) - - client_pid = - spawn(fn -> - connect_self(widget) - end) - - assert_receive {:control_event, %{type: :client_join, origin: ^client_pid}} - assert_receive {:control_event, %{type: :client_leave, origin: ^client_pid}} - end - - test "subscriber receives client events" do - widget = Kino.Controls.new(@controls) - - Kino.Controls.subscribe(widget) - - client_pid = - spawn(fn -> - connect_self(widget) - send(widget.pid, {:event, %{type: :keydown, origin: self(), key: "u"}}) - end) - - assert_receive {:control_event, %{type: :keydown, origin: ^client_pid, key: "u"}} - end - end - - describe "unsubscribe/1" do - test "cancells subscription" do - widget = Kino.Controls.new(@controls) - - Kino.Controls.subscribe(widget) - Kino.Controls.unsubscribe(widget) - - spawn(fn -> - connect_self(widget) - end) - - refute_receive {:control_event, _} - end - end - - defp connect_self(widget) do - send(widget.pid, {:connect, self()}) - assert_receive {:connect_reply, %{}} - end -end diff --git a/test/kino/input_test.exs b/test/kino/input_test.exs index 8ddd0ab2..5da1a5d5 100644 --- a/test/kino/input_test.exs +++ b/test/kino/input_test.exs @@ -3,7 +3,7 @@ defmodule Kino.InputTest do describe "select/3" do test "raises an error for empty option list" do - assert_raise ArgumentError, "expected at least on option, got: []", fn -> + assert_raise ArgumentError, "expected at least one option, got: []", fn -> Kino.Input.select("Language", []) end end @@ -34,4 +34,17 @@ defmodule Kino.InputTest do end end end + + describe "subscribe/2" do + test "subscribes to input events" do + input = Kino.Input.text("Name") + + Kino.Input.subscribe(input, :name) + + info = %{origin: self(), value: "Jake"} + send(input.attrs.destination, {:event, input.attrs.id, info}) + + assert_receive {:event, :name, ^info} + end + end end