feat: issue #32 markdown editor for TUI
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user