Repository: livebook-dev/kino
Pull request #291 (2023-06-21 20:58:52)

Add datetime, time and date inputs

jannikbecher: livebook-dev/livebook/pull/2002

Patch:
diff --git a/lib/kino/control.ex b/lib/kino/control.ex
index ffe4d34..8f83450 100644
--- a/lib/kino/control.ex
+++ b/lib/kino/control.ex
@@ -36,7 +36,7 @@ defmodule Kino.Control do
 
   @opaque interval :: {:interval, milliseconds :: non_neg_integer()}
 
-  @type event_source :: t() | Kino.Input.t() | interval()
+  @type event_source :: t() | Kino.Input.t() | interval() | Kino.JS.Live.t()
 
   defp new(attrs) do
     ref = Kino.Output.random_ref()
@@ -279,9 +279,13 @@ defmodule Kino.Control do
   It accepts a single source or a list of sources, where each
   source is either of:
 
-    * `Kino.Control` - emitting value on relevant interaction
+    * `%Kino.Control{}` - emitting value on relevant interaction
 
-    * `Kino.Input` - emitting value on value change
+    * `%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`.
@@ -305,12 +309,25 @@ defmodule Kino.Control do
   def stream(source)
 
   def stream(sources) when is_list(sources) do
-    for source <- sources, do: assert_stream_source!(source)
+    {tagged_topics, tagged_intervals} =
+      for source <- sources, reduce: {[], []} do
+        {tagged_topics, tagged_intervals} ->
+          assert_stream_source!(source)
+
+          case source do
+            %struct{attrs: %{ref: ref}} when struct in [Kino.Control, Kino.Input] ->
+              {[{nil, ref} | tagged_topics], tagged_intervals}
 
-    tagged_topics = for %{attrs: %{ref: ref}} <- sources, do: {nil, ref}
-    tagged_intervals = for {:interval, ms} <- sources, do: {nil, ms}
+            %Kino.JS.Live{ref: ref} ->
+              {[{nil, ref} | tagged_topics], tagged_intervals}
 
-    build_stream(tagged_topics, tagged_intervals, fn nil, event -> event end)
+            {: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
@@ -335,29 +352,42 @@ defmodule Kino.Control do
   """
   @spec tagged_stream(keyword(event_source())) :: Enumerable.t()
   def tagged_stream(entries) when is_list(entries) do
-    for entry <- entries do
-      case entry do
-        {tag, source} when is_atom(tag) ->
-          assert_stream_source!(source)
+    {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
-    end
+            _other ->
+              raise ArgumentError, "expected a keyword list, got: #{inspect(entries)}"
+          end
+
+          {tag, source} = entry
 
-    tagged_topics = for {tag, %{attrs: %{ref: ref}}} <- entries, do: {tag, ref}
-    tagged_intervals = for {tag, {:interval, ms}} <- entries, do: {tag, ms}
+          case source do
+            %struct{attrs: %{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, tagged_intervals, fn tag, event -> {tag, event} 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{} or {:interval, ms}, got: #{inspect(item)}"
+          "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
diff --git a/lib/kino/download.ex b/lib/kino/download.ex
index 6f6943e..300dbe0 100644
--- a/lib/kino/download.ex
+++ b/lib/kino/download.ex
@@ -41,7 +41,7 @@ defmodule Kino.Download do
       if present and `"Download"` otherwise
 
   """
-  @spec new((() -> binary()), keyword()) :: t()
+  @spec new((-> binary()), keyword()) :: t()
   def new(content_fun, opts \\ []) do
     opts = Keyword.validate!(opts, [:filename, :label])
     filename = opts[:filename] || "download"
diff --git a/lib/kino/input.ex b/lib/kino/input.ex
index d2667c0..6d88ef8 100644
--- a/lib/kino/input.ex
+++ b/lib/kino/input.ex
@@ -257,6 +257,163 @@ defmodule Kino.Input do
     })
   end
 
+  @doc """
+  Creates a new datetime input.
+
+  The input is editable in user-local time zone, however the value
+  is always read in UTC as a `%NaiveDateTime{}` struct.
+
+  ## Options
+
+    * `:default` - the initial input value. Defaults to `nil`
+
+    * `:min` - the minimum datetime value (in UTC)
+
+    * `:max` - the maximum datetime value (in UTC)
+  """
+  @spec utc_datetime(String.t(), keyword()) :: t()
+  def utc_datetime(label, opts \\ []) when is_binary(label) and is_list(opts) do
+    min = Keyword.get(opts, :min, nil) |> truncate_datetime()
+    max = Keyword.get(opts, :max, nil) |> truncate_datetime()
+    default = Keyword.get(opts, :default, nil) |> truncate_datetime()
+
+    if min && max && min > max do
+      raise ArgumentError,
+            "expected :min to be less than :max, got: #{inspect(min)} and #{inspect(max)}"
+    end
+
+    assert_default_value!(
+      default,
+      "be %NaiveDateTime{} or nil",
+      &(is_struct(&1, NaiveDateTime) or &1 == nil)
+    )
+
+    if min && default && default <= min do
+      raise ArgumentError,
+            "expected :default to be bigger than :min, got: #{inspect(default)} #{inspect(min)}"
+    end
+
+    if max && default && default > max do
+      raise ArgumentError,
+            "expected :default to be smaller than :max, got: #{inspect(default)}"
+    end
+
+    new(%{
+      type: :utc_datetime,
+      label: label,
+      default: default,
+      min: min,
+      max: max
+    })
+  end
+
+  defp truncate_datetime(nil), do: nil
+
+  defp truncate_datetime(datetime) do
+    datetime
+    |> NaiveDateTime.truncate(:second)
+    |> Map.replace!(:second, 0)
+  end
+
+  @doc """
+  Creates a new time input.
+
+  The input is editable in user-local time zone, however the value
+  is always read in UTC as a `%Time{}` struct.
+
+  ## Options
+
+    * `:default` - the initial input value. Defaults to `nil`
+
+    * `:min` - the minimum time value (in UTC)
+
+    * `:max` - the maximum time value (in UTC)
+  """
+  @spec utc_time(String.t(), keyword()) :: t()
+  def utc_time(label, opts \\ []) when is_binary(label) and is_list(opts) do
+    min = Keyword.get(opts, :min, nil) |> truncate_time()
+    max = Keyword.get(opts, :max, nil) |> truncate_time()
+    default = Keyword.get(opts, :default, nil) |> truncate_time()
+
+    if min && max && min > max do
+      raise ArgumentError,
+            "expected :min to be less than :max, got: #{inspect(min)} and #{inspect(max)}"
+    end
+
+    assert_default_value!(default, "be %Time{} or nil", &(is_struct(&1, Time) or &1 == nil))
+
+    if min && default && default <= min do
+      raise ArgumentError,
+            "expected :default to be bigger than :min, got: #{inspect(default)} #{inspect(min)}"
+    end
+
+    if max && default && default > max do
+      raise ArgumentError,
+            "expected :default to be smaller than :max, got: #{inspect(default)}"
+    end
+
+    new(%{
+      type: :utc_time,
+      label: label,
+      default: default,
+      min: min,
+      max: max
+    })
+  end
+
+  defp truncate_time(nil), do: nil
+
+  defp truncate_time(time) do
+    time
+    |> Time.truncate(:second)
+    |> Map.replace!(:second, 0)
+  end
+
+  @doc """
+  Creates a new date input.
+
+  The input is read as a `%Date{}` struct.
+
+  ## Options
+
+    * `:default` - the initial input value. Defaults to `nil`
+
+    * `:min` - the minimum date value
+
+    * `:max` - the maximum date value
+  """
+  @spec date(String.t(), keyword()) :: t()
+  def date(label, opts \\ []) when is_binary(label) and is_list(opts) do
+    min = Keyword.get(opts, :min, nil)
+    max = Keyword.get(opts, :max, nil)
+    default = Keyword.get(opts, :default, nil)
+
+    if min && max && min > max do
+      raise ArgumentError,
+            "expected :min to be less than :max, got: #{inspect(min)} and #{inspect(max)}"
+    end
+
+    assert_default_value!(default, "be %Date{} or nil", &(is_struct(&1, Date) or &1 == nil))
+
+    if min && default && default <= min do
+      raise ArgumentError,
+            "expected :default to be bigger than :min, got: #{inspect(default)}"
+    end
+
+    if max && default && default >= max do
+      raise ArgumentError,
+            "expected :default to be smaller than :max, got: #{inspect(default)}"
+    end
+
+    new(%{
+      type: :date,
+      label: label,
+      default: default,
+      min: min,
+      max: max
+    })
+  end
+
   @doc """
   Creates a new color input.
 
diff --git a/lib/kino/js/live.ex b/lib/kino/js/live.ex
index aad6916..dcf2e21 100644
--- a/lib/kino/js/live.ex
+++ b/lib/kino/js/live.ex
@@ -294,7 +294,8 @@ defmodule Kino.JS.Live do
     quote location: :keep do
       @behaviour Kino.JS.Live
 
-      import Kino.JS.Live.Context, only: [assign: 2, update: 3, broadcast_event: 3, send_event: 4]
+      import Kino.JS.Live.Context,
+        only: [assign: 2, update: 3, broadcast_event: 3, send_event: 4, emit_event: 2]
 
       @before_compile Kino.JS.Live
     end
@@ -328,6 +329,8 @@ defmodule Kino.JS.Live do
 
     case Kino.start_child({Kino.JS.Live.Server, {module, init_arg, ref}}) do
       {:ok, pid} ->
+        subscription_manager = Kino.SubscriptionManager.cross_node_name()
+        Kino.Bridge.monitor_object(pid, subscription_manager, {:clear_topic, ref})
         %__MODULE__{module: module, pid: pid, ref: ref}
 
       {:error, reason} ->
@@ -394,3 +397,10 @@ defmodule Kino.JS.Live do
     Process.monitor(kino.pid)
   end
 end
+
+defimpl Enumerable, for: Kino.JS.Live do
+  def reduce(kino, acc, fun), do: Enumerable.reduce(Kino.Control.stream([kino]), acc, fun)
+  def member?(_kino, _value), do: {:error, __MODULE__}
+  def count(_kino), do: {:error, __MODULE__}
+  def slice(_kino), do: {:error, __MODULE__}
+end
diff --git a/lib/kino/js/live/context.ex b/lib/kino/js/live/context.ex
index 8a63aaf..d6f74e0 100644
--- a/lib/kino/js/live/context.ex
+++ b/lib/kino/js/live/context.ex
@@ -81,4 +81,21 @@ defmodule Kino.JS.Live.Context do
   def send_event(%__MODULE__{} = ctx, client_id, event, payload \\ nil) when is_binary(event) do
     Kino.JS.Live.Server.send_event(ctx, client_id, event, payload)
   end
+
+  @doc """
+  Emits an event to processes subscribed to this kino.
+
+  Consumers may subscribe to events emitted by the given instance of
+  `Kino.JS.Live` using functions in the `Kino.Control` module, such
+  as `Kino.Control.stream/1`.
+
+  ## Examples
+
+      emit_event(ctx, %{event: :click, counter: 1})
+
+  """
+  @spec emit_event(t(), term()) :: :ok
+  def emit_event(%__MODULE__{} = ctx, event) do
+    Kino.JS.Live.Server.emit_event(ctx, event)
+  end
 end
diff --git a/lib/kino/js/live/server.ex b/lib/kino/js/live/server.ex
index f05efbd..67ee8b1 100644
--- a/lib/kino/js/live/server.ex
+++ b/lib/kino/js/live/server.ex
@@ -35,6 +35,11 @@ defmodule Kino.JS.Live.Server do
     :ok
   end
 
+  def emit_event(ctx, event) do
+    send(Kino.SubscriptionManager, {:event, ctx.__private__.ref, event})
+    :ok
+  end
+
   @impl true
   def init({module, init_arg, ref}) do
     {:ok, ctx, _opts} = call_init(module, init_arg, ref)
diff --git a/lib/kino/output.ex b/lib/kino/output.ex
index e9dce37..d1758ee 100644
--- a/lib/kino/output.ex
+++ b/lib/kino/output.ex
@@ -297,6 +297,36 @@ defmodule Kino.Output do
               max: number(),
               step: number()
             }
+          | %{
+              type: :utc_datetime,
+              ref: ref(),
+              id: input_id(),
+              label: String.t(),
+              default: NaiveDateTime.t() | nil,
+              destination: Process.dest(),
+              min: NaiveDateTime.t() | nil,
+              max: NaiveDateTime.t() | nil
+            }
+          | %{
+              type: :utc_time,
+              ref: ref(),
+              id: input_id(),
+              label: String.t(),
+              default: Time.t() | nil,
+              destination: Process.dest(),
+              min: Time.t() | nil,
+              max: Time.t() | nil
+            }
+          | %{
+              type: :date,
+              ref: ref(),
+              id: input_id(),
+              label: String.t(),
+              default: Date.t(),
+              destination: Process.dest(),
+              min: Date.t(),
+              max: Date.t()
+            }
           | %{
               type: :color,
               ref: ref(),
diff --git a/lib/kino/process.ex b/lib/kino/process.ex
index 0d976c5..661e144 100644
--- a/lib/kino/process.ex
+++ b/lib/kino/process.ex
@@ -169,7 +169,7 @@ defmodule Kino.Process do
   block in order to render the visual. In addition, this function returns the result
   from the provided trace function.
   """
-  @spec render_seq_trace(trace_target(), (() -> any())) :: any()
+  @spec render_seq_trace(trace_target(), (-> any())) :: any()
   def render_seq_trace(trace_target \\ :all, trace_function) do
     {func_result, sequence_diagram} = seq_trace(trace_target, trace_function)
     Kino.render(sequence_diagram)
@@ -245,7 +245,7 @@ defmodule Kino.Process do
         Agent.stop(agent_pid)
       end)
   """
-  @spec seq_trace(trace_target(), (() -> any())) :: {any(), Mermaid.t()}
+  @spec seq_trace(trace_target(), (-> any())) :: {any(), Mermaid.t()}
   def seq_trace(trace_target \\ :all, trace_function)
 
   def seq_trace(pid, trace_function) when is_pid(pid) do
diff --git a/lib/kino/tree.ex b/lib/kino/tree.ex
index af3ebe9..a8fe1b5 100644
--- a/lib/kino/tree.ex
+++ b/lib/kino/tree.ex
@@ -126,7 +126,7 @@ defmodule Kino.Tree do
 
   defp to_node(map, suffix) when is_map(map) do
     size = map_size(map)
-    children = to_key_value_children(map, size)
+    children = map |> Enum.sort() |> to_key_value_children(size)
 
     %{
       content: [black("%{...}") | suffix],


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index dbe52f6..c099e4a 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -12,8 +12,8 @@ jobs:
       matrix:
         include:
           - pair:
-              elixir: "1.14.0"
-              otp: "25.0"
+              elixir: "1.15.2"
+              otp: "26.0.2"
             lint: true
     steps:
       - uses: actions/checkout@v2
diff --git a/test/kino/control_test.exs b/test/kino/control_test.exs
index 7316334..61eabac 100644
--- a/test/kino/control_test.exs
+++ b/test/kino/control_test.exs
@@ -70,7 +70,7 @@ defmodule Kino.ControlTest do
   describe "stream/1" do
     test "raises on invalid argument" do
       assert_raise ArgumentError,
-                   "expected source to be either %Kino.Control{}, %Kino.Input{} or {:interval, ms}, got: 10",
+                   "expected source to be either %Kino.Control{}, %Kino.Input{}, %Kino.JS.Live{} or {:interval, ms}, got: 10",
                    fn ->
                      Kino.Control.stream(10)
                    end
@@ -79,16 +79,17 @@ defmodule Kino.ControlTest do
     test "returns control event feed" do
       button = Kino.Control.button("Name")
 
-      spawn(fn ->
-        Process.sleep(1)
+      background_tick(fn ->
         info = %{origin: "client1"}
         send(button.attrs.destination, {:event, button.attrs.ref, info})
-        send(button.attrs.destination, {:event, button.attrs.ref, info})
       end)
 
       events = button |> Kino.Control.stream() |> Enum.take(2)
 
       assert events == [%{origin: "client1"}, %{origin: "client1"}]
+
+      # Assert that nothing leaks to the inbox
+      refute_receive _, 2
     end
 
     test "supports interval" do
@@ -99,22 +100,41 @@ defmodule Kino.ControlTest do
     test "halts when the topic is cleared" do
       button = Kino.Control.button("Name")
 
-      spawn(fn ->
-        Process.sleep(1)
+      background_tick(fn ->
         info = %{origin: "client1"}
         send(button.attrs.destination, {:event, button.attrs.ref, info})
-        send(button.attrs.destination, {:clear_topic, button.attrs.ref})
       end)
 
-      events = button |> Kino.Control.stream() |> Enum.to_list()
-      assert events == [%{origin: "client1"}]
+      events =
+        button
+        |> Kino.Control.stream()
+        |> Enum.map(fn event ->
+          send(button.attrs.destination, {:clear_topic, button.attrs.ref})
+          event
+        end)
+
+      assert [%{origin: "client1"} | _] = events
+    end
+
+    test "supports Kino.JS.Live" do
+      kino = Kino.TestModules.LiveCounter.new(0)
+
+      background_tick(fn ->
+        Kino.TestModules.LiveCounter.bump(kino, 1)
+      end)
+
+      events = kino |> Kino.Control.stream() |> Enum.take(2)
+      assert events == [%{event: :bump, by: 1}, %{event: :bump, by: 1}]
+
+      # Assert that nothing leaks to the inbox
+      refute_receive _, 2
     end
   end
 
   describe "stream/1 with a list of sources" do
     test "raises on invalid source" do
       assert_raise ArgumentError,
-                   "expected source to be either %Kino.Control{}, %Kino.Input{} or {:interval, ms}, got: 10",
+                   "expected source to be either %Kino.Control{}, %Kino.Input{}, %Kino.JS.Live{} or {:interval, ms}, got: 10",
                    fn ->
                      Kino.Control.stream([10])
                    end
@@ -124,16 +144,14 @@ defmodule Kino.ControlTest do
       button = Kino.Control.button("Click")
       input = Kino.Input.text("Name")
 
-      spawn(fn ->
-        Process.sleep(1)
-        info = %{origin: "client1"}
-        send(button.attrs.destination, {:event, button.attrs.ref, info})
-        send(button.attrs.destination, {:event, input.attrs.ref, info})
+      background_tick(fn ->
+        send(button.attrs.destination, {:event, button.attrs.ref, %{origin: "client1"}})
+        send(button.attrs.destination, {:event, input.attrs.ref, %{origin: "client2"}})
       end)
 
       events = [button, input] |> Kino.Control.stream() |> Enum.take(2)
 
-      assert events == [%{origin: "client1"}, %{origin: "client1"}]
+      assert Enum.sort(events) == [%{origin: "client1"}, %{origin: "client2"}]
     end
   end
 
@@ -144,7 +162,7 @@ defmodule Kino.ControlTest do
       end
 
       assert_raise ArgumentError,
-                   "expected source to be either %Kino.Control{}, %Kino.Input{} or {:interval, ms}, got: 10",
+                   "expected source to be either %Kino.Control{}, %Kino.Input{}, %Kino.JS.Live{} or {:interval, ms}, got: 10",
                    fn ->
                      Kino.Control.tagged_stream(name: 10)
                    end
@@ -154,11 +172,9 @@ defmodule Kino.ControlTest do
       button = Kino.Control.button("Click")
       input = Kino.Input.text("Name")
 
-      spawn(fn ->
-        Process.sleep(1)
-        info = %{origin: "client1"}
-        send(button.attrs.destination, {:event, button.attrs.ref, info})
-        send(input.attrs.destination, {:event, input.attrs.ref, info})
+      background_tick(fn ->
+        send(button.attrs.destination, {:event, button.attrs.ref, %{origin: "client1"}})
+        send(input.attrs.destination, {:event, input.attrs.ref, %{origin: "client2"}})
       end)
 
       events =
@@ -166,7 +182,21 @@ defmodule Kino.ControlTest do
         |> Kino.Control.tagged_stream()
         |> Enum.take(2)
 
-      assert events == [{:click, %{origin: "client1"}}, {:name, %{origin: "client1"}}]
+      assert Enum.sort(events) == [{:click, %{origin: "client1"}}, {:name, %{origin: "client2"}}]
     end
   end
+
+  defp background_tick(fun) do
+    pid =
+      spawn(fn ->
+        for _ <- Stream.cycle([:infinity]) do
+          Process.sleep(1)
+          fun.()
+        end
+      end)
+
+    on_exit(fn -> Process.exit(pid, :kill) end)
+
+    pid
+  end
 end
diff --git a/test/kino/data_table_test.exs b/test/kino/data_table_test.exs
index c81c89d..68386b0 100644
--- a/test/kino/data_table_test.exs
+++ b/test/kino/data_table_test.exs
@@ -210,7 +210,7 @@ defmodule Kino.DataTableTest do
   test "supports sliceable data" do
     entries = %{x: 1..3, y: 1..3}
 
-    kino = Kino.DataTable.new(entries)
+    kino = Kino.DataTable.new(entries, keys: [:x, :y])
     data = connect(kino)
 
     assert %{
@@ -225,7 +225,7 @@ defmodule Kino.DataTableTest do
   test "correctly paginates sliceable data" do
     entries = %{x: 1..30, y: 1..30}
 
-    kino = Kino.DataTable.new(entries)
+    kino = Kino.DataTable.new(entries, keys: [:x, :y])
     data = connect(kino)
 
     assert %{
diff --git a/test/support/test_modules/live_counter.ex b/test/support/test_modules/live_counter.ex
index 04b6b00..64fa49d 100644
--- a/test/support/test_modules/live_counter.ex
+++ b/test/support/test_modules/live_counter.ex
@@ -68,6 +68,7 @@ defmodule Kino.TestModules.LiveCounter do
 
   defp bump_count(ctx, by) do
     broadcast_event(ctx, "bump", %{by: by})
+    emit_event(ctx, %{event: :bump, by: by})
     update(ctx, :count, &(&1 + by))
   end