feat: issue #32 markdown editor for TUI

This commit is contained in:
2026-07-17 16:38:46 +02:00
parent 2fd132e827
commit 46c239df56
8 changed files with 1042 additions and 51 deletions

View 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