RasterExRatatui.Surface behaviour (RasterExRatatui v0.4.0)

Copy Markdown View Source

The contract a pixel display implements to run an ExRatatui app.

A surface is a module that knows one panel: how big it is, how its pixels are packed, and how to get bytes onto it. Everything between the app and those bytes (starting the app, folding cell diffs, rasterising glyphs and pixel regions, forwarding input) is done by the surface process that use RasterExRatatui.Surface gives the module.

defmodule MyDevice.Surface do
  use RasterExRatatui.Surface,
    app: MyDevice.Dashboard,
    format: RasterExRatatui.PixelFormat.RGB565,
    scale: 2

  @impl true
  def init(opts) do
    lcd = MyDevice.LCD.open!()
    {:ok, [size: MyDevice.LCD.size(lcd)], lcd}
  end

  @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
end

# in the application's supervision tree
children = [MyDevice.Surface]

Lifecycle

  1. start_link/1 merges its options over the use options and calls init/1 with the result.
  2. init/1 returns the consumer's state and a keyword list of options it could only know at runtime (typically :size, read from the device); those win over everything else.
  3. The process builds a RasterExRatatui.Raster and starts a RasterExRatatui.Session on it, which creates an ExRatatui.CellSession of the raster's grid with the raster's font_size: and starts the app server on it, linked.
  4. Every render of the app arrives as a cell diff; the raster turns it into patches and push/2 writes them.
  5. When the app server exits, :on_app_exit decides. With :stop, the default, the surface exits with the same reason; the generated child spec is restart: :transient, like ExRatatui.App's, so a crash restarts the pair and an app that quits with {:stop, state} stays stopped. With :restart, the surface keeps its raster, opens a fresh cell session, and starts the app again; the new app's first render repaints the panel. A crash loop (more than :max_restarts crashes within :max_seconds) stops the surface after all, and its supervisor takes over. When the surface stops, it stops the app server first.

Options

Accepted by use, by start_link/1, and in the keyword list init/1 returns (later wins, in that order):

  • :app (required) — the ExRatatui.App module to run
  • :app_opts — keyword list passed to the app's mount/1 (or reducer init/1), default []. The surface adds surface: to it, a map describing the panel the app is on: :size (the physical panel in pixels), :cell_size (the effective cell in pixels), :grid_size (cells), :format, :scale, and :rotate
  • :size (required) — the physical panel size in pixels, {width, height}
  • :format (required) — a RasterExRatatui.PixelFormat module
  • :rotate — 0 (default), 90, 180, or 270: how far clockwise the app's image is turned on the panel, for a panel mounted on its side. The grid follows the turned image; patches and frames stay in the panel's own coordinates, so push/2 does not change
  • :font, :scale, :format_opts — see RasterExRatatui.Raster.new/1
  • :push_mode — :patches (default) calls push/2 with the changed rectangles; :frame calls it with {:frame, binary}, the whole panel, for panels that only take full frames
  • :min_interval — minimum milliseconds between two pushes (default 0). Renders arriving sooner wait, and are rasterised together and pushed once when the interval has passed, which keeps slow panels (e-ink, SPI at low baud) from refreshing more often than they should
  • :on_app_exit — what to do when the app server exits: :stop (default) stops the surface with the same reason, and its supervisor decides; :restart starts the app again on the same surface at once, for a panel that has nothing else to show. Both emit [:raster_ex_ratatui, :app, :exit] with the reason and the action
  • :max_restarts, :max_seconds — with on_app_exit: :restart, an app that crashes more than max_restarts times (default 3) within max_seconds (default 5) stops the surface with its reason instead, so a crash loop reaches the supervisor rather than spinning inside the surface. An app that quits on purpose (a :normal or :shutdown exit) is not counted and always comes back
  • :shutdown_timeout — milliseconds to wait for the app server to stop when the surface terminates before killing it (default 4_000). The generated child spec gives the supervisor's :shutdown a second more, so the consumer's terminate/2 always runs; a value returned only from init/1 cannot reach the child spec, so pass it to use or the child spec when raising it
  • :name — registers the surface process

Every other option reaches init/1 untouched, so device settings (a device path, a GPIO pin) can travel with the rest.

The surface never falls behind the app. Every render that is waiting when the surface gets to work is folded into the raster as one batch (RasterExRatatui.Raster.apply/2 with a list) and pushed at once, so when the app renders faster than the surface can rasterise or the panel can write, the panel shows fewer, later frames rather than every frame later and later, and input never waits behind stale renders. A min_interval holds the batch back instead.

Input

Input belongs to the consumer: whatever reads the device (an evdev keyboard, GPIO buttons, a touch controller) turns its events into ExRatatui.Event structs and hands them to send_event/2. When the reader delivers messages to the surface process itself, handle_info/2 can translate them and return {:events, events, state}. RasterExRatatui.Input.Evdev translates evdev key events.

Resize

resize/2 changes the panel size (a display that comes back at another resolution): the raster and the session are rebuilt at the new grid, the app receives an ExRatatui.Event.Resize, and the next render repaints the whole panel.

Summary

Types

A running surface: its pid or registered name.

Callbacks

Handles any other message sent to the surface process.

Prepares the device and returns the consumer's state.

Writes pixels to the device and returns the new state.

Called when the surface stops, after the app server is stopped. The default does nothing.

Functions

The surface's current RasterExRatatui.Raster, for inspection and tests (RasterExRatatui.Raster.frame/1 renders what the panel shows).

Changes the panel size. The grid, the session, and the app are resized, and the next render repaints the panel.

Forwards an ExRatatui.Event (a %Key{}, a %Mouse{}, …) to the app.

The pid of the app server the surface runs.

Starts the surface process for module, a module implementing this behaviour, with opts (see the moduledoc). Modules that use RasterExRatatui.Surface get a start_link/1 that calls this with their use options merged in.

Types

surface()

@type surface() :: GenServer.server()

A running surface: its pid or registered name.

Callbacks

handle_info(msg, state)

@callback handle_info(msg :: term(), state :: term()) ::
  {:noreply, term()} | {:events, [ExRatatui.Event.t()], term()}

Handles any other message sent to the surface process.

Return {:events, events, state} to forward ExRatatui.Event structs to the app, or {:noreply, state}. The default ignores the message.

init(opts)

@callback init(opts :: keyword()) ::
  {:ok, keyword(), state :: term()} | {:stop, reason :: term()}

Prepares the device and returns the consumer's state.

opts is every option given to use and start_link/1, merged. The returned keyword list is merged over them, so options only known once the device is open (:size, :format) are returned here. Returning {:stop, reason} aborts the start; any other return raises ArgumentError.

The default implementation returns {:ok, [], opts}: no extra options, and the options themselves as state.

push(pixels, state)

@callback push(
  pixels :: [RasterExRatatui.Patch.t()] | {:frame, binary()},
  state :: term()
) :: term()

Writes pixels to the device and returns the new state.

Receives the list of RasterExRatatui.Patch rectangles to write in order (push_mode: :patches), or {:frame, binary} with the whole panel, row-major, width * height packed pixels (push_mode: :frame). Called from the surface process; a slow push delays the next one but never the app.

terminate(reason, state)

@callback terminate(reason :: term(), state :: term()) :: term()

Called when the surface stops, after the app server is stopped. The default does nothing.

Functions

raster(surface)

@spec raster(surface()) :: RasterExRatatui.Raster.t()

The surface's current RasterExRatatui.Raster, for inspection and tests (RasterExRatatui.Raster.frame/1 renders what the panel shows).

resize(surface, size)

Changes the panel size. The grid, the session, and the app are resized, and the next render repaints the panel.

Returns the new grid size, {cols, rows}.

send_event(surface, event)

@spec send_event(surface(), ExRatatui.Event.t()) :: :ok

Forwards an ExRatatui.Event (a %Key{}, a %Mouse{}, …) to the app.

Returns immediately; the event reaches the app's handle_event/2 (or update/2) like input from a terminal.

server(surface)

@spec server(surface()) :: pid()

The pid of the app server the surface runs.

start_link(module, opts)

@spec start_link(module(), keyword()) :: GenServer.on_start()

Starts the surface process for module, a module implementing this behaviour, with opts (see the moduledoc). Modules that use RasterExRatatui.Surface get a start_link/1 that calls this with their use options merged in.