feat: issue #32 markdown editor for TUI
This commit is contained in:
199
test/bds/ui/code_editor_test.exs
Normal file
199
test/bds/ui/code_editor_test.exs
Normal file
@@ -0,0 +1,199 @@
|
||||
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("<div>{{ x }}</div>", :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
|
||||
Reference in New Issue
Block a user