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

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