All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.4.0 - 2026-10-23
Added
PixelFormat.native_format/1, an optional callback naming theExRatatui.Pixelsformat whose bytes match the format'srgb_pixel/6.RGB565returns:rgb565,XRGB8888:xrgb8888, andMono:mono_bayer4(its region dither; cells keep the tone rules).PixelFormat.native_format/2returnsnilfor a format that does not define it.
Changed
- Pixel regions are packed natively. A region used to be sampled onto its rect, turned, and then packed into the panel's format one row at a time in Elixir, which was most of what a region cost: about 20–30 ns a pixel on a laptop and ~130 on a Raspberry Pi 4. The raster now does all of it in one pass with
ExRatatui.Pixels.pack_rgb8/5(new in ex_ratatui 0.18), and flat, turned and scaled regions cost the same. On this host a 696×480 RGB565 region went from 8.5 ms to 0.6 ms flat and from 10.3 ms to 0.6 ms turned, and a 1080×1000 XRGB8888 one from 30.5 ms to 2.5 ms. The output is byte for byte what it was, at every angle and scale. A custom format withoutnative_format/1still gets the sampling and rotation natively and packs through itsrgb_row/4(orrgb_pixel/6) as before. - Requires ex_ratatui
~> 0.18, forExRatatui.Pixels.pack_rgb8/5.
0.3.1 - 2026-10-05
Changed
- Requires ex_ratatui
~> 0.17. Firmware builds no longer pick up the host's NIF, so the Nerves Quick Start drops the step that deleted it.
0.3.0 - 2026-09-21
Changed
- A pixel region on a turned panel costs about what it costs on a flat one. Until now a rotated region was gathered pixel by pixel in Elixir — a panel row is a column of the app's image, so each row of the patch was assembled with one
binary_partper pixel — which made a region roughly twice as expensive turned as flat. The raster now samples the bitmap onto its rect in the app's orientation, turns it in one pass withExRatatui.Pixels.rotate_rgb8/4(new in ex_ratatui 0.15), and packs whole panel rows through the format'srgb_row/4, which is the same path a flat region already took. On this host one 954×528 XRGB8888 region went from 20.0 ms to 12.4 ms at 90 and from 21.3 ms to 13.6 ms at 270, against 10.4 ms flat; the rotation itself is 0.4 ms of that. On a Raspberry Pi 4 with the Touch Display 2 atrotate: 90, the dashboard's Showcase tab went from a 95 ms to a 55 ms median raster per frame. The output is byte for byte what it was, at every angle and every scale. Packing stays in Elixir and is now most of what a region costs, turned or not. - Requires ex_ratatui
~> 0.15, forExRatatui.Pixels.rotate_rgb8/4.
Removed
- The rotated region fast path and its helpers (
column/3,reverse_rows/2, and the per-pixelgather/3), which the native rotation replaces. Private, so nothing to migrate.
0.2.1 - 2026-09-19
Fixed
- Touch panels produced no mouse events through
input_event.input_eventdelivers one evdev frame per message and drops thesyn_reportthat ends it, andRasterExRatatui.Input.Touchonly emitted on asyn_report, so it followed the finger and never reported it.Input.Touch.translate_all/2now takes the end of its list as the end of a frame (an explicitsyn_reportstill ends one too), and the tests feed frames in the shapeinput_eventsends, including a tap captured on a Raspberry Pi Touch Display 2.
0.2.0 - 2026-09-19
Migration
Nothing breaks for consumers of the documented API. Two things to know:
- Apps started by a surface or a session receive a
surface:key in theirmount/1(or reducerinit/1) options. An app that already usedapp_opts: [surface: ...]for something else gets the library's map instead; rename that option. %RasterExRatatui.Raster{}has two new enforced fields,:logical_sizeand:rotate. Code that builds the struct by hand instead of callingRaster.new/1has to add them.
Added
RasterExRatatui.Framebuffer.Surface: a complete surface for a Linux framebuffer in oneuse:use RasterExRatatui.Framebuffer.Surface, app: MyApp, rotate: 90waits for/dev/fb0, reads its geometry from sysfs, picks the format from the depth and the scale from the long side (RasterExRatatui.Framebuffer.auto_scale/2, new), detaches the framebuffer console, reads the keyboard throughInput.Devices, writes withFramebuffer.write/2, and restarts the app when it quits (on_app_exit: :restartby default). Every option is documented in its moduledoc;init/1,push/2,handle_info/2, andterminate/2are overridable and each default is a public function an override can call. Withoutinput_eventin the deps it logs once and runs without input.RasterExRatatui.Session: the surface without the process, for a consumer that already owns one.start/2creates the cell session for a raster and starts the app server linked to the caller; every render arrives as a{RasterExRatatui.Session, ref, diff}message thathandle/2folds (together with every render of the session still waiting) into patches, or{:exit, reason, session}when the app exits;await/2waits for the first frame;keep_frame: truekeeps a whole frame current forframe/1;send_event/2,resize/2,stop/1, anddrain/2+render/2as the two halves ofhandle/2.RasterExRatatui.Surfaceis now built on it.rotate: 0 | 90 | 180 | 270onRasterExRatatui.Raster.new/1,RasterExRatatui.Session.start/2's raster, andRasterExRatatui.Surface, for a panel mounted on its side: the app's image is turned clockwise on its way to the panel.:sizestays the physical panel and so do every patch,frame/1, andresize/2, so consumers'push/2andFramebuffer.write/2are untouched; the grid, the newRaster.logical_size/1, andmargin/1follow the turned image. Glyph blocks are rotated once into the cache, runs of cells become vertical strips, and regions are gathered already turned (a bitmap at panel size turned by 90 or 270 is gathered one column per panel row in a single bit-syntax pass); dithering and checkerboards stay anchored to the panel's pixels. Cells cost the same turned as flat; a turned pixel region costs about 1.6× a flat one on the host and 2× on a Raspberry Pi 4, the price of gathering it pixel by pixel in Elixir.Raster.rotate/1reports the angle, andsurface:carries it to the app.RasterExRatatui.Input.Touch: a pure translator from evdev touch events (multitouch type B, or single-touchabs_x/abs_ywithbtn_touch) toExRatatui.Event.Mouseevents on the cell under the first finger:"down"and"up"for a tap,"drag"when the finger moves into another cell, nothing in the margins, a second finger ignored. Axes are scaled to the panel's pixels and the cell comes from acell_at:function, normallyRaster.cell_at/2, so rotation is handled;swap_xy:,invert_x:,invert_y:cover a controller that does not follow the panel.Input.Devicesreads a touch panel withtouch: true(found bytouch_path/1, axes fromtouch_axes/1, grabbed, found again after an unplug) givensize:andcell_at:, andFramebuffer.Surfacepasses both from its own geometry, sotouch: truethere is all a Raspberry Pi with the Touch Display 2 needs.RasterExRatatui.Input.Devices: the evdev device owner a Linux surface needs, process-less and embeddable.new/1+start/1look for the keyboard (the first device reporting letter keys, or a given path), start aninput_eventreader on it with the device grabbed, translate its events throughInput.Evdev, and look again everyretry_ms:while there is none: at boot, and after an unplug, with the modifiers released.handle_info/2takes every message of the owning process and returns{:events, keys, devices},{:noreply, devices}, or:unknown;stop/1stops the readers.input_eventstays the consumer's dependency:start/1returns{:error, :input_event_missing}without it. Pure helperskeyboard_path/1,touch_path/1, andtouch_axes/1pick devices out ofInputEvent.enumerate/0. Moved out of therpi_framebufferexample, which adopts it with the one-useframebuffer surface.on_app_exit: :stop | :restartonRasterExRatatui.Surface.:stop(the default) is today's behaviour: the surface exits with the app's reason and its supervisor decides.:restartkeeps the surface and its raster, starts the app again at once on a fresh cell session, and lets the new app's first render repaint the panel, for a panel that has nothing else to show. A crash loop does not spin: more thanmax_restarts:crashes (default 3) withinmax_seconds:(default 5) stop the surface with the app's reason after all, so its supervisor takes over; quits on purpose are never counted. Both policies report the exit as the new[:raster_ex_ratatui, :app, :exit]telemetry event, with the:reasonand the:actiontaken; the default logger prints it.- The app's
mount/1(or reducerinit/1) options always includesurface:, a map with the panel:sizein pixels, the effective:cell_size, the:grid_sizein cells, the:format, the:scale, and the:rotateangle, so an app can lay itself out for the panel it runs on instead of assuming a cell size. RasterExRatatui.Raster.cell_at/2: the cell under a physical panel pixel,{col, row}or:outside(margins, off the panel), whatever the rotation; the inverse mapping a touch controller reporting panel coordinates needs.RasterExRatatui.Raster.to_png/1: the whole panel as a PNG (8-bit RGB, physical size), pure Elixir with:zlib, for tests and debugging. It unpacks pixels through the new optionalRasterExRatatui.PixelFormat.unpack_row/2callback, whichMono,RGB565, andXRGB8888implement; a format without it raisesArgumentErrornaming the callback.RasterExRatatui.Telemetry.probe/3watches a surface for a few seconds from IEx and reports the raster and push spans (count, median, p90, max) and the surface's mailbox before and after, the quickest way to tell whether a panel keeps up with its app.RasterExRatatui.Raster.apply/2takes 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. The patches are those of the last state only.RasterExRatatui.PixelFormatgains an optionalrgb_row/4callback that packs a whole row of region pixels in one call, plusRasterExRatatui.PixelFormat.rgb_row/5, the per-pixel fallback the raster uses for formats without one.Mono,RGB565, andXRGB8888implement it.examples/rpi_framebuffer: a Nerves project that puts a dashboard on a Raspberry Pi's/dev/fb0with a USB keyboard, through oneuse RasterExRatatui.Framebuffer.Surface. The panel's size, pixel format, and font scale are read from sysfs, so the same firmware fits the DSI Touch Display 2 (turned landscape withrotate:) and an HDMI monitor. The app is a two-tab dashboard (pixel regions, keyboard test) that reads its cell size from thesurface:option and also runs in a terminal.examples/e_ink: the e-ink name badge, the consumer that runs its apps withRasterExRatatui.Sessionfrom its own screen process instead of a surface.- The Nerves Quick Start guide: from
mix nerves.newto an app on a Raspberry Pi's display with keyboard and touch, the build traps (OTP-matched Elixir, the host NIF in firmware builds), first checks over SSH, and what to do when the panel stays dark.
Fixed
RasterExRatatui.Raster.resize/2no longer calls the format'sinit/1a second time with empty options (it reuses the raster's config), so formats whoseinit/1rejects[]survive a resize.RasterExRatatui.Raster.new/1raisesArgumentErrorfor a missing:sizeor:format, and for a:sizethat is not a pair of positive integers, instead ofKeyErrororMatchError. ARasterExRatatui.Surfacewhoseinit/1returns something other than{:ok, opts, state}or{:stop, reason}raisesArgumentErrornaming the module and the value, instead ofFunctionClauseError.RasterExRatatui.Input.Evdev.translate_all/2accepts the:disconnectthatinput_eventsends in place of the event list when a device goes away: held modifiers are released and no keys are produced. The Linux Framebuffers guide's surface sketch handles it, and stops its reader interminate/2: a reader that is only linked survives a surface that stops normally (its app quit) and keeps its grab on the keyboard, so the next surface's reader was disconnected at once, and therpi_framebufferexample crashed on that message three times and took the application down. The example now does both.
Changed
- The child spec that
use RasterExRatatui.Surfacegenerates sets:shutdowntoshutdown_timeout + 1_000instead of the default 5 seconds, so raisingshutdown_timeout:no longer lets the supervisor kill the surface in the middle of the consumer'sterminate/2. RasterExRatatui.Raster.frame/1composes each row from its patches without copying the row once per patch (a scanline sweep over the few patches instead of a map of every slice): a flat 1920×1080 frame takes 23 ms instead of 30 on the host, and a rotated one 38 ms instead of 148.push_mode: :frameapplies each render's patches to a frame the surface keeps (RasterExRatatui.Session'skeep_frame:) instead of rasterising the patches and then rendering the whole panel again; the frame pushed is the same.- Region rasterisation goes row by row: the raster hands each source row to the format's
rgb_row/4instead of callingrgb_pixel/6per pixel, gathers scaled rows once instead of per pixel, and reuses a source row an upscaled region repeats. A 954×528 region costs 14 ms instead of 35 on the host; on a Raspberry Pi 4 a 696×480Viewport3Dregion went from 184 ms to 43 ms per frame. - The surface process folds renders instead of queuing them. When a diff arrives, every diff already waiting in the mailbox goes with it through one
Raster.apply/2and one push, right away; before, each diff was rasterised on its own and the push went through a self-sent message that queued behind every render still waiting, so a panel whose raster took longer than the app's tick fell further behind on every frame (on a Raspberry Pi 4 with a turningViewport3Dregion: one push every ~5 s, ~28 stacked copies of the same rectangle per push, and key presses ~5 s late). Now the panel shows fewer, later frames and input never waits behind stale renders.min_interval:holds the batch back on a timer instead of rasterising in between. The[:raster_ex_ratatui, :frame, :raster]:stopmetadata gains:diffs, how many were folded into the call. RasterExRatatui.Raster.apply/2rasterises only the pixel regions that are new or changed, instead of every region whenever the region list changed. A stillImagenext to an animatedViewport3Dis now rasterised once, which halves the per-frame cost of that layout. Overlapping regions stay correct: an unchanged region that touches a repainted area is repainted with it, in list order, and unchanged regions that swap order are all repainted. Patches still reproduceframe/1exactly.examples/README.mdis a catalogue page with a row per example; the device examples carry their own READMEs. The Linux Framebuffers and Building a Surface guides point at them.
0.1.0 - 2026-09-15
Added
- First release.
raster_ex_ratatuirendersExRatatuiapps on pixel displays that are not terminals, on top ofExRatatui.CellSessionpixel regions. RasterExRatatui.Surface: a behaviour withusedefaults whose supervised process starts the app on a cell session, folds every diff through the raster, and calls the consumer'spush/2with changed rectangles (push_mode: :patches) or whole frames (push_mode: :frame). Renders that arrive whilepush/2is busy are pushed together, so a slow device never builds a backlog;min_interval:additionally throttles pushes for slow panels.send_event/2forwards input,resize/2rebuilds the grid (diffs rendered for the old size are dropped), the surface exits with the app's exit reason under a:transientchild spec, andshutdown_timeout:bounds how long it waits for the app to stop.RasterExRatatui.Raster: the pure rasteriser.apply/2returnsRasterExRatatui.Patchrectangles for changed cells, changed pixel regions (scaled nearest-neighbour onto their cell rect), and margins on a full repaint;frame/1renders the whole panel andrender_frame/1does the same while keeping the glyph cache. Cells under a pixel region are never rasterised. Cell pixels are cached per glyph and colours.RasterExRatatui.Fontbehaviour with the built-inFont.Default6x8(hand-drawn printable ASCII, light box drawing and the corners of everyBlockborder type, blocks, and common symbols; generated braille, eighth blocks, and quadrants),Font.Artfor compile-time ASCII-art glyphs, andFont.Generatedfor the geometric blocks at any cell size. An integerscale:magnifies any font.RasterExRatatui.PixelFormatbehaviour withMono(gray8 for 1-bit panels: tone rules for cells, Bayer 4×4 dither for regions),RGB565, andXRGB8888, andRasterExRatatui.Palettefor named, indexed, RGB, and:resetcolours with a configurable theme.RasterExRatatui.Framebuffer: Linux fbdev geometry from sysfs, stride-aware patch and frame writes, blanking, and console unbinding.RasterExRatatui.Input.Evdev: a pure evdev key translator producingExRatatui.Event.Keystructs with shift, ctrl, alt, super, and caps lock, for a US layout or a custom map.RasterExRatatui.Telemetry: surface start/stop, rasterisation and push spans, and input forwarding events.- Guides (Building a Surface, Fonts, Pixel Formats, Linux Framebuffers, Telemetry), usage rules, a headless snapshot example, and a rasterisation benchmark.