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.
|
||||
@@ -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."))
|
||||
|
||||
72
lib/bds/ui/code_editor/highlight.ex
Normal file
72
lib/bds/ui/code_editor/highlight.ex
Normal 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
|
||||
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
|
||||
205
lib/bds/ui/code_editor/widget.ex
Normal file
205
lib/bds/ui/code_editor/widget.ex
Normal 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
|
||||
@@ -33,12 +33,12 @@ rule SidebarNavigation {
|
||||
|
||||
rule OpenEntry {
|
||||
when: TuiKeyPressed(code: "enter")
|
||||
-- Posts open in the rendered markdown preview by default (title +
|
||||
-- textarea still seeded from the persisted form; ctrl+e switches to
|
||||
-- editing). New empty posts created with "n" open in the editor
|
||||
-- instead — there is nothing to preview yet. Images open in the
|
||||
-- terminal image preview. Other entities report that terminal
|
||||
-- editing is not available yet.
|
||||
-- Posts open in the editor by default (title + textarea seeded from
|
||||
-- the persisted form; syntax highlighting and word wrap on; ctrl+e
|
||||
-- switches to the rendered preview). New empty posts created with
|
||||
-- "n" open in the editor as well. Images open in the terminal image
|
||||
-- preview. Other entities report that terminal editing is not
|
||||
-- available yet.
|
||||
ensures: TuiState.editing_post.updated()
|
||||
}
|
||||
|
||||
@@ -50,17 +50,27 @@ rule SaveAndPublish {
|
||||
ensures: PostPersisted()
|
||||
}
|
||||
|
||||
rule WrappedPreview {
|
||||
rule EditorMode {
|
||||
when: TuiKeyPressed(code: "e", modifiers: "ctrl")
|
||||
-- ctrl+e toggles between the read-only word-wrapped Markdown
|
||||
-- preview (the default mode when opening a post — rendered through
|
||||
-- tui-markdown so headings/emphasis/lists are styled) and the
|
||||
-- editing textarea (which cannot soft-wrap, an upstream
|
||||
-- ratatui-textarea limitation); keys in preview never edit content,
|
||||
-- and esc returns to the sidebar from either mode.
|
||||
-- ctrl+e toggles between the syntax-highlighted editor (the default
|
||||
-- mode when opening a post) and the read-only rendered-Markdown
|
||||
-- preview (rendered through tui-markdown so headings/emphasis/lists
|
||||
-- are styled); keys in preview never edit content, and esc returns
|
||||
-- to the sidebar from either mode.
|
||||
ensures: PreviewToggled()
|
||||
}
|
||||
|
||||
rule CodeEditorWrapping {
|
||||
when: TuiState.editing_post.updated()
|
||||
-- The post editor renders the buffer with syntax highlighting
|
||||
-- (markdown with [[macro]] accents for posts, HTML+Liquid for
|
||||
-- templates, Lua for scripts), soft-wraps long lines to the
|
||||
-- viewport width, keeps the cursor's visual row visible while
|
||||
-- scrolling, and highlights the cursor's source line. There is no
|
||||
-- line-number gutter.
|
||||
ensures: TuiState.updated()
|
||||
}
|
||||
|
||||
rule AiQuickAction {
|
||||
when: TuiKeyPressed(code: "g", modifiers: "ctrl")
|
||||
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
|
||||
|
||||
@@ -38,14 +38,18 @@ defmodule BDS.TUITest do
|
||||
state
|
||||
end
|
||||
|
||||
defp screen_text(state, width \\ 100, height \\ 32) do
|
||||
defp screen_cells(state, width \\ 100, height \\ 32) do
|
||||
session = CellSession.new(width, height)
|
||||
frame = %ExRatatui.Frame{width: width, height: height}
|
||||
:ok = CellSession.draw(session, BDS.TUI.render(state, frame))
|
||||
snapshot = CellSession.take_cells(session)
|
||||
:ok = CellSession.close(session)
|
||||
|
||||
snapshot.cells
|
||||
end
|
||||
|
||||
defp screen_text(state, width \\ 100, height \\ 32) do
|
||||
state
|
||||
|> screen_cells(width, height)
|
||||
|> Enum.group_by(& &1.row)
|
||||
|> Enum.sort_by(fn {row, _} -> row end)
|
||||
|> Enum.map_join("\n", fn {_row, cells} ->
|
||||
@@ -53,6 +57,22 @@ defmodule BDS.TUITest do
|
||||
end)
|
||||
end
|
||||
|
||||
# The cell where `text` starts on screen (used to assert per-cell styles).
|
||||
defp cell_at_text(cells, text) do
|
||||
{_row, row_cells} =
|
||||
cells
|
||||
|> Enum.group_by(& &1.row)
|
||||
|> Enum.find(fn {_row, row_cells} ->
|
||||
row_cells |> Enum.sort_by(& &1.col) |> Enum.map_join("", & &1.symbol) |> String.contains?(text)
|
||||
end)
|
||||
|
||||
sorted = Enum.sort_by(row_cells, & &1.col)
|
||||
joined = Enum.map_join(sorted, & &1.symbol)
|
||||
{byte_offset, _len} = :binary.match(joined, text)
|
||||
char_offset = joined |> binary_part(0, byte_offset) |> String.length()
|
||||
Enum.at(sorted, char_offset)
|
||||
end
|
||||
|
||||
defp key(code, modifiers), do: %Key{code: code, kind: "press", modifiers: modifiers}
|
||||
|
||||
defp press(state, code, modifiers \\ []) do
|
||||
@@ -68,7 +88,7 @@ defmodule BDS.TUITest do
|
||||
assert text =~ "Hello TUI"
|
||||
end
|
||||
|
||||
test "opening a post lands in the markdown preview, not the editor", %{post: post} do
|
||||
test "opening a post lands in the editor with the raw source", %{post: post} do
|
||||
{:ok, _} =
|
||||
BDS.Posts.update_post(post.id, %{content: "# Big Heading\n\nsome **bold** words"})
|
||||
|
||||
@@ -76,19 +96,66 @@ defmodule BDS.TUITest do
|
||||
|
||||
assert state.focus == :editor
|
||||
assert state.editor.post.id == post.id
|
||||
refute state.editor.preview?
|
||||
|
||||
# The editor shows the raw markdown source.
|
||||
assert screen_text(state) =~ "**bold**"
|
||||
|
||||
# ctrl+e flips to the rendered markdown preview: inline markers are
|
||||
# consumed by the styling.
|
||||
state = press(state, "e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
|
||||
text = screen_text(state)
|
||||
assert text =~ "Big Heading"
|
||||
# Rendered markdown: inline markers are consumed by the styling
|
||||
# (headings keep their # but get styled, invisible in a text dump).
|
||||
assert text =~ "some bold words"
|
||||
refute text =~ "**bold**"
|
||||
|
||||
# ctrl+e drops into the editing textarea with the raw source.
|
||||
# and back to the editor.
|
||||
state = press(state, "e", ["ctrl"])
|
||||
refute state.editor.preview?
|
||||
assert ExRatatui.textarea_get_value(state.editor.textarea) =~ "**bold**"
|
||||
assert screen_text(state) =~ "**bold**"
|
||||
end
|
||||
|
||||
test "the editor word-wraps long lines", %{post: post} do
|
||||
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
refute state.editor.preview?
|
||||
|
||||
# The tail of a long line wraps onto visible rows without any
|
||||
# horizontal scrolling.
|
||||
assert screen_text(state, 80, 32) =~ "FINALWORD"
|
||||
end
|
||||
|
||||
test "the cursor cell stays visible when the cursor sits below the viewport", %{post: post} do
|
||||
content = Enum.map_join(1..30, "\n", &"line #{&1}")
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: content})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
|
||||
# The cursor opens at the end of the buffer — source row 29 of 30.
|
||||
assert ExRatatui.textarea_cursor(state.editor.textarea) == {29, 7}
|
||||
|
||||
cells = screen_cells(state)
|
||||
# The cursor cell is the single space carrying the cursor background
|
||||
# inside the editor area (the sidebar selection also uses a cyan bg).
|
||||
assert Enum.any?(cells, &(&1.bg == :cyan and &1.symbol == " " and &1.col >= 34))
|
||||
end
|
||||
|
||||
test "the cursor line has a distinct background from the other lines", %{post: post} do
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: "first\nsecond\nthird"})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
|
||||
# Move the cursor from the end of the buffer onto the first line.
|
||||
state = state |> press("up") |> press("up")
|
||||
assert ExRatatui.textarea_cursor(state.editor.textarea) == {0, 5}
|
||||
|
||||
cells = screen_cells(state)
|
||||
assert cell_at_text(cells, "first").bg == :dark_gray
|
||||
refute cell_at_text(cells, "second").bg == :dark_gray
|
||||
end
|
||||
|
||||
test "ctrl+s persists textarea edits", %{post: post} do
|
||||
@@ -126,26 +193,17 @@ defmodule BDS.TUITest do
|
||||
assert state.focus == :sidebar
|
||||
end
|
||||
|
||||
test "the preview word-wraps and ctrl+e toggles back and forth", %{post: post} do
|
||||
test "the preview word-wraps long lines", %{post: post} do
|
||||
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
|
||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
|
||||
|
||||
state = mount!() |> press("enter")
|
||||
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
|
||||
# The textarea cannot soft-wrap, so the tail of a long line is off-screen
|
||||
# there; the preview renders through the Markdown widget, which wraps.
|
||||
assert screen_text(state, 80, 32) =~ "FINALWORD"
|
||||
|
||||
state = press(state, "e", ["ctrl"])
|
||||
refute state.editor.preview?
|
||||
|
||||
state = press(state, "e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
end
|
||||
|
||||
test "keys in preview mode do not edit the content" do
|
||||
state = mount!() |> press("enter")
|
||||
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
||||
assert state.editor.preview?
|
||||
before = ExRatatui.textarea_get_value(state.editor.textarea)
|
||||
|
||||
|
||||
199
test/bds/ui/code_editor_test.exs
Normal file
199
test/bds/ui/code_editor_test.exs
Normal file
@@ -0,0 +1,199 @@
|
||||
defmodule BDS.UI.CodeEditorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BDS.UI.CodeEditor
|
||||
alias BDS.UI.CodeEditor.Highlight
|
||||
alias ExRatatui.Layout.Rect
|
||||
alias ExRatatui.Style
|
||||
alias ExRatatui.Text.Line
|
||||
alias ExRatatui.Widgets.Paragraph
|
||||
alias ExRatatui.Widgets.Textarea
|
||||
|
||||
@theme :base16_ocean_dark
|
||||
@rect %Rect{x: 0, y: 0, width: 40, height: 10}
|
||||
|
||||
defp find_span(line, text) do
|
||||
Enum.find(line.spans, fn span -> String.contains?(span.content, text) end)
|
||||
end
|
||||
|
||||
defp editor_widget(content, opts \\ []) do
|
||||
textarea = ExRatatui.textarea_new()
|
||||
:ok = ExRatatui.textarea_insert_str(textarea, content)
|
||||
|
||||
widget = %CodeEditor{
|
||||
textarea: textarea,
|
||||
language: Keyword.get(opts, :language, :markdown_macros),
|
||||
cursor_style: Keyword.get(opts, :cursor_style, %Style{bg: :cyan}),
|
||||
line_highlight_style: Keyword.get(opts, :line_highlight_style, %Style{}),
|
||||
block: Keyword.get(opts, :block),
|
||||
scroll: Keyword.get(opts, :scroll, {0, 0})
|
||||
}
|
||||
|
||||
{widget, textarea}
|
||||
end
|
||||
|
||||
defp render(widget, rect \\ @rect) do
|
||||
[{paragraph, ^rect}] = ExRatatui.Widget.render(widget, rect)
|
||||
paragraph
|
||||
end
|
||||
|
||||
describe "Highlight.highlight/3 — :lua" do
|
||||
test "colours keywords with the theme keyword colour, distinct from identifiers" do
|
||||
[first, _middle, last] =
|
||||
Highlight.highlight("local function foo()\n return 1\nend", :lua, @theme)
|
||||
|
||||
keyword = find_span(first, "function")
|
||||
assert keyword.style.fg != nil
|
||||
assert find_span(last, "end").style.fg == keyword.style.fg
|
||||
refute find_span(first, "foo").style.fg == keyword.style.fg
|
||||
end
|
||||
end
|
||||
|
||||
describe "Highlight.highlight/3 — :markdown_macros" do
|
||||
test "recolours [[macro]] syntax with the GUI accent colours" do
|
||||
[line] = Highlight.highlight(~s([[gallery names="x"]]), :markdown_macros, @theme)
|
||||
|
||||
opener = find_span(line, "[[gallery")
|
||||
assert opener.style.fg == {:rgb, 197, 134, 192}
|
||||
assert :bold in opener.style.modifiers
|
||||
|
||||
closer = find_span(line, "]]")
|
||||
assert closer.style.fg == {:rgb, 197, 134, 192}
|
||||
assert :bold in closer.style.modifiers
|
||||
|
||||
assert find_span(line, "names").style.fg == {:rgb, 156, 220, 254}
|
||||
assert find_span(line, ~s("x")).style.fg == {:rgb, 206, 145, 120}
|
||||
end
|
||||
|
||||
test "keeps the base markdown styling outside macros" do
|
||||
content = ~s(before [[gallery names="x"]] after)
|
||||
|
||||
[overlayed] = Highlight.highlight(content, :markdown_macros, @theme)
|
||||
[base] = ExRatatui.CodeBlock.highlight(content, "markdown", @theme)
|
||||
|
||||
base_style = find_span(base, "before").style
|
||||
assert find_span(overlayed, "before").style == base_style
|
||||
assert find_span(overlayed, "after").style == base_style
|
||||
end
|
||||
|
||||
test "highlights the macro over a styled markdown base" do
|
||||
[line] = Highlight.highlight("some **bold** [[macro]] words", :markdown_macros, @theme)
|
||||
|
||||
# Bold markdown keeps its base styling while the macro is recoloured.
|
||||
assert :bold in find_span(line, "bold").style.modifiers
|
||||
assert find_span(line, "[[macro").style.fg == {:rgb, 197, 134, 192}
|
||||
assert find_span(line, "]]").style.fg == {:rgb, 197, 134, 192}
|
||||
end
|
||||
end
|
||||
|
||||
describe "Highlight.highlight/3 — :liquid" do
|
||||
test "recolours output tags over the HTML base" do
|
||||
[line] = Highlight.highlight("<div>{{ x }}</div>", :liquid, @theme)
|
||||
|
||||
assert find_span(line, "{{").style.fg == {:rgb, 86, 156, 214}
|
||||
assert find_span(line, "}}").style.fg == {:rgb, 86, 156, 214}
|
||||
# The HTML base styling survives outside the tag.
|
||||
assert find_span(line, "div").style.fg != nil
|
||||
end
|
||||
|
||||
test "recolours the tag keyword distinctly from identifiers" do
|
||||
[line] = Highlight.highlight("{% if x %}y{% endif %}", :liquid, @theme)
|
||||
|
||||
keyword = find_span(line, "if")
|
||||
assert keyword.style.fg == {:rgb, 86, 156, 214}
|
||||
assert find_span(line, "endif").style.fg == keyword.style.fg
|
||||
refute find_span(line, " x ").style.fg == keyword.style.fg
|
||||
end
|
||||
end
|
||||
|
||||
describe "Highlight.highlight/3 — line bookkeeping" do
|
||||
test "empty content yields a single empty line" do
|
||||
assert [%Line{spans: []}] = Highlight.highlight("", :markdown_macros, @theme)
|
||||
end
|
||||
|
||||
test "trailing empty lines are padded back to the textarea's line count" do
|
||||
lines = Highlight.highlight("a\n\n", :text, @theme)
|
||||
assert length(lines) == 3
|
||||
end
|
||||
|
||||
test "spans never contain newline characters" do
|
||||
lines = Highlight.highlight("a\nb\n", :text, @theme)
|
||||
|
||||
for line <- lines, span <- line.spans do
|
||||
refute String.contains?(span.content, "\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "ExRatatui.Widget rendering" do
|
||||
test "emits a wrapping Paragraph, never a bare Textarea, for every highlighted language" do
|
||||
for language <- [:markdown_macros, :liquid, :lua, :text] do
|
||||
{widget, _textarea} = editor_widget("hello", language: language)
|
||||
paragraph = render(widget)
|
||||
|
||||
assert %Paragraph{wrap: true, scroll: {_top, 0}} = paragraph
|
||||
refute match?(%Textarea{}, paragraph)
|
||||
end
|
||||
end
|
||||
|
||||
test "splices the cursor cell into the character under the cursor" do
|
||||
{widget, textarea} = editor_widget("hello")
|
||||
:ok = ExRatatui.textarea_handle_key(textarea, "home", [])
|
||||
|
||||
%Paragraph{text: [line | _rest]} = render(widget)
|
||||
|
||||
cursor_span = Enum.find(line.spans, &(&1.style.bg == :cyan))
|
||||
assert cursor_span.content == "h"
|
||||
assert Enum.map_join(line.spans, & &1.content) == "hello"
|
||||
end
|
||||
|
||||
test "appends a cursor cell when the cursor sits at the end of the line" do
|
||||
{widget, _textarea} = editor_widget("hello")
|
||||
|
||||
%Paragraph{text: [line | _rest]} = render(widget)
|
||||
|
||||
cursor_span = List.last(line.spans)
|
||||
assert cursor_span.content == " "
|
||||
assert cursor_span.style.bg == :cyan
|
||||
end
|
||||
|
||||
test "highlights the cursor line with the line highlight style" do
|
||||
{widget, _textarea} =
|
||||
editor_widget("ab\ncd",
|
||||
language: :text,
|
||||
cursor_style: %Style{},
|
||||
line_highlight_style: %Style{bg: :dark_gray}
|
||||
)
|
||||
|
||||
%Paragraph{text: lines} = render(widget)
|
||||
|
||||
# The cursor opens at the end of the second line.
|
||||
assert Enum.all?(Enum.at(lines, 1).spans, &(&1.style.bg == :dark_gray))
|
||||
assert Enum.all?(Enum.at(lines, 0).spans, &(&1.style.bg != :dark_gray))
|
||||
end
|
||||
|
||||
test "scrolls so the cursor visual row stays inside the window" do
|
||||
content = Enum.map_join(1..30, "\n", &"line #{&1}")
|
||||
rect = %Rect{x: 0, y: 0, width: 20, height: 5}
|
||||
{widget, _textarea} = editor_widget(content, rect: rect)
|
||||
|
||||
# The cursor opens on source row 29: 30 visual rows, window height 5.
|
||||
assert %Paragraph{scroll: {25, 0}} = render(widget, rect)
|
||||
end
|
||||
|
||||
test "wrapped source lines count towards the cursor's visual row" do
|
||||
rect = %Rect{x: 0, y: 0, width: 4, height: 2}
|
||||
{widget, _textarea} = editor_widget("abcdefghij", rect: rect)
|
||||
|
||||
# 10 characters in a 4-wide window wrap to 3 visual rows; the cursor
|
||||
# at the end of the line lands on the third.
|
||||
assert %Paragraph{scroll: {1, 0}} = render(widget, rect)
|
||||
end
|
||||
|
||||
test "keeps the scroll offset at zero while the cursor is visible" do
|
||||
{widget, _textarea} = editor_widget("a\nb\nc")
|
||||
|
||||
assert %Paragraph{scroll: {0, 0}} = render(widget)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user