From 7d1d1f4d98292a6537135159bb05af45a1be3d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Sun, 4 Apr 2021 21:22:28 +0200 Subject: [PATCH] Respect CR in cell output (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Respect CR in cell output * Update test/livebook_web/helpers_test.exs Co-authored-by: José Valim * Improve rewind implementation Co-authored-by: José Valim --- lib/livebook_web/ansi.ex | 15 ++++--- lib/livebook_web/helpers.ex | 40 ++++++++++++++++++- .../live/session_live/cell_component.ex | 22 +--------- test/livebook_web/ansi_test.exs | 17 ++++++-- test/livebook_web/helpers_test.exs | 22 ++++++++++ 5 files changed, 85 insertions(+), 31 deletions(-) create mode 100644 test/livebook_web/helpers_test.exs diff --git a/lib/livebook_web/ansi.ex b/lib/livebook_web/ansi.ex index d97041d48..e449661a6 100644 --- a/lib/livebook_web/ansi.ex +++ b/lib/livebook_web/ansi.ex @@ -37,12 +37,16 @@ defmodule LivebookWeb.ANSI do * `:renderer` - a function used to render styled HTML content. The function receives HTML styles string and HTML-escaped content (iodata). By default the renderer wraps the whole content in a single `` tag with the given style. + Note that the style may be an empty string for plain text. """ @spec ansi_string_to_html(String.t(), keyword()) :: Phoenix.HTML.safe() def ansi_string_to_html(string, opts \\ []) do + renderer = Keyword.get(opts, :renderer, &default_renderer/2) + [head | ansi_prefixed_strings] = String.split(string, "\e[") {:safe, head_html} = Phoenix.HTML.html_escape(head) + head_html = renderer.("", head_html) # Each pair has the form of {modifiers, html_content} {pairs, _} = @@ -63,7 +67,6 @@ defmodule LivebookWeb.ANSI do pairs = Enum.filter(pairs, fn {_modifiers, content} -> content not in ["", []] end) - renderer = Keyword.get(opts, :renderer, &default_renderer/2) tail_html = pairs_to_html(pairs, renderer) Phoenix.HTML.raw([head_html, tail_html]) @@ -177,10 +180,6 @@ defmodule LivebookWeb.ANSI do pairs_to_html([{modifiers, [content1, content2]} | pairs], iodata, renderer) end - defp pairs_to_html([{modifiers, content} | pairs], iodata, renderer) when modifiers == %{} do - pairs_to_html(pairs, [iodata, content], renderer) - end - defp pairs_to_html([{modifiers, content} | pairs], iodata, renderer) do style = modifiers_to_css(modifiers) rendered = renderer.(style, content) @@ -188,7 +187,11 @@ defmodule LivebookWeb.ANSI do pairs_to_html(pairs, [iodata, rendered], renderer) end - defp default_renderer(style, content) do + def default_renderer("", content) do + content + end + + def default_renderer(style, content) do [~s{}, content, ~s{}] end diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index 8583f5e0b..274087d05 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -31,8 +31,6 @@ defmodule LivebookWeb.Helpers do defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/) defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/) - defdelegate ansi_string_to_html(string, opts \\ []), to: LivebookWeb.ANSI - @doc """ Returns [Remix](https://remixicon.com) icon tag. """ @@ -41,4 +39,42 @@ defmodule LivebookWeb.Helpers do attrs = Keyword.update(attrs, :class, icon_class, fn class -> "#{icon_class} #{class}" end) content_tag(:i, "", attrs) end + + defdelegate ansi_string_to_html(string, opts \\ []), to: LivebookWeb.ANSI + + @doc """ + Converts a string with ANSI escape codes into HTML lines. + + This method is similar to `ansi_string_to_html/2`, + but makes sure each line is itself a valid HTML + (as opposed to just splitting HTML into lines). + """ + @spec ansi_to_html_lines(String.t()) :: list(Phoenix.HTML.safe()) + def ansi_to_html_lines(string) do + string + |> ansi_string_to_html( + # Make sure every line is styled separately, + # so that later we can safely split the whole HTML + # into valid HTML lines. + renderer: fn style, content -> + content + |> IO.iodata_to_binary() + |> String.split("\n") + |> Enum.map(&apply_rewind/1) + |> Enum.map(&LivebookWeb.ANSI.default_renderer(style, &1)) + |> Enum.intersperse("\n") + end + ) + |> Phoenix.HTML.safe_to_string() + |> String.split("\n") + |> Enum.map(&Phoenix.HTML.raw/1) + end + + # Respect \r indicating the line should be cleared + defp apply_rewind(line) do + line + |> String.split("\r") + |> Enum.reverse() + |> Enum.find("", &(&1 != "")) + end end diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index b3b119e6a..55b66bc41 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -232,7 +232,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do ~L"""
- +
""" @@ -244,7 +244,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do ~L"""
- +
""" @@ -258,24 +258,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end - defp ansi_to_html_lines(string) do - string - |> ansi_string_to_html( - # Make sure every line is styled separately, - # so tht later we can safely split the whole HTML - # into valid HTML lines. - renderer: fn style, content -> - content - |> IO.iodata_to_binary() - |> String.split("\n") - |> Enum.map(&[~s{}, &1, ~s{}]) - |> Enum.intersperse("\n") - end - ) - |> Phoenix.HTML.safe_to_string() - |> String.split("\n") - end - defp render_cell_status(validity_status, evaluation_status, changed) defp render_cell_status(_, :evaluating, changed) do diff --git a/test/livebook_web/ansi_test.exs b/test/livebook_web/ansi_test.exs index 998d25229..56746abf5 100644 --- a/test/livebook_web/ansi_test.exs +++ b/test/livebook_web/ansi_test.exs @@ -3,7 +3,7 @@ defmodule LivebookWeb.ANSITest do alias LivebookWeb.ANSI - describe "ansi_string_to_html/1" do + describe "ansi_string_to_html/2" do test "converts ANSI escape codes to span tags" do assert ~s{cat} == ANSI.ansi_string_to_html("\e[34mcat\e[0m") |> Phoenix.HTML.safe_to_string() @@ -70,13 +70,24 @@ defmodule LivebookWeb.ANSITest do end test "given custom renderer uses it to generate HTML" do - div_renderer = fn style, content -> - [~s{
}, content, ~s{
}] + div_renderer = fn + "", content -> content + style, content -> [~s{
}, content, ~s{
}] end assert ~s{
cat
} == ANSI.ansi_string_to_html("\e[34mcat\e[0m", renderer: div_renderer) |> Phoenix.HTML.safe_to_string() end + + test "given custom renderer uses it for style-less text as well" do + div_renderer = fn _style, content -> + [~s{
}, content, ~s{
}] + end + + assert ~s{
cat
} == + ANSI.ansi_string_to_html("cat", renderer: div_renderer) + |> Phoenix.HTML.safe_to_string() + end end end diff --git a/test/livebook_web/helpers_test.exs b/test/livebook_web/helpers_test.exs new file mode 100644 index 000000000..1a1413c9e --- /dev/null +++ b/test/livebook_web/helpers_test.exs @@ -0,0 +1,22 @@ +defmodule LivebookWeb.HelpersTest do + use ExUnit.Case, async: true + + alias LivebookWeb.Helpers + + describe "ansi_to_html_lines/1" do + test "puts every line in its own tag" do + assert [ + {:safe, ~s{smiley}}, + {:safe, ~s{cat}} + ] == + Helpers.ansi_to_html_lines("\e[34msmiley\ncat\e[0m") + end + + test "respects CR as line cleaner" do + assert [ + {:safe, ~s{cat}} + ] == + Helpers.ansi_to_html_lines("\e[34msmiley\rcat\r\e[0m") + end + end +end