defmodule BDS.UI.CodeEditorTest do use ExUnit.Case, async: true alias BDS.UI.CodeEditor alias BDS.UI.CodeEditor.Highlight alias ExRatatui.Layout.Rect alias ExRatatui.Style alias ExRatatui.Text.Line alias ExRatatui.Widgets.Paragraph alias ExRatatui.Widgets.Textarea @theme :base16_ocean_dark @rect %Rect{x: 0, y: 0, width: 40, height: 10} defp find_span(line, text) do Enum.find(line.spans, fn span -> String.contains?(span.content, text) end) end defp editor_widget(content, opts \\ []) do textarea = ExRatatui.textarea_new() :ok = ExRatatui.textarea_insert_str(textarea, content) widget = %CodeEditor{ textarea: textarea, language: Keyword.get(opts, :language, :markdown_macros), cursor_style: Keyword.get(opts, :cursor_style, %Style{bg: :cyan}), line_highlight_style: Keyword.get(opts, :line_highlight_style, %Style{}), block: Keyword.get(opts, :block), scroll: Keyword.get(opts, :scroll, {0, 0}) } {widget, textarea} end defp render(widget, rect \\ @rect) do [{paragraph, ^rect}] = ExRatatui.Widget.render(widget, rect) paragraph end describe "Highlight.highlight/3 — :lua" do test "colours keywords with the theme keyword colour, distinct from identifiers" do [first, _middle, last] = Highlight.highlight("local function foo()\n return 1\nend", :lua, @theme) keyword = find_span(first, "function") assert keyword.style.fg != nil assert find_span(last, "end").style.fg == keyword.style.fg refute find_span(first, "foo").style.fg == keyword.style.fg end end describe "Highlight.highlight/3 — :markdown_macros" do test "recolours [[macro]] syntax with the GUI accent colours" do [line] = Highlight.highlight(~s([[gallery names="x"]]), :markdown_macros, @theme) opener = find_span(line, "[[gallery") assert opener.style.fg == {:rgb, 197, 134, 192} assert :bold in opener.style.modifiers closer = find_span(line, "]]") assert closer.style.fg == {:rgb, 197, 134, 192} assert :bold in closer.style.modifiers assert find_span(line, "names").style.fg == {:rgb, 156, 220, 254} assert find_span(line, ~s("x")).style.fg == {:rgb, 206, 145, 120} end test "keeps the base markdown styling outside macros" do content = ~s(before [[gallery names="x"]] after) [overlayed] = Highlight.highlight(content, :markdown_macros, @theme) [base] = ExRatatui.CodeBlock.highlight(content, "markdown", @theme) base_style = find_span(base, "before").style assert find_span(overlayed, "before").style == base_style assert find_span(overlayed, "after").style == base_style end test "highlights the macro over a styled markdown base" do [line] = Highlight.highlight("some **bold** [[macro]] words", :markdown_macros, @theme) # Bold markdown keeps its base styling while the macro is recoloured. assert :bold in find_span(line, "bold").style.modifiers assert find_span(line, "[[macro").style.fg == {:rgb, 197, 134, 192} assert find_span(line, "]]").style.fg == {:rgb, 197, 134, 192} end end describe "Highlight.highlight/3 — :liquid" do test "recolours output tags over the HTML base" do [line] = Highlight.highlight("
{{ x }}
", :liquid, @theme) assert find_span(line, "{{").style.fg == {:rgb, 86, 156, 214} assert find_span(line, "}}").style.fg == {:rgb, 86, 156, 214} # The HTML base styling survives outside the tag. assert find_span(line, "div").style.fg != nil end test "recolours the tag keyword distinctly from identifiers" do [line] = Highlight.highlight("{% if x %}y{% endif %}", :liquid, @theme) keyword = find_span(line, "if") assert keyword.style.fg == {:rgb, 86, 156, 214} assert find_span(line, "endif").style.fg == keyword.style.fg refute find_span(line, " x ").style.fg == keyword.style.fg end end describe "Highlight.highlight/3 — line bookkeeping" do test "empty content yields a single empty line" do assert [%Line{spans: []}] = Highlight.highlight("", :markdown_macros, @theme) end test "trailing empty lines are padded back to the textarea's line count" do lines = Highlight.highlight("a\n\n", :text, @theme) assert length(lines) == 3 end test "spans never contain newline characters" do lines = Highlight.highlight("a\nb\n", :text, @theme) for line <- lines, span <- line.spans do refute String.contains?(span.content, "\n") end end end describe "ExRatatui.Widget rendering" do test "emits a wrapping Paragraph, never a bare Textarea, for every highlighted language" do for language <- [:markdown_macros, :liquid, :lua, :text] do {widget, _textarea} = editor_widget("hello", language: language) paragraph = render(widget) assert %Paragraph{wrap: true, scroll: {_top, 0}} = paragraph refute match?(%Textarea{}, paragraph) end end test "splices the cursor cell into the character under the cursor" do {widget, textarea} = editor_widget("hello") :ok = ExRatatui.textarea_handle_key(textarea, "home", []) %Paragraph{text: [line | _rest]} = render(widget) cursor_span = Enum.find(line.spans, &(&1.style.bg == :cyan)) assert cursor_span.content == "h" assert Enum.map_join(line.spans, & &1.content) == "hello" end test "appends a cursor cell when the cursor sits at the end of the line" do {widget, _textarea} = editor_widget("hello") %Paragraph{text: [line | _rest]} = render(widget) cursor_span = List.last(line.spans) assert cursor_span.content == " " assert cursor_span.style.bg == :cyan end test "highlights the cursor line with the line highlight style" do {widget, _textarea} = editor_widget("ab\ncd", language: :text, cursor_style: %Style{}, line_highlight_style: %Style{bg: :dark_gray} ) %Paragraph{text: lines} = render(widget) # The cursor opens at the end of the second line. assert Enum.all?(Enum.at(lines, 1).spans, &(&1.style.bg == :dark_gray)) assert Enum.all?(Enum.at(lines, 0).spans, &(&1.style.bg != :dark_gray)) end test "scrolls so the cursor visual row stays inside the window" do content = Enum.map_join(1..30, "\n", &"line #{&1}") rect = %Rect{x: 0, y: 0, width: 20, height: 5} {widget, _textarea} = editor_widget(content, rect: rect) # The cursor opens on source row 29: 30 visual rows, window height 5. assert %Paragraph{scroll: {25, 0}} = render(widget, rect) end test "wrapped source lines count towards the cursor's visual row" do rect = %Rect{x: 0, y: 0, width: 4, height: 2} {widget, _textarea} = editor_widget("abcdefghij", rect: rect) # 10 characters in a 4-wide window wrap to 3 visual rows; the cursor # at the end of the line lands on the third. assert %Paragraph{scroll: {1, 0}} = render(widget, rect) end test "keeps the scroll offset at zero while the cursor is visible" do {widget, _textarea} = editor_widget("a\nb\nc") assert %Paragraph{scroll: {0, 0}} = render(widget) end end end