Emerge behaviour (Emerge v0.5.1)

Copy Markdown View Source

Defines Emerge viewport modules.

A viewport owns a rendered UI and receives its events. Use Emerge in a module, implement mount/1, and implement either render/0 or render/1.

use Emerge imports the common Emerge.UI helpers and aliases Emerge as Viewport inside your module.

Example

defmodule MyApp.CounterViewport do
  use Emerge

  @impl Viewport
  def mount(opts) do
    output = %Emerge.Config.Window{title: Keyword.get(opts, :title, "Counter")}
    {:ok, %{count: 0}, %Emerge.Config{output: output}}
  end

  @impl Viewport
  def render(%{count: count}) do
    row([spacing(12), padding(12)], [
      Input.button([Event.on_press(:decrement)], text("-")),
      text("Count: #{count}"),
      Input.button([Event.on_press(:increment)], text("+"))
    ])
  end

  @impl Viewport
  def handle_info(:increment, state) do
    {:noreply, Viewport.rerender(%{state | count: state.count + 1})}
  end

  def handle_info(:decrement, state) do
    {:noreply, Viewport.rerender(%{state | count: state.count - 1})}
  end
end

Start the viewport directly:

{:ok, viewport} = MyApp.CounterViewport.start_link()

Or supervise it:

children = [MyApp.CounterViewport]
Supervisor.start_link(children, strategy: :one_for_one)

use Emerge adds start_link/1 and child_spec/1 to the viewport module.

Callbacks

mount/1 receives the options passed to start_link/1. Return one of:

  • {:ok, config} and implement render/0;
  • {:ok, state, config} and implement render/1;
  • {:stop, reason} to stop startup.

config must be %Emerge.Config{}. Application mount arguments remain keyword lists; they are not automatically merged into renderer configuration. State must be a map. The :__emerge__ key is reserved.

Optionally implement config(previous) or config(previous, state), never both. The first invocation receives normalized mount config before renderer creation; later invocations receive the latest normalized desired intent, including while native application is pending or the renderer is recovering. Return a complete config; new nested records replace rather than merge with previous records. Invalid results retain previous intent; operational errors preserve desired intent for retry. See Emerge.Config for supported live versus startup-only fields.

render/0 or render/1 returns an Emerge.tree() built with Emerge.UI.

Implement handle_info/2 for messages and element events. Implement handle_input/2 only when you need raw renderer input. Implement handle_close/2 to customize window-close behavior; the default stops the viewport normally.

Callback handlers return {:noreply, state} or {:stop, reason, state}.

Rerendering

Call rerender/1 after changing viewport state:

{:noreply, Viewport.rerender(%{state | count: state.count + 1})}

Return the state produced by rerender/1. It schedules a coalesced config/render cycle, not an immediate callback. Config-only changes do not require a tree diff. While DRM display intent is off, config reconciliation remains active but routine render callbacks and uploads are deferred; output ownership is still acquired.

Element events

Event helpers send ordinary Elixir messages. Passing only a message sends it to the viewport process:

Input.button([Event.on_press(:save)], text("Save"))

@impl Viewport
def handle_info(:save, state) do
  {:noreply, state}
end

To send elsewhere, pass {pid, message}. Payload events append the payload to a tuple message or send {message, payload} for a non-tuple message. Override wrap_payload/3 when an integration needs another shape.

Viewport configuration

A viewport's %Emerge.Config{} chooses its output, drawing API, input, assets, diagnostics and runtime settings. Return it from mount/1; direct renderer callers pass the same record to EmergeSkia.start/1. Each configuration record explains its fields and which can change while running.

Native build support versus runtime selection

compiled_backends belongs in config/config.exs, before compiling Emerge. It selects optional native support; config.output and config.renderer.api choose which available implementation a viewport uses at runtime.

Headless output and CPU raster rendering are always present. DRM and Wayland add presenters; OpenGL and Vulkan add shared rendering APIs. Configuration entries resolve to a deduplicated union:

compiled_backendsOptional native features
[] or [headless: [:raster]]None
[headless: [:opengl]]OpenGL
[drm: [:raster]]DRM
[drm: [:opengl]]DRM and OpenGL
[headless: [:opengl], drm: [:opengl]]The same DRM and OpenGL
[wayland: [:opengl]]Wayland and OpenGL
[drm: [:vulkan]]DRM and Vulkan

For example, the normal Linux desktop default can be made explicit:

# config/config.exs
config :emerge, compiled_backends: [wayland: [:opengl]]

APIs are shared build features, not separate per-backend exclusion masks. [wayland: [:vulkan], drm: [:opengl]] enables both presenters and both APIs. Repeated API selections do not create another artifact. Archives match the exact optional feature set: an opengl archive does not secretly include DRM/Wayland; drm_opengl includes DRM. The baseline archive has no variant suffix. Prebuilt coverage is curated per target for common configurations; accepting a configuration does not mean an archive is published for it. Other combinations require a source build, never an archive with a larger feature set.

Use :all for the supported APIs on an entry. Existing atom shorthand remains: [:drm] and [:wayland] imply OpenGL, [:macos] implies Metal, and legacy [:headless] implies OpenGL. Use [] or [headless: [:raster]] for the minimal baseline. Explicit configuration always overrides defaults.

Compiling an implementation does not choose a display, start a frame consumer or enable an API in an already-installed binary. Runtime config cannot add missing build features. Prefer explicit runtime APIs for hardware recipes: :auto is output-specific and does not promise to choose Vulkan merely because it is built.

The canonical variants described here require 0.5.1 archives and matching checksums; existing 0.5.0 assets stay unchanged. An unreleased checkout needs an explicit source build rather than assuming its new archives already exist.

Platform defaults

When compiled_backends is unspecified:

  • Normal Linux uses [wayland: [:opengl]].
  • macOS uses [macos: [:metal]] through the separate window host.
  • Nerves uses a known stock system/version capability profile; unknown/custom systems conservatively use headless/raster ([]).

Headless/raster needs a live frame recipient, not the desktop window configuration used by the introductory counter. Metal remains on the macOS window host; headless Metal is deferred. Darwin headless raster uses its separate NIF.

Nerves and device defaults

Choose the highest usable tier of the image and display path, not just the SoC's advertised API:

Established supportBuild settingRuntime API
Atomic KMS and a usable Vulkan stack[drm: [:vulkan]]:vulkan with a device
Atomic KMS and usable OpenGL ES[drm: [:opengl]]:opengl
Atomic KMS with CPU scanout, no usable GPU renderer[drm: [:raster]]:raster
No usable atomic KMS, but offscreen EGL/GLES[headless: [:opengl]]:opengl with a frame consumer
No established path, or unknown system[]:raster with a frame consumer

DRM/raster also requires atomic KMS, dumb buffers, a compatible XRGB8888 plane and leasing. It is not a legacy-KMS/fbdev fallback. Headless Vulkan currently exports PRIME frames, not ordinary binary pixels; raster cannot export PRIME.

Known defaults match Nerves' exported NERVES_SYSTEM package/version, such as nerves_system_rpi5-portable-2.0.1, not a MIX_TARGET nickname or CPU architecture. The initial profiles describe stock-image configuration, not physical-board qualification. They deliberately do not guess about custom or future images:

BoardStock systemAtomic KMSGLESVulkanDefaultEvidence
Raspberry Pi 5nerves_system_rpi5 2.0.1truetruefalse[drm: [:opengl]]Image configuration
Raspberry Pi 4 / 400nerves_system_rpi4 2.0.1truetruefalse[drm: [:opengl]]Image configuration
Raspberry Pi 3nerves_system_rpi3 2.0.1falsefalsefalse[]Image configuration
Raspberry Pi Zero 2 Wnerves_system_rpi0_2 2.0.1falsefalsefalse[]Image configuration
Raspberry Pi Zero / Zero Wnerves_system_rpi0 2.0.1falsefalsefalse[]Image configuration

These stock Pi 4/5 images configure KMS and Mesa GLES but not a Vulkan stack. Hardware Vulkan support does not install the required loader/driver/extensions. Older stock images without Mesa do not mean the SoC can never render: a custom image may supply a usable EGL/GLES stack. Override the build explicitly when using such an image, or an SPI panel instead of the board's KMS/HDMI path.

ARMv6 still requires a source build. Determine 32-bit/64-bit ABI from the active system/toolchain, not the board name. GPU rendering still needs suitable target libraries and device access; neither artifact availability nor NIF loading proves hardware support. Build inspection never probes the build host's GPU.

Runtime output examples

These records can be returned from mount/1 or passed to EmergeSkia.start/1. Replace :my_app and device paths with your application/system values. A viewport can infer assets.otp_app; standalone renderer callers must provide it.

For the Wayland/OpenGL build above:

%Emerge.Config{
  output: %Emerge.Config.Window{backend: :wayland, title: "My app"},
  renderer: [api: :opengl],
  assets: [otp_app: :my_app]
}

For compiled_backends: [drm: [:opengl]]:

%Emerge.Config{
  output: %Emerge.Config.DRM{card: "/dev/dri/card0"},
  renderer: [api: :opengl],
  assets: [otp_app: :my_app]
}

For compiled_backends: [drm: [:vulkan]], select a compatible GPU device as well as the display card; these paths are examples, not universal numbering:

%Emerge.Config{
  output: %Emerge.Config.DRM{card: "/dev/dri/card0"},
  renderer: [api: :vulkan, device: "/dev/dri/renderD128"],
  assets: [otp_app: :my_app]
}

For compiled_backends: [drm: [:raster]], use CPU KMS presentation without a GPU stack. GPU-upload raster is a separate explicit OpenGL-backed route:

%Emerge.Config{
  output: %Emerge.Config.DRM{card: "/dev/dri/card0"},
  renderer: [api: :raster, raster_present: :cpu],
  assets: [otp_app: :my_app]
}

For compiled_backends: [headless: [:opengl]], construct the config in the live frame-consumer process (or supply that process's PID instead of self()):

%Emerge.Config{
  output: %Emerge.Config.Headless{
    target: self(),
    size: {320, 240},
    frames: %Emerge.Config.BinaryFrames{pixel_format: :rgb888}
  },
  renderer: [api: :opengl],
  assets: [otp_app: :my_app]
}

The recipient handles {:emerge_skia_frame, %VideoInterop.Frame{}} and sends pixels to its panel. Honor the format/stride and do panel-specific conversion, such as RGB888 to RGB565 for an SPI/ILI9341 driver, in your application. Owned binary frames need no release; PRIME has a different ownership protocol. This headless GL build needs EGL/GBM/GLES and GPU device access, but no DRM/KMS or Wayland presenter.

For the minimal compiled_backends: [] build, use raster instead:

%Emerge.Config{
  output: %Emerge.Config.Headless{
    target: self(),
    size: {320, 240},
    frames: %Emerge.Config.BinaryFrames{pixel_format: :rgb888}
  },
  renderer: [api: :raster],
  assets: [otp_app: :my_app]
}

Inspect and rebuild native support

Once the dependency's Elixir code is available:

MIX_TARGET=your_target mix emerge.build_info
mix emerge.build_info --systems
mix emerge.build_info --profiles

For a desktop, omit MIX_TARGET. Before native compilation succeeds, the shipped standalone script needs no NIF, Rustler or compiled dependency code:

elixir deps/emerge/scripts/build-info.exs \
  --backend headless:opengl --target aarch64-unknown-linux-gnu

In Emerge's own checkout, omit deps/emerge/. Without explicit --backend values, the standalone script reports environment defaults, not unloaded application config. Inspect firmware inside the bootstrapped Nerves environment so the system and target ABI are available. The report describes the requested build, not a previously installed binary, and does not assert that an archive is published.

After changing build settings, the system/version, driver stack or ABI:

mix deps.compile emerge --force
# Firmware:
MIX_TARGET=your_target mix deps.compile emerge --force
MIX_TARGET=your_target mix firmware

Unsupported exact profiles or missing checksums are explained before a potentially long native fallback. To deliberately build from source, add the optional build dependency to your application:

{:rustler, "~> 0.38.0", runtime: false}
EMERGE_SKIA_BUILD=true MIX_TARGET=your_target mix deps.compile emerge --force

Source builds need Elixir 1.19+, Rust 1.91+, a C/C++ toolchain and the selected target libraries. Use Nerves' active cross compiler/sysroot, never a host-built NIF in firmware. rust-skia uses host Python; set EMERGE_SKIA_HOST_PYTHON to an absolute host executable if /usr/bin/python3 is unsuitable. Corrupt archives or invalid checksum manifests are errors, not permission to bypass integrity.

Live configuration and events

Return %Emerge.Config{} from mount. Optional config/1 or config/2 callbacks receive the previous normalized desired config and reconcile changes during rerenders. Each Emerge.Config record documents its live fields and backend limits. A complete live config is distinct from native application or presentation completion.

Raw input subscriptions use Emerge.Config.Input.events, defaulting to []; see Emerge.Input and the input guide. This filter does not disable element interactions. Video targets are viewport-local atoms; submit owned binary or borrowed DMA-BUF frames with submit_video_frame/3.

Summary

Types

Viewport state map passed to render/1 and callback functions.

Public tree type built with Emerge.UI and rendered by Emerge backends.

Callbacks

Returns desired configuration, given the previous normalized config.

Returns desired configuration, given the previous normalized config and application state.

Functions

Applies Emerge's default payload-event message shape.

Requests a rerender from viewports after application source is reloaded.

Returns the renderer handle owned by a running viewport.

Marks viewport state for rerendering and returns the updated state.

Submits one frame to a viewport-local video target.

Types

state()

@type state() :: map()

Viewport state map passed to render/1 and callback functions.

tree()

@type tree() :: Emerge.Engine.Element.t()

Public tree type built with Emerge.UI and rendered by Emerge backends.

Callbacks

config(normalized_t)

(optional)
@callback config(Emerge.Config.normalized_t()) :: Emerge.Config.t()

Returns desired configuration, given the previous normalized config.

config normalized_t, state

(optional)
@callback config(Emerge.Config.normalized_t(), state()) :: Emerge.Config.t()

Returns desired configuration, given the previous normalized config and application state.

handle_close(term, state)

(optional)
@callback handle_close(term(), state()) :: {:noreply, state()} | {:stop, term(), state()}

handle_info(term, state)

(optional)
@callback handle_info(term(), state()) :: {:noreply, state()} | {:stop, term(), state()}

handle_input(event, state)

(optional)
@callback handle_input(Emerge.Input.event(), state()) ::
  {:noreply, state()} | {:stop, term(), state()}

mount(keyword)

@callback mount(keyword()) ::
  {:ok, state(), Emerge.Config.t()} | {:ok, Emerge.Config.t()} | {:stop, term()}

render()

(optional)
@callback render() :: tree()

render(state)

(optional)
@callback render(state()) :: tree()

wrap_payload(term, term, term)

(optional)
@callback wrap_payload(term(), term(), term()) :: term()

Functions

default_wrap_payload(message, payload, event_type)

@spec default_wrap_payload(term(), term(), term()) :: term()

Applies Emerge's default payload-event message shape.

Tuple messages receive the payload as a final tuple element. Other messages become {message, payload}. Viewports normally use this through the default wrap_payload/3 callback.

notify_source_reloaded(meta \\ %{})

@spec notify_source_reloaded(term()) :: :ok

Requests a rerender from viewports after application source is reloaded.

Emerge.Runtime.CodeReloader calls this automatically. Custom development reloaders may pass metadata for their own logging or coordination.

renderer(pid)

@spec renderer(pid()) :: term()

Returns the renderer handle owned by a running viewport.

Use this handle with renderer-specific APIs such as EmergeSkia.renderer_info/1, EmergeSkia.stats/2, and capture.

renderer = Emerge.renderer(viewport)
{:ok, info} = EmergeSkia.renderer_info(renderer)

rerender(state)

@spec rerender(state()) :: state()

Marks viewport state for rerendering and returns the updated state.

Call this after changing local state and return its result from the callback:

{:noreply, Viewport.rerender(%{state | count: state.count + 1})}

submit_video_frame(viewport, target, frame)

@spec submit_video_frame(pid(), atom(), VideoInterop.Frame.t()) ::
  :ok | {:error, term()}

Submits one frame to a viewport-local video target.

The target is the same atom passed to Emerge.UI.video/2. Hidden targets consume and drop frames. Visible targets retain only the latest frame.

Every normal return consumes the supplied frame: immutable binary frames need no further action, while borrowed storage is released by Emerge when retired.

:ok = Emerge.submit_video_frame(viewport, :camera, frame)