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