11 KiB
TUI Editor — Implementation Plan (Issue #32)
Tracking issue: #32 — "markdown, liquid and lua code editor with syntax
highlighting in the TUI".
Spec under change: specs/tui.allium.
Goal: a syntax-highlighted, word-wrapping TUI editor for posts (markdown
with [[macro]] syntax), templates (HTML + Liquid), and scripts (Lua),
replacing the current plain ExRatatui.Widgets.Textarea body.
Decision: pure Elixir — no new Rust
ExRatatui already ships everything we need:
ExRatatui.CodeBlock.highlight/3(deps/ex_ratatui/lib/ex_ratatui/code_block.ex:103) returns[%ExRatatui.Text.Line{}]of styled spans via the bundled syntect NIF. Supports Lua, Markdown, HTML, … out of the box.ExRatatui.Widgets.CodeBlockis read-only; we do not use it directly for editing.ExRatatui.Widgetprotocol (deps/ex_ratatui/lib/ex_ratatui/widget.ex:1) lets us implement a custom widget in pure Elixir that composes primitives every frame. This is the seam we use.- The editing engine stays Rust-owned
(
ExRatatui.textarea_new/0,textarea_handle_key/3,textarea_get_value/1,textarea_cursor/1,textarea_line_count/1). The custom widget only re-renders the buffer through a styling + wrap pipeline. ExRatatui.Widgets.Paragraphaccepts[%Text.Line{}]of styled spans withwrap: trueand ascroll: {vertical, horizontal}offset — exactly the output shape we need.
The existing comment at lib/bds/tui.ex:1451-1454 already pre-announces
this approach as "the planned MarkdownEditor custom widget".
Languages supported (matches GUI Monaco registrations in
assets/js/monaco/languages.js):
| Entity | Base lexer (NIF) | Elixir overlay |
|---|---|---|
| post body | markdown |
[[macro …]] recoloured |
| script body | lua |
none |
| template body | html |
{{ … }} and {% … %} recoloured |
Liquid is always HTML+Liquid in this codebase (no markdown+Liquid templates exist). Plain text falls back to syntect's plain renderer.
GUI styling parity (from assets/js/monaco/theme.js + languages.js)
- macro
[[ … ]]→keyword.macro=#C586C0, bold. - inside macro:
attribute.name(e.g.names) →#9CDCFE,attribute.value(e.g."x") →#CE9178. - Liquid uses base vs-dark tokens; terminal approximation uses the same base theme accent colours. No custom Liquid token colours overridden.
Use {:rgb,197,134,192} etc. via ExRatatui.Style (already supported
by ExRatatui.CodeBlock.from_native/1).
Architecture
state.editor.textarea (unchanged, Rust-owned buffer/cursor/undo)
state.editor.language (:markdown_macros | :liquid | :lua | :text)
state.editor.preview? (unchanged ctrl+e toggle — default false for posts)
body_widget/3 edit branch: %BDS.UI.CodeEditor{textarea:, language:, theme:, wrap:, cursor_style:, line_highlight_style:, block:}
↳ impl ExRatatui.Widget.render:
1. content = ExRatatui.textarea_get_value(textarea)
2. {row, col} = ExRatatui.textarea_cursor(textarea)
3. lines = BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)
4. apply current-line bg style into every span on lines[row]
5. cut the span at byte col on lines[row], splice cursor-style cell
6. top = vertical-scroll offset so cursor visual row stays in window (Latin-width count)
7. return [{%Paragraph{text: lines, wrap: true, scroll: {top,0}, block: block}, rect}]
editor_key/1 in lib/bds/tui.ex keeps calling
ExRatatui.textarea_handle_key/3 — no edit-model rewrite, the
textarea's own undo/redo/clipboard keeps working.
Phases (test-first, per AGENTS.md)
Phase 0 — Spec
Edit specs/tui.allium:
OpenEntry: posts open in the editor (wrap on, highlighting on) by default — not the rendered Markdown preview. New empty posts still open in the editor (unchanged).- Rename
WrappedPreview→EditorMode:ctrl+etoggles editor ↔ read-only rendered-Markdown preview (viaExRatatui.Widgets.Markdown). Drop the "textarea cannot wrap" caveat. - Add
CodeEditorWrappingrule: syntax highlighting + auto wrap + highlighted current line. Explicitly no line-number gutter. - Validate with
allium check specs/tui.allium.
Phase 1 — Red tests
New file test/bds/ui/code_editor_test.exs:
BDS.UI.CodeEditor.Highlight.highlight/3for:luacolours afunction … endkeyword with the theme's keyword colour.:markdown_macrosrecolours[[gallery names="x"]]:[[,]]and macro name →{:rgb,197,134,192}boldnames→{:rgb,156,220,254}"x"→{:rgb,206,145,120}- surrounding markdown still base-styled.
:liquidrecolours{{ x }}and{% if x %}over an HTML base;ifkeyword distinct from identifiers.- Widget emits a
%Paragraph{wrap: true, scroll:}, never a bare%Textarea, when language is one of the highlighted set.
Extended test/bds/tui_test.exs:
- Opening an existing post lands in the editor (
preview? == false), not the rendered preview. Replace the assertion intest "opening a post lands in the markdown preview, not the editor". Test name/logic flips. - Long line wraps across N visual rows in a 40-wide viewport
(assert via
CellSession.take_cells/1). - Cursor at the last visual row stays visible when scrolling a small viewport (assert cursor-style cell appears in snapshot cells).
- Current line's cells have a distinct
bgfrom non-current rows on the same buffer. ctrl+eflips to preview and back; preview still rendered Markdown.ctrl+spersists textarea bytes unchanged regardless of highlight/wrap (existing assertion still covers this — keep it green).
Phase 2 — lib/bds/ui/code_editor/highlight.ex + overlay.ex
BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)→[%ExRatatui.Text.Line{}]::lua,:text→ pass straight through toExRatatui.CodeBlock.highlight/3.:markdown_macros→ base highlight as"markdown", then applyOverlay.markdown_macros/1per source line.:liquid→ base highlight as"html", then applyOverlay.liquid/1per source line.
BDS.UI.CodeEditor.Overlay:apply_ranges(line, ranges_with_styles)— re-splits aLine's spans into a new span list, preserving the base styling outside each range.markdown_macro_ranges/1—~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/s.macro_inner_ranges/1— breakattr=(attr-name) and quoted value into separate styled sub-ranges inside a macro.liquid_ranges/1—~r/(\{\{.*?\}\}|\{%-?\s*(\w+).*?-?%\})/s, with a sub-range for the tag keyword (if,for,assign, …).
- Cache results by
{content_hash, language, theme}if profiling shows re-tokenisation overhead on keystroke (Phase 2 stretch goal — skip until a test says otherwise).
Phase 3 — lib/bds/ui/code_editor/widget.ex
%BDS.UI.CodeEditor{}struct fields:textarea,language,theme(default:base16_ocean_dark, readable in dark terminals),wrap: true,cursor_style,line_highlight_style,block,scroll.defimpl ExRatatui.Widgetper the pipeline above.- Cursor cell: re-style the single codepoint at
colon source linerowintocursor_style. Paragraph wraps the same line; the styled cell rides the wrap. - Scroll math: count display rows by wrapping each source line into
widthcolumns (Latin-width assumption — 1 char = 1 cell). Note the limitation explicitly; CJK width is a follow-up if/when needed. - Line highlight: apply
line_highlight_style(abg:colour) to every span on the cursor's source line before cursor cut-in.
Phase 4 — Wire into lib/bds/tui.ex
build_editor/4(aroundlib/bds/tui.ex:1968): defaultpreview? = falsefor existing posts. New posts already open in editor — unchanged.body_widget/3edit branch (lib/bds/tui.ex:1466): emit%BDS.UI.CodeEditor{}instead of%ExRatatui.Widgets.Textarea{}for entities that have a syntax highlighter (:lua,:markdown_macros,:liquid). Plain text can keep%Textarea{}or feed:textthrough the new widget — pick whichever keeps the test surface smaller.- Remove the "wrap limitation" comment block at
lib/bds/tui.ex:1451-1454. cycle_language/1continues per post; cycle through the active set (:markdown_macros,:liquid,:lua,:text) matching the existing GUI editor language options.- Preview branch (
body_widget/3preview arm atlib/bds/tui.ex:1455) stays onExRatatui.Widgets.Markdown— unchanged. - No new user-facing strings: the existing body title already flows
through
dgettext("ui", …). If a new toolbar/hint is added later, it MUST go through gettext and provide de/fr/it/es translations.
Phase 5 — Verify
mix test(write to/tmp/test_run.logfirst, grep for failures — per AGENTS.md).mix credo --strictmix deps.audit --ignore-file .mix_audit.ignoremix dialyzer— treat warnings as errors.
No bundle rebuild, no asset change, no NIF rebuild. Monaco still drives the GUI editor; this change is TUI-only.
Risks / limits
- Per-frame NIF cost: re-tokenises the whole buffer on every
keystroke. For typical post sizes (<50 KB) syntect is sub-ms. Add the
{content_hash, language, theme}cache only if a test shows it. - CJK cell width: wrap math assumes 1 char = 1 cell. Fine for de/fr/it/es. Note as TODO; not in scope for this phase.
- Liquid mis-lexing inside HTML is cosmetically possible (an HTML
attribute containing
{{). Overlay wins after the base lexer; the result is visually tolerable. A custom.sublime-syntaxin the dep would be the real fix — out of scope here per "stay on Elixir". - Cursor in a wrapped paragraph rides the styled cell. Equivalent to a cell-caret; GUI uses a real caret — acceptable TUI parity.
Files touched (expected)
specs/tui.allium— edited.lib/bds/ui/code_editor/highlight.ex— new.lib/bds/ui/code_editor/overlay.ex— new.lib/bds/ui/code_editor/widget.ex— new.lib/bds/tui.ex— edited (build_editor,body_widget, comment removal, defaultpreview?).test/bds/ui/code_editor_test.exs— new.test/bds/tui_test.exs— edited (flip the "opens in preview" assertion, add wrap/cursor/current-line tests).
No changes to mix.exs, no new deps, no Rust, no assets.