diff --git a/TUI_EDITOR.md b/TUI_EDITOR.md new file mode 100644 index 0000000..679767a --- /dev/null +++ b/TUI_EDITOR.md @@ -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. \ No newline at end of file diff --git a/lib/bds/tui.ex b/lib/bds/tui.ex index c2159ce..0dd2f4e 100644 --- a/lib/bds/tui.ex +++ b/lib/bds/tui.ex @@ -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.")) diff --git a/lib/bds/ui/code_editor/highlight.ex b/lib/bds/ui/code_editor/highlight.ex new file mode 100644 index 0000000..4a62e14 --- /dev/null +++ b/lib/bds/ui/code_editor/highlight.ex @@ -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 diff --git a/lib/bds/ui/code_editor/overlay.ex b/lib/bds/ui/code_editor/overlay.ex new file mode 100644 index 0000000..3cb08ef --- /dev/null +++ b/lib/bds/ui/code_editor/overlay.ex @@ -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 diff --git a/lib/bds/ui/code_editor/widget.ex b/lib/bds/ui/code_editor/widget.ex new file mode 100644 index 0000000..d184241 --- /dev/null +++ b/lib/bds/ui/code_editor/widget.ex @@ -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 diff --git a/specs/tui.allium b/specs/tui.allium index f79da71..a6549aa 100644 --- a/specs/tui.allium +++ b/specs/tui.allium @@ -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 diff --git a/test/bds/tui_test.exs b/test/bds/tui_test.exs index 8756d52..92cd5b4 100644 --- a/test/bds/tui_test.exs +++ b/test/bds/tui_test.exs @@ -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) diff --git a/test/bds/ui/code_editor_test.exs b/test/bds/ui/code_editor_test.exs new file mode 100644 index 0000000..f1443cd --- /dev/null +++ b/test/bds/ui/code_editor_test.exs @@ -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("
{{ x }}
", :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