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