feat: issue #32 markdown editor for TUI
This commit is contained in:
225
lib/bds/ui/code_editor/overlay.ex
Normal file
225
lib/bds/ui/code_editor/overlay.ex
Normal 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
|
||||
Reference in New Issue
Block a user