RasterExRatatui.Raster (RasterExRatatui v0.4.0)

Copy Markdown View Source

Turns ExRatatui.CellSession payloads into packed pixels for a panel.

A raster is a pure value: the panel geometry, a font, a pixel format, an integer scale, and the RasterExRatatui.Grid of the current frame. Feed it every snapshot or diff the session produces and it answers with the RasterExRatatui.Patch rectangles that changed, packed and ready to write.

raster = Raster.new(size: {400, 300}, font: Default6x8, format: Mono)
{cols, rows} = Raster.grid_size(raster)
session = CellSession.new(cols, rows, font_size: Raster.font_size(raster))

:ok = CellSession.draw(session, widgets)
{raster, patches} = Raster.apply(raster, CellSession.take_cells_diff(session))

Geometry

The effective cell is the font's cell times scale:; it is what the session must be created with as font_size:, so pixel-mode widgets render bitmaps at the panel's resolution. The grid is the panel size divided by the effective cell, rounded down; the leftover right and bottom strips (margin/1) are painted with the format's blank pixel.

Patches

apply/2 rasterises only what changed:

  • changed cells, one patch per contiguous run on a row
  • when the region list changed, one patch per region that is new or whose bitmap or rect changed (the bitmap scaled nearest-neighbour onto its cell rect, clipped to the grid, in one native pass by ExRatatui.Pixels.pack_rgb8/5), plus the cells a region that went away no longer covers. A region that is the same as in the previous payload costs nothing, so a still image next to an animated one is rasterised once. Overlapping regions stay correct: an unchanged region that touches a repainted area is repainted too, in list order
  • on a full payload (the first diff, a snapshot, a resize), every run of cells on every row, every region, and the margins

Cells under a pixel region are never rasterised, on full payloads or diffs: they arrive blank and the region paints over them.

apply/2 also takes a list of payloads and rasterises them as one: the grid is folded through all of them first, so a cell or region that changed several times is drawn once, in its final state. A consumer that falls behind its app (a slow panel, a big pixel region) folds everything queued into one call instead of drawing frames nobody will see; the surface process does exactly that.

Patches must be written in list order. frame/1 renders the whole panel as one buffer for panels that only take full frames (render_frame/1 does the same and keeps the glyph cache it fills); writing apply/2's patches over the previous frame/1 gives the next one.

Rotation

A panel mounted on its side keeps its native scan order: /dev/fb0 on a portrait display is portrait however the stand holds it. rotate: 90 | 180 | 270 turns the app's image clockwise on the way to the panel. :size stays the physical panel; the grid is computed from the logical size, which is the physical size with width and height swapped for 90 and 270 (logical_size/1); and every patch, frame/1, and margin/1 come out in physical coordinates, so whatever writes the panel does not know the difference.

rotate: 90 on a 720×1280 panel      logical (x, y) → physical
                                      90:  (W - 1 - y, x)
  logical 1280×720  ─┐               180:  (W - 1 - x, H - 1 - y)
  ┌───────────────┐  │ clockwise     270:  (y, H - 1 - x)
  │ A B C         │  │               W, H: the physical size
  └───────────────┘  ▼
                  ┌─────┐
                  │ A   │  physical 720×1280
                  │ B   │  (the app's top edge is on the right)
                  │ C   │
                  └─────┘

Glyphs are rotated once as they enter the cache; a run of cells becomes a vertical strip; a pixel region's bitmap is sampled, turned and packed in the same native pass as a flat one, so both cost the same. Dithering and checkerboards stay anchored to the panel's own pixel grid, which is what a 1-bit panel wants.

Summary

Types

What apply/2 folds in: a cell diff or a snapshot from an ExRatatui.CellSession.

Clockwise rotation of the app's image on the panel, in degrees.

t()

Functions

Folds a snapshot or diff, or a list of them in order, into the raster and returns the patches that repaint what changed.

Bytes per packed pixel, from the format.

The cell under a physical pixel, {col, row}, or :outside for a pixel in the margins or off the panel.

Effective cell size in pixels (the font's cell times the scale): the font_size: for ExRatatui.CellSession.new/3.

Renders the whole panel as one row-major buffer of width * height packed pixels: cells, then regions, then margins.

The raster's current grid.

Grid size in cells, {cols, rows}: the size to create the ExRatatui.CellSession with.

The size of the app's image in pixels, {width, height}: the physical size, with width and height swapped when the raster rotates by 90 or 270.

The unused strips on the right and at the bottom of the app's image, in pixels, {right, bottom}. On a rotated raster they are on the right and at the bottom as the app sees them; on the panel they are wherever the rotation puts them.

Builds a raster for a panel.

Like frame/1, but also returns the raster with every glyph it rendered added to its cache. Use it when rendering frames repeatedly (a panel that only takes full frames) and keep the returned raster.

Rebuilds the geometry for a new physical panel size, keeping font, format, scale, rotation, and the glyph cache, and clears the grid.

How far clockwise the app's image is turned on the panel: 0, 90, 180, or 270.

The physical panel size in pixels, {width, height}: what patches and frame/1 are laid out for.

Renders the whole panel as a PNG image: 8-bit RGB, the panel's physical size, what frame/1 holds turned back into colours by the format's RasterExRatatui.PixelFormat.unpack_row/2.

Types

payload()

What apply/2 folds in: a cell diff or a snapshot from an ExRatatui.CellSession.

rotation()

@type rotation() :: 0 | 90 | 180 | 270

Clockwise rotation of the app's image on the panel, in degrees.

size()

@type size() :: {pos_integer(), pos_integer()}

t()

@type t() :: %RasterExRatatui.Raster{
  blank: binary(),
  bytes_per_pixel: pos_integer(),
  cache: map(),
  cell_size: size(),
  config: RasterExRatatui.PixelFormat.config(),
  font: RasterExRatatui.Font.t(),
  format: RasterExRatatui.PixelFormat.t(),
  grid: RasterExRatatui.Grid.t(),
  grid_size: size(),
  logical_size: size(),
  native_format: ExRatatui.Pixels.format() | nil,
  rotate: rotation(),
  scale: pos_integer(),
  size: size()
}

Functions

apply(raster, payloads)

@spec apply(t(), payload() | [payload()]) :: {t(), [RasterExRatatui.Patch.t()]}

Folds a snapshot or diff, or a list of them in order, into the raster and returns the patches that repaint what changed.

See the moduledoc for which patches are produced. A list produces the patches of its last state only, never of the states in between; an empty list produces none.

Examples

iex> alias ExRatatui.CellSession.{Cell, Diff}
iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> raster = Raster.new(size: {12, 8}, format: PixelFormat.Mono)
iex> full = %Diff{width: 2, height: 1, ops: [%Cell{col: 0, symbol: "A"}, %Cell{col: 1}]}
iex> {raster, [row]} = Raster.apply(raster, full)
iex> {row.x, row.y, row.width, row.height, byte_size(row.data)}
{0, 0, 12, 8, 96}
iex> {_raster, [cell]} = Raster.apply(raster, %Diff{width: 2, height: 1, ops: [%Cell{col: 1, symbol: "B"}]})
iex> {cell.x, cell.width}
{6, 6}
iex> twice = [%Diff{width: 2, height: 1, ops: [%Cell{col: 0, symbol: "C"}]}, %Diff{width: 2, height: 1, ops: [%Cell{col: 0, symbol: "D"}]}]
iex> {_raster, [once]} = Raster.apply(raster, twice)
iex> {once.x, once.width}
{0, 6}

bytes_per_pixel(raster)

@spec bytes_per_pixel(t()) :: pos_integer()

Bytes per packed pixel, from the format.

Examples

iex> RasterExRatatui.Raster.new(size: {64, 64}, format: RasterExRatatui.PixelFormat.RGB565) |> RasterExRatatui.Raster.bytes_per_pixel()
2

cell_at(raster, arg)

@spec cell_at(t(), {integer(), integer()}) ::
  {non_neg_integer(), non_neg_integer()} | :outside

The cell under a physical pixel, {col, row}, or :outside for a pixel in the margins or off the panel.

The inverse of the rotation mapping in the moduledoc: what a touch controller, which reports the panel's own coordinates, needs to find the cell the app sees under a finger.

Examples

A 12×16 panel with the 6×8 font: two columns and two rows flat, two columns and one row turned.

iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> flat = Raster.new(size: {12, 16}, format: PixelFormat.Mono)
iex> {Raster.cell_at(flat, {7, 9}), Raster.cell_at(flat, {12, 0}), Raster.cell_at(flat, {-1, 0})}
{{1, 1}, :outside, :outside}

iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> at = fn angle, point -> Raster.cell_at(Raster.new(size: {12, 16}, format: PixelFormat.Mono, rotate: angle), point) end
iex> {at.(90, {5, 10}), at.(180, {7, 9}), at.(270, {5, 10})}
{{1, 0}, {0, 0}, {0, 0}}

Turned by 90, the app's 4-pixel right margin is at the bottom of the panel and its bottom margin on the panel's left:

iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> turned = Raster.new(size: {12, 16}, format: PixelFormat.Mono, rotate: 90)
iex> {Raster.cell_at(turned, {4, 11}), Raster.cell_at(turned, {4, 12}), Raster.cell_at(turned, {3, 11})}
{{1, 0}, :outside, :outside}

font_size(raster)

@spec font_size(t()) :: size()

Effective cell size in pixels (the font's cell times the scale): the font_size: for ExRatatui.CellSession.new/3.

Examples

iex> RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono, scale: 2) |> RasterExRatatui.Raster.font_size()
{12, 16}

frame(raster)

@spec frame(t()) :: binary()

Renders the whole panel as one row-major buffer of width * height packed pixels: cells, then regions, then margins.

Examples

iex> alias ExRatatui.CellSession.{Cell, Snapshot}
iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> raster = Raster.new(size: {8, 9}, format: PixelFormat.Mono)
iex> {raster, _patches} = Raster.apply(raster, %Snapshot{width: 1, height: 1, cells: [%Cell{symbol: "█"}]})
iex> frame = Raster.frame(raster)
iex> {byte_size(frame), :binary.at(frame, 0), :binary.at(frame, 6), :binary.at(frame, 8 * 8)}
{72, 0, 255, 255}

grid(raster)

@spec grid(t()) :: RasterExRatatui.Grid.t()

The raster's current grid.

grid_size(raster)

@spec grid_size(t()) :: size()

Grid size in cells, {cols, rows}: the size to create the ExRatatui.CellSession with.

Examples

iex> RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono) |> RasterExRatatui.Raster.grid_size()
{66, 37}

logical_size(raster)

@spec logical_size(t()) :: size()

The size of the app's image in pixels, {width, height}: the physical size, with width and height swapped when the raster rotates by 90 or 270.

Examples

iex> RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono, rotate: 270) |> RasterExRatatui.Raster.logical_size()
{300, 400}

margin(raster)

@spec margin(t()) :: {non_neg_integer(), non_neg_integer()}

The unused strips on the right and at the bottom of the app's image, in pixels, {right, bottom}. On a rotated raster they are on the right and at the bottom as the app sees them; on the panel they are wherever the rotation puts them.

Examples

iex> RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono) |> RasterExRatatui.Raster.margin()
{4, 4}

iex> RasterExRatatui.Raster.new(size: {300, 400}, format: RasterExRatatui.PixelFormat.Mono, rotate: 90) |> RasterExRatatui.Raster.margin()
{4, 4}

new(opts)

@spec new(keyword()) :: t()

Builds a raster for a panel.

Options

Raises ArgumentError when :size or :format is missing, when :size is not a pair of positive integers, when :scale is not a positive integer, when :rotate is not one of the four angles, or when not even one cell fits on the panel.

Examples

iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> raster = Raster.new(size: {1920, 1080}, format: PixelFormat.XRGB8888, scale: 3)
iex> {Raster.grid_size(raster), Raster.font_size(raster), Raster.margin(raster)}
{{106, 45}, {18, 24}, {12, 0}}

A portrait panel on a landscape stand: the grid is that of the turned image.

iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> raster = Raster.new(size: {720, 1280}, format: PixelFormat.RGB565, scale: 2, rotate: 90)
iex> {Raster.size(raster), Raster.logical_size(raster), Raster.grid_size(raster)}
{{720, 1280}, {1280, 720}, {106, 45}}

render_frame(raster)

@spec render_frame(t()) :: {t(), binary()}

Like frame/1, but also returns the raster with every glyph it rendered added to its cache. Use it when rendering frames repeatedly (a panel that only takes full frames) and keep the returned raster.

Examples

iex> alias ExRatatui.CellSession.{Cell, Snapshot}
iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> raster = Raster.new(size: {12, 8}, format: PixelFormat.Mono)
iex> {raster, _patches} = Raster.apply(raster, %Snapshot{width: 2, height: 1, cells: [%Cell{symbol: "A"}]})
iex> {rendered, frame} = Raster.render_frame(%{raster | cache: %{}})
iex> {frame == Raster.frame(raster), map_size(rendered.cache) > 0}
{true, true}

resize(raster, size)

@spec resize(t(), size()) :: t()

Rebuilds the geometry for a new physical panel size, keeping font, format, scale, rotation, and the glyph cache, and clears the grid.

After a resize the ExRatatui.CellSession must be resized to the new grid_size/1; its next diff is a full payload, so apply/2 repaints the whole panel.

Examples

iex> alias RasterExRatatui.Raster
iex> raster = Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono)
iex> raster |> Raster.resize({120, 80}) |> Raster.grid_size()
{20, 10}

iex> alias RasterExRatatui.Raster
iex> raster = Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono, rotate: 90)
iex> raster |> Raster.resize({80, 120}) |> Raster.grid_size()
{20, 10}

rotate(raster)

@spec rotate(t()) :: rotation()

How far clockwise the app's image is turned on the panel: 0, 90, 180, or 270.

Examples

iex> RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono) |> RasterExRatatui.Raster.rotate()
0

size(raster)

@spec size(t()) :: size()

The physical panel size in pixels, {width, height}: what patches and frame/1 are laid out for.

Examples

iex> RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono) |> RasterExRatatui.Raster.size()
{400, 300}

to_png(raster)

@spec to_png(t()) :: binary()

Renders the whole panel as a PNG image: 8-bit RGB, the panel's physical size, what frame/1 holds turned back into colours by the format's RasterExRatatui.PixelFormat.unpack_row/2.

For tests and debugging: a snapshot of what the panel would show, written with File.write!/2 and opened in any image viewer. Pure Elixir, no dependency.

Raises ArgumentError when the format does not implement RasterExRatatui.PixelFormat.unpack_row/2.

Examples

iex> alias RasterExRatatui.{Raster, PixelFormat}
iex> png = Raster.to_png(Raster.new(size: {12, 8}, format: PixelFormat.RGB565))
iex> <<137, "PNG", 13, 10, 26, 10, 13::32, "IHDR", width::32, height::32, 8, 2, _::binary>> = png
iex> {width, height}
{12, 8}