diff --git a/lib/kino.ex b/lib/kino.ex index 8626d7f2..6b4eb0d1 100644 --- a/lib/kino.ex +++ b/lib/kino.ex @@ -90,8 +90,8 @@ defmodule Kino do def inspect(term, opts \\ []) do label = if label = opts[:label], do: "#{label}: ", else: "" - {:text, text} = Kino.Output.inspect(term, opts) - output = {:text, label <> text} + {:terminal_text, text, info} = Kino.Output.inspect(term, opts) + output = {:terminal_text, label <> text, info} Kino.Bridge.put_output(output) term diff --git a/lib/kino/markdown.ex b/lib/kino/markdown.ex index 545c10fe..23a256d0 100644 --- a/lib/kino/markdown.ex +++ b/lib/kino/markdown.ex @@ -29,23 +29,32 @@ defmodule Kino.Markdown do This format may come in handy when exploring Markdown from external sources: - content = File.read!("/path/to/README.md") - Kino.Markdown.new(content) + text = File.read!("/path/to/README.md") + Kino.Markdown.new(text) ''' - @enforce_keys [:content] + @enforce_keys [:text, :chunk] - defstruct [:content] + defstruct [:text, :chunk] @opaque t :: %__MODULE__{ - content: binary() + text: String.t(), + chunk: boolean() } @doc """ Creates a new kino displaying the given Markdown content. + + ## Options + + * `:chunk` - whether this is a part of a larger text. Adjacent chunks + are merged into a single text. This is useful for streaming content. + Defaults to `false` + """ - @spec new(binary()) :: t() - def new(content) do - %__MODULE__{content: content} + @spec new(binary(), keyword()) :: t() + def new(text, opts \\ []) do + opts = Keyword.validate!(opts, chunk: false) + %__MODULE__{text: text, chunk: opts[:chunk]} end end diff --git a/lib/kino/output.ex b/lib/kino/output.ex index 2b147b41..5cc69620 100644 --- a/lib/kino/output.ex +++ b/lib/kino/output.ex @@ -10,8 +10,7 @@ defmodule Kino.Output do """ @type t :: ignored() - | stdout() - | text() + | terminal_text() | plain_text() | markdown() | image() @@ -27,31 +26,30 @@ defmodule Kino.Output do """ @type ignored :: :ignored - @typedoc """ - IO text output, adjacent such outputs are treated as a whole. + @typedoc ~S""" + Terminal text content. - Supports ANSI escape codes. + Supports ANSI escape codes and overwriting lines with `\r`. """ - @type stdout :: {:stdout, binary()} - - @typedoc """ - Standalone text block visually matching `t:stdout/0`. - - Supports ANSI escape codes. - """ - @type text :: {:text, binary()} + @type terminal_text :: {:terminal_text, String.t(), info :: %{chunk: boolean()}} @typedoc """ Plain text content. + Adjacent outputs with `:chunk` set to `true` are merged and rendered + as a whole. + Similar to `t:markdown/0`, but with no special markup. """ - @type plain_text :: {:plain_text, binary()} + @type plain_text :: {:plain_text, String.t(), info :: %{chunk: boolean()}} @typedoc """ Markdown content. + + Adjacent outputs with `:chunk` set to `true` are merged and rendered + as a whole. """ - @type markdown :: {:markdown, binary()} + @type markdown :: {:markdown, String.t(), info :: %{chunk: boolean()}} @typedoc """ A raw image in the given format. @@ -423,27 +421,27 @@ defmodule Kino.Output do @type ref :: String.t() @doc """ - See `t:text/0`. + See `t:terminal_text/0`. """ - @spec text(binary()) :: t() - def text(text) when is_binary(text) do - {:text, text} + @spec terminal_text(binary(), boolean()) :: t() + def terminal_text(text, chunk \\ false) when is_binary(text) do + {:terminal_text, text, %{chunk: chunk}} end @doc """ See `t:plain_text/0`. """ - @spec plain_text(binary()) :: t() - def plain_text(text) do - {:plain_text, text} + @spec plain_text(binary(), boolean()) :: t() + def plain_text(text, chunk \\ false) do + {:plain_text, text, %{chunk: chunk}} end @doc """ See `t:markdown/0`. """ - @spec markdown(binary()) :: t() - def markdown(content) when is_binary(content) do - {:markdown, content} + @spec markdown(binary(), boolean()) :: t() + def markdown(content, chunk \\ false) when is_binary(content) do + {:markdown, content, %{chunk: chunk}} end @doc """ @@ -508,7 +506,7 @@ defmodule Kino.Output do @spec inspect(term(), keyword()) :: t() def inspect(term, opts \\ []) do inspected = Kernel.inspect(term, inspect_opts(opts)) - text(inspected) + terminal_text(inspected) end defp inspect_opts(opts) do diff --git a/lib/kino/render.ex b/lib/kino/render.ex index ba999b16..32b03964 100644 --- a/lib/kino/render.ex +++ b/lib/kino/render.ex @@ -48,17 +48,17 @@ end defimpl Kino.Render, for: Kino.Text do def to_livebook(%{terminal: true} = text) do - Kino.Output.text(text.content) + Kino.Output.terminal_text(text.text, text.chunk) end def to_livebook(text) do - Kino.Output.plain_text(text.content) + Kino.Output.plain_text(text.text, text.chunk) end end defimpl Kino.Render, for: Kino.Markdown do def to_livebook(markdown) do - Kino.Output.markdown(markdown.content) + Kino.Output.markdown(markdown.text, markdown.chunk) end end diff --git a/lib/kino/text.ex b/lib/kino/text.ex index 2ce99054..1ca67453 100644 --- a/lib/kino/text.ex +++ b/lib/kino/text.ex @@ -15,13 +15,14 @@ defmodule Kino.Text do ''' - @enforce_keys [:content] + @enforce_keys [:text] - defstruct [:content, terminal: false] + defstruct [:text, :terminal, :chunk] @opaque t :: %__MODULE__{ - content: binary(), - terminal: boolean() + text: String.t(), + terminal: boolean(), + chunk: boolean() } @doc """ @@ -31,10 +32,15 @@ defmodule Kino.Text do * `:terminal` - whether to render the text as if it were printed to standard output, supporting ANSI escape codes. Defaults to `false` + + * `:chunk` - whether this is a part of a larger text. Adjacent chunks + are merged into a single text. This is useful for streaming content. + Defaults to `false` + """ - @spec new(String.t(), opts) :: t() when opts: [terminal: boolean()] - def new(content, opts \\ []) when is_binary(content) do - opts = Keyword.validate!(opts, terminal: false) - %__MODULE__{content: content, terminal: opts[:terminal]} + @spec new(String.t(), opts) :: t() when opts: [terminal: boolean(), chunk: boolean()] + def new(text, opts \\ []) when is_binary(text) do + opts = Keyword.validate!(opts, terminal: false, chunk: false) + %__MODULE__{text: text, terminal: opts[:terminal], chunk: opts[:chunk]} end end diff --git a/test/kino/debug_test.exs b/test/kino/debug_test.exs index 436f2d6b..7f083aea 100644 --- a/test/kino/debug_test.exs +++ b/test/kino/debug_test.exs @@ -33,7 +33,7 @@ defmodule Kino.Debug.Test do {kino, output, _frame_ref} = assert_dbg_pipeline_render() - assert output == {:text, "\e[34m13\e[0m"} + assert output == {:terminal_text, "\e[34m13\e[0m", %{chunk: false}} %{ dbg_line: dbg_line, @@ -76,7 +76,10 @@ defmodule Kino.Debug.Test do "changed" => true }) - assert_output({:frame, [{:text, "\e[34m31\e[0m"}], %{ref: ^frame_ref, type: :replace}}) + assert_output( + {:frame, [{:terminal_text, "\e[34m31\e[0m", %{chunk: false}}], + %{ref: ^frame_ref, type: :replace}} + ) end test "updates result when a pipeline step is moved" do @@ -100,7 +103,10 @@ defmodule Kino.Debug.Test do "changed" => true }) - assert_output({:frame, [{:text, "\e[34m31\e[0m"}], %{ref: ^frame_ref, type: :replace}}) + assert_output( + {:frame, [{:terminal_text, "\e[34m31\e[0m", %{chunk: false}}], + %{ref: ^frame_ref, type: :replace}} + ) end test "handles evaluation error" do @@ -124,7 +130,8 @@ defmodule Kino.Debug.Test do }) assert_output( - {:frame, [{:text, "\e[34m1\e[0m..\e[34m5\e[0m"}], %{ref: ^frame_ref, type: :replace}} + {:frame, [{:terminal_text, "\e[34m1\e[0m..\e[34m5\e[0m", %{chunk: false}}], + %{ref: ^frame_ref, type: :replace}} ) end @@ -163,7 +170,7 @@ defmodule Kino.Debug.Test do {kino, output} = assert_dbg_default_render() - assert output == {:text, "\e[34m15\e[0m"} + assert output == {:terminal_text, "\e[34m15\e[0m", %{chunk: false}} %{ dbg_line: dbg_line, diff --git a/test/kino/frame_test.exs b/test/kino/frame_test.exs index 67ed381b..e13e316a 100644 --- a/test/kino/frame_test.exs +++ b/test/kino/frame_test.exs @@ -5,17 +5,24 @@ defmodule Kino.FrameTest do frame = Kino.Frame.new() Kino.Frame.render(frame, 1) - assert_output({:frame, [{:text, "\e[34m1\e[0m"}], %{type: :replace}}) + + assert_output( + {:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], %{type: :replace}} + ) Kino.Frame.render(frame, Kino.Markdown.new("_hey_")) - assert_output({:frame, [{:markdown, "_hey_"}], %{type: :replace}}) + assert_output({:frame, [{:markdown, "_hey_", %{chunk: false}}], %{type: :replace}}) end test "render/2 sends output to a specific client when the :to is given" do frame = Kino.Frame.new() Kino.Frame.render(frame, 1, to: "client1") - assert_output_to("client1", {:frame, [{:text, "\e[34m1\e[0m"}], %{type: :replace}}) + + assert_output_to( + "client1", + {:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], %{type: :replace}} + ) assert Kino.Frame.get_outputs(frame) == [] end @@ -24,7 +31,10 @@ defmodule Kino.FrameTest do frame = Kino.Frame.new() Kino.Frame.render(frame, 1, temporary: true) - assert_output_to_clients({:frame, [{:text, "\e[34m1\e[0m"}], %{type: :replace}}) + + assert_output_to_clients( + {:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], %{type: :replace}} + ) assert Kino.Frame.get_outputs(frame) == [] end @@ -65,6 +75,6 @@ defmodule Kino.FrameTest do frame = Kino.Frame.new() Kino.Frame.append(frame, 1) - assert_output({:frame, [{:text, "\e[34m1\e[0m"}], %{type: :append}}) + assert_output({:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], %{type: :append}}) end end diff --git a/test/kino/output_test.exs b/test/kino/output_test.exs index c79b272d..c45866c4 100644 --- a/test/kino/output_test.exs +++ b/test/kino/output_test.exs @@ -6,7 +6,7 @@ defmodule Kino.OutputTest do Kino.Config.configure(inspect: [limit: 1, syntax_colors: []]) list = Enum.to_list(1..100) - assert Kino.Output.inspect(list) == {:text, "[1, ...]"} + assert Kino.Output.inspect(list) == {:terminal_text, "[1, ...]", %{chunk: false}} Application.delete_env(:kino, :inspect) end diff --git a/test/kino/text_test.exs b/test/kino/text_test.exs index 0016c1c7..dca15ab7 100644 --- a/test/kino/text_test.exs +++ b/test/kino/text_test.exs @@ -4,15 +4,15 @@ defmodule Kino.TextTest do describe "new/1" do test "outputs plain text" do "Hello!" |> Kino.Text.new() |> Kino.render() - assert_output({:plain_text, "Hello!"}) + assert_output({:plain_text, "Hello!", %{chunk: false}}) "Hello!" |> Kino.Text.new(terminal: false) |> Kino.render() - assert_output({:plain_text, "Hello!"}) + assert_output({:plain_text, "Hello!", %{chunk: false}}) end test "outputs terminal text" do "Hello!" |> Kino.Text.new(terminal: true) |> Kino.render() - assert_output({:text, "Hello!"}) + assert_output({:terminal_text, "Hello!", %{chunk: false}}) end end end diff --git a/test/kino_test.exs b/test/kino_test.exs index afb287de..525ac691 100644 --- a/test/kino_test.exs +++ b/test/kino_test.exs @@ -9,14 +9,15 @@ defmodule KinoTest do spawn(fn -> assert_receive {:io_request, from, ref, {:livebook_put_output, output}} send(from, {:io_reply, ref, :ok}) - - assert {:text, "\e[34m:hey\e[0m"} = output + send(from, {:output, output}) end) Process.group_leader(self(), gl) Kino.inspect(:hey) + assert_receive {:output, {:terminal_text, "\e[34m:hey\e[0m", %{chunk: false}}} + await_process(gl) end end @@ -28,8 +29,16 @@ defmodule KinoTest do |> Kino.animate(fn i -> i end) assert_output({:frame, [], %{ref: ref, type: :default}}) - assert_output({:frame, [{:text, "\e[34m0\e[0m"}], %{ref: ^ref, type: :replace}}) - assert_output({:frame, [{:text, "\e[34m1\e[0m"}], %{ref: ^ref, type: :replace}}) + + assert_output( + {:frame, [{:terminal_text, "\e[34m0\e[0m", %{chunk: false}}], + %{ref: ^ref, type: :replace}} + ) + + assert_output( + {:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], + %{ref: ^ref, type: :replace}} + ) end test "ignores failures" do @@ -43,7 +52,11 @@ defmodule KinoTest do end) assert_output({:frame, [], %{ref: ref, type: :default}}) - assert_output({:frame, [{:text, "\e[34m1\e[0m"}], %{ref: ^ref, type: :replace}}) + + assert_output( + {:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], + %{ref: ^ref, type: :replace}} + ) end) assert log =~ "Kino.animate" @@ -60,8 +73,16 @@ defmodule KinoTest do end) assert_output({:frame, [], %{ref: ref, type: :default}}) - assert_output({:frame, [{:text, "\e[34m0\e[0m"}], %{ref: ^ref, type: :replace}}) - assert_output({:frame, [{:text, "\e[34m1\e[0m"}], %{ref: ^ref, type: :replace}}) + + assert_output( + {:frame, [{:terminal_text, "\e[34m0\e[0m", %{chunk: false}}], + %{ref: ^ref, type: :replace}} + ) + + assert_output( + {:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], + %{ref: ^ref, type: :replace}} + ) end test "ignores failures" do @@ -75,8 +96,16 @@ defmodule KinoTest do end) assert_output({:frame, [], %{ref: ref, type: :default}}) - assert_output({:frame, [{:text, "\e[34m1\e[0m"}], %{ref: ^ref, type: :replace}}) - assert_output({:frame, [{:text, "\e[34m4\e[0m"}], %{ref: ^ref, type: :replace}}) + + assert_output( + {:frame, [{:terminal_text, "\e[34m1\e[0m", %{chunk: false}}], + %{ref: ^ref, type: :replace}} + ) + + assert_output( + {:frame, [{:terminal_text, "\e[34m4\e[0m", %{chunk: false}}], + %{ref: ^ref, type: :replace}} + ) end) assert log =~ "Kino.animate"