206 lines
6.6 KiB
Elixir
206 lines
6.6 KiB
Elixir
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
|