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,72 @@
defmodule BDS.UI.CodeEditor.Highlight do
@moduledoc """
Syntax highlighting for the TUI code editor (issue #32).
Wraps `ExRatatui.CodeBlock.highlight/3` with the editor's language
set: base lexing via syntect, plus pure-Elixir overlays for the
syntaxes syntect does not know — post macros (`[[gallery names="x"]]`)
over the markdown base and Liquid tags (`{{ … }}` / `{% … %}`) over
the HTML base. Mirrors the GUI Monaco language registrations in
`assets/js/monaco/languages.js`.
"""
alias BDS.UI.CodeEditor.Overlay
alias ExRatatui.Text.Line
alias ExRatatui.Widgets.CodeBlock
@type language :: :markdown_macros | :liquid | :lua | :text
@doc """
Highlights `content` for `language` using `theme`, returning one
`%ExRatatui.Text.Line{}` per source line.
The returned line count always matches `String.split(content, "\\n")`
(syntect drops trailing empty lines, but the textarea's line count is
authoritative for the cursor row, so they are padded back). Span
contents never contain newline characters.
"""
@spec highlight(String.t(), language(), CodeBlock.theme()) :: [Line.t()]
def highlight(content, language, theme) when is_binary(content) do
content
|> base_highlight(language, theme)
|> sanitize_lines()
|> pad_lines(source_line_count(content))
|> apply_overlay(language)
end
defp base_highlight(content, :markdown_macros, theme),
do: ExRatatui.CodeBlock.highlight(content, "markdown", theme)
defp base_highlight(content, :liquid, theme),
do: ExRatatui.CodeBlock.highlight(content, "html", theme)
defp base_highlight(content, :lua, theme),
do: ExRatatui.CodeBlock.highlight(content, "lua", theme)
defp base_highlight(content, :text, theme),
do: ExRatatui.CodeBlock.highlight(content, nil, theme)
# syntect appends the line terminator to the last span of a line;
# spans fed to `ExRatatui.Widgets.Paragraph` must not contain "\n".
defp sanitize_lines(lines) do
Enum.map(lines, fn %Line{spans: spans} = line ->
spans =
spans
|> Enum.map(&%{&1 | content: String.replace(&1.content, ~r/[\r\n]/, "")})
|> Enum.reject(&(&1.content == ""))
%{line | spans: spans}
end)
end
defp source_line_count(content), do: content |> String.split("\n") |> length()
defp pad_lines(lines, count) when length(lines) >= count, do: lines
defp pad_lines(lines, count),
do: lines ++ List.duplicate(%Line{}, count - length(lines))
defp apply_overlay(lines, :markdown_macros), do: Enum.map(lines, &Overlay.markdown_macros/1)
defp apply_overlay(lines, :liquid), do: Enum.map(lines, &Overlay.liquid/1)
defp apply_overlay(lines, _language), do: lines
end