Skip to main content

lib/bb/tui/renderer.ex

defmodule BB.TUI.Renderer do
  @moduledoc """
  Behaviour a **consumer** implements to teach the dashboard how to render a
  payload on a PubSub path the consumer owns — without `bb_tui` knowing anything
  about that payload's shape or struct.

  `bb_tui` subscribes to whatever paths it is told (`:subscribe_paths`) and
  dispatches each `{:bb, path, msg}` by path prefix. Most prefixes have
  dedicated, built-in handling (`[:state_machine]`, `[:sensor]`, `[:param]`, …).
  Anything else falls to a generic event-log row via `inspect/2`.

  A renderer plugs into that generic seam: a consumer registers a module for a
  path prefix via the `:renderers` option (a `%{prefix => module}` map threaded
  through `BB.TUI.run/2`, `start/2`, `start_ssh/2`). When a message arrives whose
  path matches a registered prefix (longest prefix wins, like a routing table),
  `bb_tui` calls back into the consumer's module rather than pattern-matching the
  payload itself. The dashboard therefore stays free of any downstream struct
  knowledge — the consumer owns the rendering of its own data.

  Two callbacks:

    * `summarize/2` — the one-line string for the event log. Return `nil` to fall
      back to `bb_tui`'s generic `inspect/2`.
    * `observed/2` (optional) — feed the at-a-glance status-bar readout. Return a
      `{slot_key, display, meta}` triple to record/refresh a slot, or `nil` to
      skip. The status bar surfaces the freshest slot (max `meta.seq`) and dims
      stale ones (`meta.freshness == :stale`).

  ## Return shapes

  `summarize(path, payload)` → `String.t() | nil`.

  `observed(path, payload)` → `{slot_key, display, meta} | nil` where:

    * `slot_key` — any term that identifies the slot (e.g. `{:wheels, :imu}` or a
      bare atom). Successive samples on the same `slot_key` overwrite, so the
      readout shows the latest value of each slot rather than accumulating.
    * `display` — a `map()` the status bar renders. `bb_tui` reads at least
      `:label` (a `String.t()` shown in the segment) from it; consumers may carry
      extra fields for their own future use.
    * `meta` — a `map()` carrying at least:
      * `:freshness` — `:fresh | :stale`. Stale slots render dimmed.
      * `:seq` — a sortable term (typically an integer or timestamp). The status
        bar picks the slot with the maximum `:seq` as "freshest".

  ## Example

      defmodule MyApp.SlotRenderer do
        @behaviour BB.TUI.Renderer

        @impl true
        def summarize([:demo | _], %{name: name, value: value}) do
          "\#{name} = \#{value}"
        end

        def summarize(_path, _payload), do: nil

        @impl true
        def observed([:demo | _], %{name: name, value: value} = p) do
          {name, %{label: "\#{name}:\#{value}"},
           %{freshness: Map.get(p, :freshness, :fresh), seq: Map.get(p, :seq, 0)}}
        end

        def observed(_path, _payload), do: nil
      end

  Then:

      BB.TUI.run(MyApp.Robot,
        subscribe_paths: [[:demo]],
        renderers: %{[:demo] => MyApp.SlotRenderer}
      )
  """

  @typedoc "A registered slot key — any term the renderer uses to identify a slot."
  @type slot_key :: term()

  @typedoc "The status-bar display map. `bb_tui` reads at least `:label`."
  @type display :: %{optional(atom()) => term()}

  @typedoc "Slot metadata. Carries at least `:freshness` and a sortable `:seq`."
  @type meta :: %{required(:freshness) => :fresh | :stale, required(:seq) => term()}

  @doc """
  Returns a one-line event-log summary for `payload` on `path`, or `nil` to fall
  back to `bb_tui`'s generic `inspect/2` rendering.
  """
  @callback summarize(path :: [atom()], payload :: term()) :: String.t() | nil

  @doc """
  Returns `{slot_key, display, meta}` to record/refresh an at-a-glance status-bar
  slot, or `nil` to skip. Optional — a renderer that only needs an event-log row
  can omit it.
  """
  @callback observed(path :: [atom()], payload :: term()) ::
              {slot_key(), display(), meta()} | nil

  @optional_callbacks observed: 2
end