feat: issue #32 markdown editor for TUI
This commit is contained in:
223
TUI_EDITOR.md
Normal file
223
TUI_EDITOR.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user