feat: issue #32 markdown editor for TUI
This commit is contained in:
@@ -38,14 +38,18 @@ defmodule BDS.TUITest do
|
||||
state
|
||||
end
|
||||
|
||||
defp screen_text(state, width \\ 100, height \\ 32) do
|
||||
defp screen_cells(state, width \\ 100, height \\ 32) do
|
||||
session = CellSession.new(width, height)
|
||||
frame = %ExRatatui.Frame{width: width, height: height}
|
||||
:ok = CellSession.draw(session, BDS.TUI.render(state, frame))
|
||||
snapshot = CellSession.take_cells(session)
|
||||
:ok = CellSession.close(session)
|
||||
|
||||
snapshot.cells
|
||||
end
|
||||
|
||||
defp screen_text(state, width \\ 100, height \\ 32) do
|
||||
state
|
||||
|> screen_cells(width, height)
|
||||
|> Enum.group_by(& &1.row)
|
||||
|> Enum.sort_by(fn {row, _} -> row end)
|
||||
|> Enum.map_join("\n", fn {_row, cells} ->
|
||||
@@ -53,6 +57,22 @@ defmodule BDS.TUITest do
|
||||
end)
|
||||
end
|
||||
|
||||
# The cell where `text` starts on screen (used to assert per-cell styles).
|
||||
defp cell_at_text(cells, text) do
|
||||
{_row, row_cells} =
|
||||
cells
|
||||
|> Enum.group_by(& &1.row)
|
||||
|> Enum.find(fn {_row, row_cells} ->
|
||||
row_cells |> Enum.sort_by(& &1.col) |> Enum.map_join("", & &1.symbol) |> String.contains?(text)
|
||||
end)
|
||||
|
||||
sorted = Enum.sort_by(row_cells, & &1.col)
|
||||
joined = Enum.map_join(sorted, & &1.symbol)
|
||||
{byte_offset, _len} = :binary.match(joined, text)
|
||||
char_offset = joined |> binary_part(0, byte_offset) |> String.length()
|
||||
Enum.at(sorted, char_offset)
|
||||
end
|
||||
|
||||
defp key(code, modifiers), do: %Key{code: code, kind: "press", modifiers: modifiers}
|
||||
|
||||
defp press(state, code, modifiers \\ []) do
|
||||
@@ -68,7 +88,7 @@ defmodule BDS.TUITest do
|
||||
assert text =~ "Hello TUI"
|
||||
end
|
||||
|
||||
test "opening a post lands in the markdown preview, not the editor", %{post: post} do
|
||||
test "opening a post lands in the editor with the raw source", %{post: post} do
|
||||
{:ok, _} =
|
||||
BDS.Posts.update_post(post.id, %{content: "# Big Heading\n\nsome **bold** words"})
|
||||
|
||||
@@ -76,19 +96,66 @@ defmodule BDS.TUITest do
|
||||
|
||||
assert state.focus == :editor
|
||||
assert state.editor.post.id == post.id
|
||||
refute state.editor.preview?
|
||||
|
||||
# The editor shows the raw markdown source.
|
||||
assert screen_text(state) =~ "**bold**"
|
||||
|
||||
# ctrl+e flips to the rendered markdown preview: inline markers are
|
||||
# consumed by the styling.
|
||||
state = press(state, "e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
|
||||
text = screen_text(state)
|
||||
assert text =~ "Big Heading"
|
||||
# Rendered markdown: inline markers are consumed by the styling
|
||||
# (headings keep their # but get styled, invisible in a text dump).
|
||||
assert text =~ "some bold words"
|
||||
refute text =~ "**bold**"
|
||||
|
||||
# ctrl+e drops into the editing textarea with the raw source.
|
||||
# and back to the editor.
|
||||
state = press(state, "e", ["ctrl"])
|
||||
refute state.editor.preview?
|
||||
assert ExRatatui.textarea_get_value(state.editor.textarea) =~ "**bold**"
|
||||
assert screen_text(state) =~ "**bold**"
|
||||
end
|
||||
|
||||
test "the editor word-wraps long lines", %{post: post} do
|
||||
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
refute state.editor.preview?
|
||||
|
||||
# The tail of a long line wraps onto visible rows without any
|
||||
# horizontal scrolling.
|
||||
assert screen_text(state, 80, 32) =~ "FINALWORD"
|
||||
end
|
||||
|
||||
test "the cursor cell stays visible when the cursor sits below the viewport", %{post: post} do
|
||||
content = Enum.map_join(1..30, "\n", &"line #{&1}")
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: content})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
|
||||
# The cursor opens at the end of the buffer — source row 29 of 30.
|
||||
assert ExRatatui.textarea_cursor(state.editor.textarea) == {29, 7}
|
||||
|
||||
cells = screen_cells(state)
|
||||
# The cursor cell is the single space carrying the cursor background
|
||||
# inside the editor area (the sidebar selection also uses a cyan bg).
|
||||
assert Enum.any?(cells, &(&1.bg == :cyan and &1.symbol == " " and &1.col >= 34))
|
||||
end
|
||||
|
||||
test "the cursor line has a distinct background from the other lines", %{post: post} do
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: "first\nsecond\nthird"})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
|
||||
# Move the cursor from the end of the buffer onto the first line.
|
||||
state = state |> press("up") |> press("up")
|
||||
assert ExRatatui.textarea_cursor(state.editor.textarea) == {0, 5}
|
||||
|
||||
cells = screen_cells(state)
|
||||
assert cell_at_text(cells, "first").bg == :dark_gray
|
||||
refute cell_at_text(cells, "second").bg == :dark_gray
|
||||
end
|
||||
|
||||
test "ctrl+s persists textarea edits", %{post: post} do
|
||||
@@ -126,26 +193,17 @@ defmodule BDS.TUITest do
|
||||
assert state.focus == :sidebar
|
||||
end
|
||||
|
||||
test "the preview word-wraps and ctrl+e toggles back and forth", %{post: post} do
|
||||
test "the preview word-wraps long lines", %{post: post} do
|
||||
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
|
||||
# The textarea cannot soft-wrap, so the tail of a long line is off-screen
|
||||
# there; the preview renders through the Markdown widget, which wraps.
|
||||
assert screen_text(state, 80, 32) =~ "FINALWORD"
|
||||
|
||||
state = press(state, "e", ["ctrl"])
|
||||
refute state.editor.preview?
|
||||
|
||||
state = press(state, "e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
end
|
||||
|
||||
test "keys in preview mode do not edit the content" do
|
||||
state = mount!() |> press("enter")
|
||||
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
before = ExRatatui.textarea_get_value(state.editor.textarea)
|
||||
|
||||
|
||||
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