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,225 @@
defmodule BDS.UI.CodeEditor.Overlay do
@moduledoc """
Re-styles `%ExRatatui.Text.Line{}` spans for the syntaxes the base
syntect lexer does not know: post macros (`[[gallery names="x"]]`) and
Liquid tags (`{{ … }}` / `{% … %}`).
Ranges are byte-based `{start, length, %Style{}}` triples applied with
patch semantics — the overlay's non-nil fields override the base span
style, so the theme background survives. Overlay colours mirror the
GUI Monaco theme (`assets/js/monaco/theme.js`): macro accents are the
custom `keyword.macro` / `attribute.name` / `attribute.value` rules,
Liquid accents are the vs-dark keyword/string/number base tokens.
"""
alias ExRatatui.Style
alias ExRatatui.Text.Line
alias ExRatatui.Text.Span
@type styled_range :: {non_neg_integer(), non_neg_integer(), Style.t()}
@macro_style %Style{fg: {:rgb, 197, 134, 192}, modifiers: [:bold]}
@macro_attr_style %Style{fg: {:rgb, 156, 220, 254}}
@macro_value_style %Style{fg: {:rgb, 206, 145, 120}}
@liquid_keyword_style %Style{fg: {:rgb, 86, 156, 214}}
@liquid_string_style %Style{fg: {:rgb, 206, 145, 120}}
@liquid_number_style %Style{fg: {:rgb, 181, 206, 168}}
@macro_pattern ~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/
@macro_opener ~r/\A\[\[\s*[a-zA-Z][\w-]*/
@macro_attr_name ~r/[a-zA-Z][\w-]*(?=\s*=)/
@macro_quoted_value ~r/"[^"]*"/
@macro_bare_value ~r/[^\s=\[\]"]++(?!\s*=)/
@liquid_tag ~r/\{\{-?.*?-?\}\}|\{%-?.*?-?%\}/
@liquid_open ~r/\A\{[\{%]-?/
@liquid_close ~r/-?[\}%][\}%]\z/
@liquid_keywords ~r/\b(?:assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when|true|false|nil|blank|empty|contains)\b/
@liquid_filter ~r/\|\s*[a-zA-Z_][\w-]*/
@liquid_string ~r/"[^"]*"|'[^']*'/
@liquid_number ~r/\b\d+(?:\.\d+)?\b/
@doc """
Recolours every `[[macro]]` on the line, preserving the base styling
of the surrounding markdown.
"""
@spec markdown_macros(Line.t()) :: Line.t()
def markdown_macros(%Line{} = line) do
apply_ranges(line, markdown_macro_ranges(line_text(line)))
end
@doc """
Recolours every Liquid output/tag expression on the line, preserving
the base styling of the surrounding HTML.
"""
@spec liquid(Line.t()) :: Line.t()
def liquid(%Line{} = line) do
apply_ranges(line, liquid_ranges(line_text(line)))
end
@doc """
Byte ranges of every macro on `text`: `[[` + name and `]]` in the
macro style, attribute names and values in their accent styles.
"""
@spec markdown_macro_ranges(String.t()) :: [styled_range()]
def markdown_macro_ranges(text) do
text
|> scan(@macro_pattern)
|> Enum.flat_map(fn {start, len} ->
macro = binary_part(text, start, len)
macro
|> macro_inner_ranges()
|> Enum.map(fn {inner_start, inner_len, style} ->
{start + inner_start, inner_len, style}
end)
end)
end
@doc """
Byte ranges (relative to the start of `macro`, including its `[[` and
`]]`) of the styled sub-parts of a single macro match. Quoted values
are masked before scanning for attribute names and bare values so
nothing matches inside a quoted string.
"""
@spec macro_inner_ranges(String.t()) :: [styled_range()]
def macro_inner_ranges(macro) do
[{0, opener_len}] = Regex.run(@macro_opener, macro, return: :index)
inner = binary_part(macro, opener_len, byte_size(macro) - opener_len - 2)
quoted = scan(inner, @macro_quoted_value)
masked = mask(inner, quoted)
inner_ranges =
with_style(quoted, @macro_value_style) ++
(masked |> scan(@macro_attr_name) |> with_style(@macro_attr_style)) ++
(masked |> scan(@macro_bare_value) |> with_style(@macro_value_style))
[{0, opener_len, @macro_style}, {byte_size(macro) - 2, 2, @macro_style}] ++
offset_ranges(inner_ranges, opener_len)
end
@doc """
Byte ranges of every Liquid expression on `text`: the `{{`/`}}` and
`{%`/`%}` delimiters, tag keywords and filters in the keyword style,
strings and numbers in their accent styles. Strings are masked before
scanning so keywords/numbers inside them keep the string style.
"""
@spec liquid_ranges(String.t()) :: [styled_range()]
def liquid_ranges(text) do
text
|> scan(@liquid_tag)
|> Enum.flat_map(fn {start, len} ->
tag = binary_part(text, start, len)
[{0, open_len}] = Regex.run(@liquid_open, tag, return: :index)
[{close_start, close_len}] = Regex.run(@liquid_close, tag, return: :index)
inner = binary_part(tag, open_len, close_start - open_len)
strings = scan(inner, @liquid_string)
masked = mask(inner, strings)
inner_ranges =
with_style(strings, @liquid_string_style) ++
(masked |> scan(@liquid_keywords) |> with_style(@liquid_keyword_style)) ++
(masked |> scan(@liquid_filter) |> with_style(@liquid_keyword_style)) ++
(masked |> scan(@liquid_number) |> with_style(@liquid_number_style))
[{start, open_len, @liquid_keyword_style}, {start + close_start, close_len, @liquid_keyword_style}] ++
offset_ranges(inner_ranges, start + open_len)
end)
end
@doc """
Re-splits a line's spans at the given byte ranges, patch-merging each
range's style over the base span style it covers. Overlapping ranges
resolve to the one starting earliest (strings/quotes therefore win
over keywords matched inside them).
"""
@spec apply_ranges(Line.t(), [styled_range()]) :: Line.t()
def apply_ranges(%Line{} = line, []), do: line
def apply_ranges(%Line{spans: spans} = line, ranges) do
ranges = Enum.sort_by(ranges, &elem(&1, 0))
{new_spans, _offset} =
Enum.map_reduce(spans, 0, fn span, offset ->
{split_span(span, offset, ranges), offset + byte_size(span.content)}
end)
%{line | spans: new_spans |> List.flatten() |> Enum.reject(&(&1.content == ""))}
end
@doc """
Patch-merges `patch` over `base`: non-nil colours override, modifiers
union. Used for overlay accents, the cursor cell and the current-line
highlight so the theme background/foreground survives underneath.
"""
@spec merge_style(Style.t(), Style.t()) :: Style.t()
def merge_style(%Style{} = base, %Style{} = patch) do
%Style{
fg: patch.fg || base.fg,
bg: patch.bg || base.bg,
underline_color: patch.underline_color || base.underline_color,
modifiers: Enum.uniq(base.modifiers ++ patch.modifiers)
}
end
defp split_span(%Span{} = span, span_start, ranges) do
span_end = span_start + byte_size(span.content)
cuts =
ranges
|> Enum.flat_map(fn {range_start, range_len, _style} ->
range_end = range_start + range_len
if range_end > span_start and range_start < span_end do
[max(range_start, span_start), min(range_end, span_end)]
else
[]
end
end)
|> Enum.filter(&(&1 > span_start and &1 < span_end))
|> Enum.uniq()
|> Enum.sort()
[span_start | cuts ++ [span_end]]
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [segment_start, segment_end] ->
content = binary_part(span.content, segment_start - span_start, segment_end - segment_start)
style =
case Enum.find(ranges, &covers?(&1, segment_start, segment_end)) do
nil -> span.style
{_start, _len, patch} -> merge_style(span.style, patch)
end
%Span{content: content, style: style}
end)
end
defp covers?({range_start, range_len, _style}, segment_start, segment_end) do
segment_start >= range_start and segment_end <= range_start + range_len
end
defp line_text(line), do: Enum.map_join(line.spans, & &1.content)
defp scan(text, pattern) do
pattern
|> Regex.scan(text, return: :index)
|> Enum.map(fn [{start, len}] -> {start, len} end)
end
defp with_style(ranges, style), do: Enum.map(ranges, fn {start, len} -> {start, len, style} end)
defp offset_ranges(ranges, offset),
do: Enum.map(ranges, fn {start, len, style} -> {start + offset, len, style} end)
# Blanks out the given byte ranges with spaces so later scans cannot
# match inside them (offsets stay intact).
defp mask(text, ranges) do
Enum.reduce(ranges, text, fn {start, len}, acc ->
binary_part(acc, 0, start) <>
String.duplicate(" ", len) <>
binary_part(acc, start + len, byte_size(acc) - start - len)
end)
end
end