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

@@ -25,8 +25,8 @@ defmodule BDS.TUI do
`yyyy-mm-dd`; enter keeps the filter, esc clears it),
`:` vi-style command prompt over the Blog-menu shell commands, `?`
command help (commands whose report/apply UI is GUI-only are marked).
Posts open in the rendered markdown preview (new empty posts open in
the editor). Editor focus: `ctrl+e` toggles preview/editor, text keys
Posts open in the syntax-highlighted, word-wrapping editor. Editor
focus: `ctrl+e` toggles editor/rendered-markdown preview, text keys
edit, `ctrl+s` save, `ctrl+p` publish, `ctrl+t` edit title, `ctrl+l`
cycle language, `ctrl+g` AI suggestions, `esc` back to sidebar. `ctrl+q`
quits, `esc` also closes a media preview. The UI locale is the
@@ -42,6 +42,7 @@ defmodule BDS.TUI do
alias BDS.Git
alias BDS.Posts
alias BDS.Projects
alias BDS.UI.CodeEditor
alias BDS.UI.PostEditor.{Draft, Metadata, Persistence}
alias BDS.UI.SettingsForm
alias BDS.UI.Sidebar
@@ -49,7 +50,7 @@ defmodule BDS.TUI do
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Clear, List, Markdown, Paragraph, Tabs, Textarea}
alias ExRatatui.Widgets.{Block, Clear, List, Markdown, Paragraph, Tabs}
@views ~w(posts media templates scripts tags settings git)
@git_diff_scroll_step 10
@@ -230,8 +231,7 @@ defmodule BDS.TUI do
when is_binary(project_id) do
case Posts.create_post(%{project_id: project_id, title: "", content: ""}) do
{:ok, post} ->
# A fresh, empty post starts in the editor — there is nothing to
# preview yet; existing posts open in the preview instead.
# A fresh, empty post starts in the editor — like every post.
{:noreply, state |> load_sidebar() |> open_post(post.id, false)}
{:error, reason} ->
@@ -1448,10 +1448,9 @@ defmodule BDS.TUI do
[title_widget, body_widget(state, editor, body_rect)]
end
# The editing textarea cannot soft-wrap (upstream ratatui-textarea
# limitation), so ctrl+e flips to a word-wrapped read-only Markdown
# preview of the current draft. Wrap moves into the editor itself with
# the planned MarkdownEditor custom widget.
# ctrl+e flips to a word-wrapped read-only Markdown preview of the
# current draft; the editor itself is a syntax-highlighted, wrapping
# BDS.UI.CodeEditor over the same Rust-owned textarea.
defp body_widget(_state, %{preview?: true} = editor, body_rect) do
preview_title =
"#{dgettext("ui", "Preview (ctrl+e to edit)")} [#{editor.language}]"
@@ -1468,14 +1467,14 @@ defmodule BDS.TUI do
"#{dgettext("ui", "Content")} [#{editor.language}]" <>
if(editor.dirty, do: "", else: "")
{%Textarea{
state: editor.textarea,
focused? = state.focus == :editor and not editor.editing_title?
{%CodeEditor{
textarea: editor.textarea,
language: :markdown_macros,
block: %Block{title: body_title, borders: [:all]},
cursor_style:
if(state.focus == :editor and not editor.editing_title?,
do: %Style{bg: :cyan},
else: %Style{}
)
cursor_style: if(focused?, do: %Style{bg: :cyan}, else: %Style{}),
line_highlight_style: if(focused?, do: %Style{bg: :dark_gray}, else: %Style{})
}, body_rect}
end
@@ -1945,9 +1944,9 @@ defmodule BDS.TUI do
# ── Post editor ──────────────────────────────────────────────────────────
# Posts open in the rendered markdown preview by default — the editor is
# one ctrl+e away. New (empty) posts pass preview?: false.
defp open_post(state, post_id, preview? \\ true) do
# Posts open in the syntax-highlighted editor by default — the rendered
# markdown preview is one ctrl+e away.
defp open_post(state, post_id, preview? \\ false) do
case Posts.get_post(post_id) do
nil ->
toast(state, dgettext("ui", "Posts"), dgettext("ui", "Post not found."))

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

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

View File

@@ -0,0 +1,205 @@
defmodule BDS.UI.CodeEditor do
@moduledoc """
Syntax-highlighted, word-wrapping code editor widget (issue #32).
The editing engine stays Rust-owned (`ExRatatui.textarea_*` — buffer,
cursor, undo/redo, clipboard); this widget only re-renders the buffer
every frame through a styling + wrap pipeline:
1. read the buffer and cursor off the textarea reference
2. highlight with `BDS.UI.CodeEditor.Highlight`
3. patch the current-line background into the cursor's source line
4. cut the span at the cursor column and splice in a cursor-styled
cell (the cell-caret rides the paragraph's wrapping)
5. compute the vertical scroll offset so the cursor's visual row
stays inside the window
6. emit a wrapping `ExRatatui.Widgets.Paragraph`
Fields:
* `:textarea` — the Rust textarea reference (required)
* `:language` — `:markdown_macros | :liquid | :lua | :text`
* `:theme` — syntect theme, default `:base16_ocean_dark`
* `:wrap` — soft-wrap long lines, default `true`
* `:cursor_style` — patch style for the cell under the cursor
* `:line_highlight_style` — patch style for the cursor's source line
* `:block` — optional `%ExRatatui.Widgets.Block{}` container
* `:scroll` — previous `{vertical, horizontal}` offset; the render
adjusts it minimally so the cursor stays visible
There is deliberately no line-number gutter. The wrap math assumes
Latin cell widths (1 char = 1 cell) — wide graphemes (CJK, emoji)
can scroll slightly off; noted as a TODO, out of scope for now.
"""
alias ExRatatui.Style
alias ExRatatui.Widgets.Block
alias ExRatatui.Widgets.CodeBlock
@type language :: BDS.UI.CodeEditor.Highlight.language()
@type t :: %__MODULE__{
textarea: reference() | nil,
language: language(),
theme: CodeBlock.theme(),
wrap: boolean(),
cursor_style: Style.t(),
line_highlight_style: Style.t(),
block: Block.t() | nil,
scroll: {non_neg_integer(), non_neg_integer()}
}
defstruct textarea: nil,
language: :text,
theme: :base16_ocean_dark,
wrap: true,
cursor_style: %Style{bg: :cyan},
line_highlight_style: %Style{},
block: nil,
scroll: {0, 0}
end
defimpl ExRatatui.Widget, for: BDS.UI.CodeEditor do
alias BDS.UI.CodeEditor
alias BDS.UI.CodeEditor.Highlight
alias BDS.UI.CodeEditor.Overlay
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Text.Line
alias ExRatatui.Text.Span
alias ExRatatui.Widgets.Block
alias ExRatatui.Widgets.Paragraph
def render(%CodeEditor{textarea: nil} = editor, %Rect{} = rect) do
[{%Paragraph{text: "", wrap: editor.wrap, block: editor.block}, rect}]
end
def render(%CodeEditor{} = editor, %Rect{} = rect) do
content = ExRatatui.textarea_get_value(editor.textarea)
{row, col} = ExRatatui.textarea_cursor(editor.textarea)
source_lines = String.split(content, "\n")
inner = inner_rect(rect, editor.block)
width = max(inner.width, 1)
height = max(inner.height, 1)
lines =
content
|> Highlight.highlight(editor.language, editor.theme)
|> highlight_current_line(row, editor.line_highlight_style)
|> splice_cursor(source_lines, row, col, editor)
top = scroll_top(source_lines, row, col, width, height, elem(editor.scroll, 0))
[
{%Paragraph{
text: lines,
wrap: editor.wrap,
scroll: {top, 0},
block: editor.block
}, rect}
]
end
defp inner_rect(rect, nil), do: rect
defp inner_rect(rect, %Block{borders: borders}) do
left = if border?(borders, :left), do: 1, else: 0
right = if border?(borders, :right), do: 1, else: 0
top = if border?(borders, :top), do: 1, else: 0
bottom = if border?(borders, :bottom), do: 1, else: 0
%Rect{
x: rect.x + left,
y: rect.y + top,
width: max(rect.width - left - right, 1),
height: max(rect.height - top - bottom, 1)
}
end
defp border?(borders, side), do: :all in borders or side in borders
defp highlight_current_line(lines, row, %Style{} = style) do
if style == %Style{} do
lines
else
List.update_at(lines, row, fn %Line{} = line ->
%{line | spans: Enum.map(line.spans, &%{&1 | style: Overlay.merge_style(&1.style, style)})}
end)
end
end
defp splice_cursor(lines, source_lines, row, col, editor) do
List.update_at(lines, row, fn %Line{} = line ->
if col >= cursor_line_length(source_lines, row) do
append_cursor_cell(line, editor)
else
splice_cursor_cell(line, col, editor.cursor_style)
end
end)
end
defp cursor_line_length(source_lines, row) do
source_lines |> Enum.at(row, "") |> String.length()
end
# Past the last character there is no span to cut into — append a
# cursor cell instead. The line highlight bleeds through when the
# cursor style leaves the background alone.
defp append_cursor_cell(%Line{} = line, editor) do
style = Overlay.merge_style(editor.line_highlight_style, editor.cursor_style)
%{line | spans: line.spans ++ [%Span{content: " ", style: style}]}
end
defp splice_cursor_cell(%Line{} = line, col, cursor_style) do
{new_spans, _offset} =
Enum.map_reduce(line.spans, 0, fn span, offset ->
len = String.length(span.content)
if col >= offset and col < offset + len do
{cut_span(span, col - offset, cursor_style), offset + len}
else
{[span], offset + len}
end
end)
%{line | spans: List.flatten(new_spans)}
end
defp cut_span(span, at, cursor_style) do
[
%Span{content: String.slice(span.content, 0, at), style: span.style},
%Span{
content: String.slice(span.content, at, 1),
style: Overlay.merge_style(span.style, cursor_style)
},
%Span{content: String.slice(span.content, at + 1, String.length(span.content)), style: span.style}
]
|> Enum.reject(&(&1.content == ""))
end
# Latin-width assumption (1 char = 1 cell): wide graphemes wrap
# differently than counted here. TODO: CJK/emoji cell widths.
defp scroll_top(source_lines, row, col, width, height, prev_top) do
visual_row =
source_lines
|> Enum.take(row)
|> Enum.map(&visual_rows(&1, width))
|> Enum.sum()
|> Kernel.+(div(col, width))
cond do
visual_row < prev_top -> visual_row
visual_row >= prev_top + height -> visual_row - height + 1
true -> prev_top
end
|> max(0)
end
defp visual_rows(line, width) do
line
|> String.length()
|> max(1)
|> then(&div(&1 + width - 1, width))
end
end