Current section

Files

Jump to
cringe lib cringe canvas.ex
Raw

lib/cringe/canvas.ex

defmodule Cringe.Canvas do
@moduledoc """
Fixed-size render surface for terminal lines.
"""
alias Cringe.Measure
@enforce_keys [:width, :height, :lines]
defstruct [:width, :height, :lines]
@type t :: %__MODULE__{width: non_neg_integer(), height: non_neg_integer(), lines: [String.t()]}
@spec new(non_neg_integer(), non_neg_integer()) :: t()
def new(width, height) when width >= 0 and height >= 0 do
%__MODULE__{width: width, height: height, lines: List.duplicate(blank(width), height)}
end
@spec put(t(), non_neg_integer(), non_neg_integer(), String.t()) :: t()
def put(%__MODULE__{} = canvas, 0, y, text)
when is_integer(y) and y >= 0 and is_binary(text) do
if y < canvas.height do
update_line(canvas, y, fn _line -> fit_line(text, canvas.width) end)
else
canvas
end
end
def put(%__MODULE__{} = canvas, x, y, text)
when is_integer(x) and x >= 0 and is_integer(y) and y >= 0 and is_binary(text) do
if y < canvas.height and x < canvas.width do
update_line(canvas, y, &put_text(&1, x, text, canvas.width))
else
canvas
end
end
@spec put_block(t(), non_neg_integer(), non_neg_integer(), [String.t()]) :: t()
def put_block(%__MODULE__{} = canvas, 0, y, lines)
when is_integer(y) and y >= 0 and is_list(lines) do
if y < canvas.height do
replacements =
lines
|> Enum.take(canvas.height - y)
|> Enum.map(&fit_line(&1, canvas.width))
%{canvas | lines: replace_lines(canvas.lines, y, replacements)}
else
canvas
end
end
def put_block(%__MODULE__{} = canvas, x, y, lines) when is_list(lines) do
lines
|> Enum.with_index(y)
|> Enum.reduce(canvas, fn {line, row}, acc -> put(acc, x, row, line) end)
end
@spec put_block(t(), non_neg_integer(), non_neg_integer(), [String.t()], keyword()) :: t()
def put_block(%__MODULE__{} = canvas, x, y, lines, opts)
when is_list(lines) and is_list(opts) do
case Keyword.get(opts, :clip) do
nil -> put_block(canvas, x, y, lines)
%Cringe.Rect{} = clip -> put_clipped_block(canvas, x, y, lines, clip)
end
end
@spec lines(t()) :: [String.t()]
def lines(%__MODULE__{lines: lines}), do: lines
defp update_line(canvas, y, fun) do
lines = List.update_at(canvas.lines, y, fun)
%{canvas | lines: lines}
end
defp replace_lines(lines, y, replacements) do
{prefix, rest} = Enum.split(lines, y)
prefix ++ replacements ++ Enum.drop(rest, length(replacements))
end
defp put_clipped_block(canvas, x, y, lines, clip) do
lines
|> Enum.with_index(y)
|> Enum.reduce(canvas, fn {line, row}, acc -> put_clipped_line(acc, x, row, line, clip) end)
end
defp put_clipped_line(canvas, x, y, line, clip) do
line_width = Measure.width(line)
left = max(x, clip.x)
right = min(x + line_width, clip.x + clip.width)
if y >= clip.y and y < clip.y + clip.height and right > left do
visible = clipped_text(line, left - x, right - left)
put(canvas, left, y, visible)
else
canvas
end
end
defp clipped_text(line, offset, width), do: Measure.slice(line, offset, width)
defp fit_line(text, width) do
text
|> Measure.take(width)
|> Measure.pad(width)
end
defp put_text(line, x, text, width) do
left = Measure.take(line, x)
visible_width = min(Measure.width(text), width - x)
visible = Measure.take(text, visible_width)
right = line |> drop_visible(x + visible_width) |> Measure.take(width)
Measure.pad(left <> visible <> right, width)
end
defp drop_visible(line, count) do
line
|> Cringe.ANSI.strip()
|> String.graphemes()
|> Enum.drop(count)
|> Enum.join()
end
defp blank(width), do: String.duplicate(" ", width)
end