223 lines
11 KiB
Markdown
223 lines
11 KiB
Markdown
# 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.CodeBlock` is read-only; we do **not** use it
|
|
directly for editing.
|
|
- `ExRatatui.Widget` protocol (`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.Paragraph` accepts `[%Text.Line{}]` of styled spans
|
|
with `wrap: true` and a `scroll: {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+e` toggles editor ↔
|
|
read-only rendered-Markdown preview (via `ExRatatui.Widgets.Markdown`).
|
|
Drop the "textarea cannot wrap" caveat.
|
|
- Add `CodeEditorWrapping` rule: 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/3` for `:lua` colours a
|
|
`function … end` keyword with the theme's keyword colour.
|
|
- `:markdown_macros` recolours `[[gallery names="x"]]`:
|
|
- `[[`, `]]` and macro name → `{:rgb,197,134,192}` bold
|
|
- `names` → `{:rgb,156,220,254}`
|
|
- `"x"` → `{:rgb,206,145,120}`
|
|
- surrounding markdown still base-styled.
|
|
- `:liquid` recolours `{{ x }}` and `{% if x %}` over an HTML base;
|
|
`if` keyword 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 in
|
|
`test "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 `bg` from non-current rows on
|
|
the same buffer.
|
|
- `ctrl+e` flips to preview and back; preview still rendered Markdown.
|
|
- `ctrl+s` persists 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 to
|
|
`ExRatatui.CodeBlock.highlight/3`.
|
|
- `:markdown_macros` → base highlight as `"markdown"`, then apply
|
|
`Overlay.markdown_macros/1` per source line.
|
|
- `:liquid` → base highlight as `"html"`, then apply
|
|
`Overlay.liquid/1` per source line.
|
|
- `BDS.UI.CodeEditor.Overlay`:
|
|
- `apply_ranges(line, ranges_with_styles)` — re-splits a `Line`'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` — break `attr=` (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.Widget` per the pipeline above.
|
|
- Cursor cell: re-style the single codepoint at `col` on source line
|
|
`row` into `cursor_style`. Paragraph wraps the same line; the styled
|
|
cell rides the wrap.
|
|
- Scroll math: count display rows by wrapping each source line into
|
|
`width` columns (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` (a `bg:` 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` (around `lib/bds/tui.ex:1968`): default
|
|
`preview? = false` for existing posts. New posts already open in
|
|
editor — unchanged.
|
|
- `body_widget/3` edit 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 `:text` through
|
|
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/1` continues per post; cycle through the active
|
|
set (`:markdown_macros`, `:liquid`, `:lua`, `:text`) matching the
|
|
existing GUI editor language options.
|
|
- Preview branch (`body_widget/3` preview arm at `lib/bds/tui.ex:1455`)
|
|
stays on `ExRatatui.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.log` first, grep for failures —
|
|
per AGENTS.md).
|
|
- `mix credo --strict`
|
|
- `mix deps.audit --ignore-file .mix_audit.ignore`
|
|
- `mix 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-syntax` in 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, default `preview?`).
|
|
- `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. |