Respect CR in cell output (#137)

* Respect CR in cell output

* Update test/livebook_web/helpers_test.exs

Co-authored-by: José Valim <jose.valim@dashbit.co>

* Improve rewind implementation

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2021-04-04 21:22:28 +02:00 committed by GitHub
parent d93b5d8450
commit 7d1d1f4d98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 85 additions and 31 deletions

View file

@ -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 `<span>` 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{<span style="#{style}">}, content, ~s{</span>}]
end

View file

@ -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

View file

@ -232,7 +232,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
~L"""
<div id="<%= @id %>" phx-hook="VirtualizedLines" data-max-height="300" data-follow="true">
<div data-template class="hidden"><%= for line <- @lines do %><div><%= raw line %></div><% end %></div>
<div data-template class="hidden"><%= for line <- @lines do %><div><%= line %></div><% end %></div>
<div data-content phx-update="ignore" class="overflow-auto whitespace-pre text-gray-500 tiny-scrollbar"></div>
</div>
"""
@ -244,7 +244,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
~L"""
<div id="<%= @id %>" phx-hook="VirtualizedLines" data-max-height="300" data-follow="false">
<div data-template class="hidden"><%= for line <- @lines do %><div><%= raw line %></div><% end %></div>
<div data-template class="hidden"><%= for line <- @lines do %><div><%= line %></div><% end %></div>
<div data-content phx-update="ignore" class="overflow-auto whitespace-pre text-gray-500 tiny-scrollbar"></div>
</div>
"""
@ -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{<span style="#{style}">}, &1, ~s{</span>}])
|> 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

View file

@ -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{<span style="color: var(--ansi-color-blue);">cat</span>} ==
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{<div style="#{style}">}, content, ~s{</div>}]
div_renderer = fn
"", content -> content
style, content -> [~s{<div style="#{style}">}, content, ~s{</div>}]
end
assert ~s{<div style="color: var(--ansi-color-blue);">cat</div>} ==
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{<div>}, content, ~s{</div>}]
end
assert ~s{<div>cat</div>} ==
ANSI.ansi_string_to_html("cat", renderer: div_renderer)
|> Phoenix.HTML.safe_to_string()
end
end
end

View file

@ -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{<span style="color: var(--ansi-color-blue);">smiley</span>}},
{:safe, ~s{<span style="color: var(--ansi-color-blue);">cat</span>}}
] ==
Helpers.ansi_to_html_lines("\e[34msmiley\ncat\e[0m")
end
test "respects CR as line cleaner" do
assert [
{:safe, ~s{<span style="color: var(--ansi-color-blue);">cat</span>}}
] ==
Helpers.ansi_to_html_lines("\e[34msmiley\rcat\r\e[0m")
end
end
end