RasterExRatatui renders ExRatatui apps on pixel displays that are not terminals, such as e-ink panels and Linux framebuffers. It consumes ExRatatui.CellSession diffs (cells plus pixel regions) and produces packed pixels. It does not drive hardware itself: the consumer writes the bytes.
It is not a terminal emulator, a font renderer, or a display driver, and apps need no changes to run on it. Build the app with use ExRatatui.App as for a terminal; the surface runs it.
Choosing the layer
| Use | When |
|---|---|
use RasterExRatatui.Framebuffer.Surface, app: MyApp | A Linux framebuffer (/dev/fb0) with an evdev keyboard: one line, every default an option or an override |
use RasterExRatatui.Surface | Any other panel. A supervised process owns the app, rasterises, and calls push/2 |
RasterExRatatui.Session | The consumer already owns a process in charge of the panel: the same app server, raster, and folding, driven from that process's mailbox |
RasterExRatatui.Raster directly | Rasterising payloads that come from somewhere else (a headless CellSession, a test) |
Framebuffer surfaces
defmodule MyDevice.Surface do
use RasterExRatatui.Framebuffer.Surface, app: MyDevice.App, rotate: 90
end- Never hardcode the panel: size and depth come from sysfs, the format from the depth, the scale from the long side (
scale: :auto). Override withscale:,framebuffer:,console:,keyboard:,touch:,rotate:. touch: truedelivers%ExRatatui.Event.Mouse{}events to the app on the cell under the finger, rotation included; handle"down","drag","up"in the app like terminal mouse events.on_app_exitdefaults to:restarthere (a panel has nothing else to show);push_modeis:patches.- To extend, override
init/1,push/2,handle_info/2, orterminate/2and callRasterExRatatui.Framebuffer.Surface.<same>/nfrom the override. The state is%{fb:, devices:}plus whatever the override adds. - Add
{:input_event, "~> 1.4"}to the deps for the keyboard. Without it the surface logs once and runs input-less; do not addinput_eventto a host-only project. - Test against a fake tree with
root:(sysfs files + an emptydev/fb0) andinput:(a stub module withenumerate/0,start_link/1,stop/1).
Surfaces
defmodule MyDevice.Surface do
use RasterExRatatui.Surface, app: MyDevice.App, format: RasterExRatatui.PixelFormat.RGB565, scale: 2
@impl true
def init(opts), do: {:ok, [size: {480, 320}], MyDevice.LCD.open!(opts)}
@impl true
def push(patches, lcd) do
Enum.each(patches, &MyDevice.LCD.write(lcd, &1.x, &1.y, &1.width, &1.height, &1.data))
lcd
end
endpush/2is the only required callback. It receives[%RasterExRatatui.Patch{}](default) or{:frame, binary}withpush_mode: :frame, and must return the new state, not:ok.init/1returns{:ok, extra_opts, state}: a three-tuple.extra_optsis a keyword list merged over theuse/start_linkoptions, for values known only at runtime (:size,:format).- Options merge in order
use<start_link<init/1's keyword.:app,:size, and:formatare required somewhere in that chain.rotate:(90, 180, 270) turns the app for a panel on its side;:sizestays physical andpush/2does not change. - Write patches in list order; a region patch may overlap earlier cell patches.
datais row-major with no stride padding. - Send input with
RasterExRatatui.Surface.send_event(surface, %ExRatatui.Event.Key{code: "enter", kind: "press"}). Codes are lowercase strings andkindis the string"press", never an atom. - From
handle_info/2, return{:events, [event], state}to forward events,{:noreply, state}otherwise. app_optsalways carrysurface: %{size:, cell_size:, grid_size:, format:, scale:, rotate:}(the panel in pixels, the effective cell, the grid in cells). Read it inmount/1or the reducer'sinit/1to lay the app out for its panel; never hardcode the cell size.on_app_exit: :stop(default) makes the surface exit when the app exits (same reason);on_app_exit: :restartstarts the app again on the same surface at once, until it crashes more thanmax_restarts:(3) times withinmax_seconds:(5), when the surface stops and its supervisor takes over. Quits (:normal,:shutdown) are never counted. Put the surface under a supervisor either way; do not restart the app by hand inside it. Both emit[:raster_ex_ratatui, :app, :exit]with:reasonand:action.min_interval:(ms) throttles pushes for slow panels. The surface never builds a backlog: every render waiting when it gets to work is folded into oneRaster.apply/2call (it takes a list) and one push, so slow panels or big regions show fewer frames, never later ones.[:raster_ex_ratatui, :frame, :raster]reports:diffsper batch.- The generated child spec is
restart: :transient: an app that quits with{:stop, state}stays stopped, a crash restarts. A kiosk that must always come back useson_app_exit: :restart. shutdown_timeout:(default 4000 ms) bounds how long the surface waits for the app server to stop; the generated child spec sets the supervisor's:shutdowna second above it. Pass it touseor the child spec, not only frominit/1, which the child spec cannot see.- Put a
Framebuffer.Surfacelast in its supervisor: its start waits for/dev/fb0up toframebuffer_timeout:(30 s).
Sessions
Process.flag(:trap_exit, true)
raster = RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono)
{:ok, session} = RasterExRatatui.Session.start(raster, app: MyDevice.App, keep_frame: true)
{:render, _patches, session} = RasterExRatatui.Session.await(session)
frame = RasterExRatatui.Session.frame(session)- Trap exits before
start/2: the app server is linked to the caller. Its exit then reacheshandle/2as{:exit, reason, session}; without trapping it takes the caller down. - Pass every message the process receives to
handle/2; it returns{:render, patches, session},{:exit, reason, session}, or:unknown. Keep the returned session: it holds the folded raster. handle/2already drains every render of the session waiting in the mailbox and folds them into one call; do not build a queue on top of it. Patches may be empty when a render changed nothing.keep_frame: truekeepsframe/1current at no cost per call; without itframe/1renders the raster.- After
{:exit, _, _}the cell session is closed andsend_event/2is a no-op. To bring the app back,start/2again onraster(session); the first render repaints everything.stop/1is safe either way. stop/1unlinks, stops the app (killing it aftershutdown_timeout:), and closes the cell session; call it fromterminate/2.
Raster
raster = RasterExRatatui.Raster.new(size: {400, 300}, format: RasterExRatatui.PixelFormat.Mono)
{cols, rows} = RasterExRatatui.Raster.grid_size(raster)
session = ExRatatui.CellSession.new(cols, rows, font_size: RasterExRatatui.Raster.font_size(raster))
{raster, patches} = RasterExRatatui.Raster.apply(raster, ExRatatui.CellSession.take_cells_diff(session))
frame = RasterExRatatui.Raster.frame(raster)- Always create the session with
grid_size/1andfont_size:from the same raster. Withoutfont_size:,Viewport3DandImagedegrade to half blocks and no regions arrive. Rasteris an immutable value: keep the structapply/2returns, or the next diff is folded into a stale grid.- Feed every diff to
apply/2, in order, even when not pushing; diffs are deltas. :scaleis a positive integer; the effective cell is font cell × scale.rotate: 90 | 180 | 270turns the app's image clockwise on the panel.:sizeis always the physical panel; the grid,logical_size/1, andmargin/1follow the turned image, while patches,frame/1, andresize/2stay physical. Never swap:sizeby hand to rotate.cell_at(raster, {px, py})maps a panel pixel (a touch point) back to{col, row}or:outside, whatever the rotation.new/1raisesArgumentError(neverKeyError) for a missing:sizeor:format, a:sizethat is not two positive integers, a bad:scale, or a panel too small for one cell.
Formats and palettes
Mono(gray8, 1-bit panels),RGB565(little-endian),XRGB8888(little-endian, bytes b, g, r, 255).- Colour formats take palette options in
format_opts:—:reset_fg,:reset_bg,:theme(a partial map of named colours),:bold_bright.Monotakes:ink_max,:paper_min. - For a Linux framebuffer, pick the format from the device:
{:ok, format} = RasterExRatatui.Framebuffer.format_for(info). Do not assume 32 bpp. - Regions are packed natively for the built-in formats. A custom
PixelFormatwhose region bytes equal one ofExRatatui.Pixels' formats returns it from the optionalnative_format/1; otherwise it implementsrgb_row/4(the raster still samples and rotates natively). Never pack region pixels in a surface.
Fonts
- The default font is
RasterExRatatui.Font.Default6x8(printable ASCII, light box drawing plus everyBlockborder type, blocks, braille, eighths, quadrants, a few arrows and marks). Other characters render as a hatched placeholder. - A font is a module implementing
RasterExRatatui.Font:cell_size/0andglyph/1returning exactlywidth * heightbits. Build glyphs at compile time withRasterExRatatui.Font.Art.parse/3andRasterExRatatui.Font.Generated.
Device helpers
RasterExRatatui.Framebuffer.open(name)takes the device name ("fb0"), not a path.write/2accepts patches or{:frame, binary}and handles stride.RasterExRatatui.Input.Devicesfinds and reads the keyboard (and, later, the touch panel) throughinput_event:new/1+start/1in the surface'sinit/1, every message throughhandle_info/2(returns{:events, keys, devices},{:noreply, devices}, or:unknown),stop/1interminate/2. It is process-less and needs the caller to trap exits. The library does not depend oninput_event; the consumer adds{:input_event, "~> 1.4"}, andstart/1returns{:error, :input_event_missing}without it.RasterExRatatui.Input.Evdevis the pure translator underneath: keep the returned keyboard struct between calls, since it tracks held modifiers and caps lock.RasterExRatatui.Input.Touchis the same for a touch panel:new(size:, cell_at:, axes:), events fold to%ExRatatui.Event.Mouse{}("down","drag","up",button: "left") on cells, first finger only.Deviceswithtouch: trueneedssize:andcell_at:(&Raster.cell_at(raster, &1)); the framebuffer surface passes both itself.swap_xy:/invert_x:/invert_y:fix a controller that does not follow the panel.- For a Linux framebuffer, use
RasterExRatatui.Framebuffer.Surface(see Framebuffer surfaces above) rather than assemblingFramebufferandInput.Devicesby hand; therpi_framebufferexample is a complete Nerves project on it, and the Nerves Quick Start guide walks through one. A device that already owns its display from one process usesRasterExRatatui.Sessionfrom that process instead, the shape of thee_inkexample.
Testing
- Test surfaces on the host: a test
push/2sends patches to the test process, andRasterExRatatui.Surface.raster/1+RasterExRatatui.Raster.frame/1show the panel. - Test rasterisation with a real headless
ExRatatui.CellSession(draw/2+take_cells_diff/1); no TTY or device is involved. RasterExRatatui.Raster.to_png(raster)snapshots the panel as a PNG (RGB8, physical size) for eyeballing; do not hand-roll PPM/PGM writers. A custom format needs the optionalunpack_row/2callback for it.