defmodule BB.TUI.Panels.Events do
@moduledoc """
Events panel — displays a scrollable list of recent robot messages with
formatted timestamps, color-coded paths, message types, and summaries.
Press Enter on a selected event to open a detail popup showing the full
message payload. Supports pause/resume (`p`) and clear (`c`) when focused.
Pure function — takes state, returns a widget struct.
"""
alias BB.TUI.State
alias BB.TUI.Theme
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Text.{Line, Span}
alias ExRatatui.Widgets.Block
alias ExRatatui.Widgets.List, as: WidgetList
alias ExRatatui.Widgets.Scrollbar
@doc """
Renders the events panel as a List widget with formatted event entries.
Newest events appear first. Scrollable with j/k when focused.
## Examples
iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: [], scroll_offset: 0, paused?: false}}
iex> widget = BB.TUI.Panels.Events.render(state, false)
iex> widget.items
[]
"""
@spec render(State.t(), boolean()) :: struct()
def render(
%State{events: %{list: events, scroll_offset: offset, paused?: paused}} = state,
focused?
) do
items = Enum.map(events, &event_line(&1, state))
%WidgetList{
items: items,
selected: if(events != [], do: offset),
highlight_style: Theme.highlight_style(),
block: %Block{
title: title_line(length(events), paused),
borders: [:all],
border_type: :rounded,
border_style: Theme.border_style(focused?)
}
}
end
@doc """
Renders the events panel as a list of `{widget, rect}` panes: the events
list plus an overlay Scrollbar pinned to the right-hand side.
The scrollbar rect is inset by one cell so it appears inside the panel's
rounded border rather than on top of it. When there are no events, only
the list pane is returned — a scrollbar with zero content would render
a track with no thumb, which is visually noisy.
## Examples
iex> rect = %ExRatatui.Layout.Rect{x: 0, y: 0, width: 40, height: 10}
iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: [], scroll_offset: 0, paused?: false}}
iex> panes = BB.TUI.Panels.Events.render_panes(state, false, rect)
iex> length(panes)
1
iex> rect = %ExRatatui.Layout.Rect{x: 0, y: 0, width: 40, height: 10}
iex> ts = ~U[2026-01-15 18:23:12.000Z]
iex> events = [{ts, [:state_machine], %{payload: %{from: :disarmed, to: :armed}}}]
iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: events, scroll_offset: 0, paused?: false}}
iex> [{_list, _list_rect}, {scrollbar, _bar_rect}] =
...> BB.TUI.Panels.Events.render_panes(state, true, rect)
iex> scrollbar.content_length
1
"""
@spec render_panes(State.t(), boolean(), Rect.t()) :: [{struct(), Rect.t()}]
def render_panes(%State{events: %{list: []}} = state, focused?, %Rect{} = rect) do
[{render(state, focused?), rect}]
end
def render_panes(
%State{events: %{list: events, scroll_offset: offset}} = state,
focused?,
%Rect{
x: x,
y: y,
width: width,
height: height
}
) do
list_rect = %Rect{x: x, y: y, width: width, height: height}
scrollbar_rect = %Rect{
x: x + 1,
y: y + 1,
width: max(width - 2, 0),
height: max(height - 2, 0)
}
scrollbar = %Scrollbar{
orientation: :vertical_right,
content_length: length(events),
position: offset,
viewport_content_length: max(height - 2, 0),
thumb_style: Theme.border_style(focused?)
}
[{render(state, focused?), list_rect}, {scrollbar, scrollbar_rect}]
end
@doc """
Builds the panel title as a single-string label (legacy form).
## Examples
iex> BB.TUI.Panels.Events.title(47, false)
" Events (47) "
iex> BB.TUI.Panels.Events.title(47, true)
" Events (47) \u{23F8} PAUSED "
iex> BB.TUI.Panels.Events.title(0, false)
" Events "
iex> BB.TUI.Panels.Events.title(0, true)
" Events \u{23F8} PAUSED "
"""
@spec title(non_neg_integer(), boolean()) :: String.t()
def title(0, false), do: " Events "
def title(count, false), do: " Events (#{count}) "
def title(0, true), do: " Events \u{23F8} PAUSED "
def title(count, true), do: " Events (#{count}) \u{23F8} PAUSED "
@doc ~S"""
Builds the panel title as a rich-text `%Line{}` — the count renders
bold-cyan and the `⏸ PAUSED` badge renders bold-yellow when the
stream is paused.
## Examples
iex> %ExRatatui.Text.Line{spans: spans} =
...> BB.TUI.Panels.Events.title_line(47, false)
iex> Enum.map_join(spans, "", & &1.content)
" 4 Events (47) "
iex> %ExRatatui.Text.Line{spans: spans} =
...> BB.TUI.Panels.Events.title_line(47, true)
iex> Enum.map_join(spans, "", & &1.content)
" 4 Events (47) ⏸ PAUSED "
iex> %ExRatatui.Text.Line{spans: spans} =
...> BB.TUI.Panels.Events.title_line(0, false)
iex> Enum.map_join(spans, "", & &1.content)
" 4 Events "
"""
@spec title_line(non_neg_integer(), boolean()) :: Line.t()
def title_line(0, false) do
%Line{spans: badge() ++ [%Span{content: "Events ", style: Theme.panel_title_style()}]}
end
def title_line(count, false) do
%Line{
spans:
badge() ++
[
%Span{content: "Events (", style: Theme.panel_title_style()},
%Span{
content: Integer.to_string(count),
style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
},
%Span{content: ") ", style: Theme.panel_title_style()}
]
}
end
def title_line(count, true) do
label =
case count do
0 ->
[%Span{content: "Events ", style: Theme.panel_title_style()}]
_ ->
[
%Span{content: "Events (", style: Theme.panel_title_style()},
%Span{
content: Integer.to_string(count),
style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
},
%Span{content: ") ", style: Theme.panel_title_style()}
]
end
%Line{
spans:
badge() ++
label ++
[
%Span{
content: " \u{23F8} PAUSED ",
style: %Style{fg: Theme.yellow(), modifiers: [:bold]}
}
]
}
end
defp badge, do: Theme.panel_badge_spans(State.panel_number(:events))
@doc ~S"""
Builds a rich-text `%Line{}` for a single event.
| segment | color |
| --------- | ------ |
| timestamp | cyan |
| path | blue (`Theme.path_style/0`) |
| summary | white |
## Examples
iex> ts = ~U[2026-01-15 18:23:12.000Z]
iex> %ExRatatui.Text.Line{spans: spans} =
...> BB.TUI.Panels.Events.event_line(
...> {ts, [:state_machine], %{payload: %{from: :disarmed, to: :armed}}}
...> )
iex> Enum.map_join(spans, "", & &1.content)
"18:23:12 state_machine disarmed → armed"
"""
@spec event_line({DateTime.t(), list(), term()}) :: Line.t()
def event_line({_timestamp, _path, _message} = event) do
event_line(event, %State{})
end
@doc """
Builds an event `%Line{}`, consulting any consumer renderer registered in
`state` for the event's path before falling back to the built-in `summarize/2`
clauses. The dashboard never inspects the payload's struct: a renderer-owned
path is summarised by the consumer's `c:BB.TUI.Renderer.summarize/2`.
"""
@spec event_line({DateTime.t(), list(), term()}, State.t()) :: Line.t()
def event_line({timestamp, path, message}, %State{} = state) do
time = Calendar.strftime(timestamp, "%H:%M:%S")
path_str = path |> format_path() |> String.pad_trailing(18)
summary = summarize_with_renderer(state, path, message)
%Line{
spans: [
%Span{content: time, style: %Style{fg: Theme.cyan()}},
%Span{content: " ", style: %Style{}},
%Span{content: path_str, style: Theme.path_style()},
%Span{content: " ", style: %Style{}},
%Span{content: summary, style: %Style{fg: :white}}
]
}
end
# Prefer the consumer renderer registered for this path (longest-prefix
# match). When a renderer matches and its `summarize/2` returns a string, use
# it; a `nil` return — or no registered renderer — falls back to the built-in
# `summarize/2` clauses (which end in a generic `inspect`). bb_tui never
# pattern-matches the renderer-owned payload itself.
defp summarize_with_renderer(%State{} = state, path, message) do
with renderer when not is_nil(renderer) <- State.renderer_for(state, path),
payload <- renderer_payload(message),
summary when is_binary(summary) <- renderer.summarize(path, payload) do
summary
else
_ -> summarize(path, message)
end
end
defp renderer_payload(%{payload: payload}), do: payload
defp renderer_payload(message), do: message
@doc """
Formats a single event as a display string.
Shows timestamp, path, and a short summary of the message payload.
## Examples
iex> ts = ~U[2026-01-15 18:23:12.936Z]
iex> BB.TUI.Panels.Events.format_event({ts, [:sensor, :simulated], %{payload: %{names: [:elbow], positions: [0.5]}}})
"18:23:12 sensor.simulated JointState 1 joint(s)"
iex> ts = ~U[2026-01-15 18:23:12.000Z]
iex> BB.TUI.Panels.Events.format_event(
...> {ts, [:command, :move, make_ref()], %{payload: %{status: :cancelled}}}
...> )
"18:23:12 command.move move cancelled"
iex> ts = ~U[2026-01-15 18:23:12.000Z]
iex> BB.TUI.Panels.Events.format_event({ts, [:state_machine], %{payload: %{from: :disarmed, to: :armed}}})
"18:23:12 state_machine disarmed \u{2192} armed"
"""
@spec format_event({DateTime.t(), list(), term()}) :: String.t()
def format_event({timestamp, path, message}) do
time = Calendar.strftime(timestamp, "%H:%M:%S")
path_str = path |> format_path() |> String.pad_trailing(18)
summary = summarize(path, message)
"#{time} #{path_str} #{summary}"
end
@doc """
Formats the detail lines for an expanded event.
Returns a list of indented strings showing the message type and payload fields.
## Examples
iex> ts = ~U[2026-01-15 18:23:12.000Z]
iex> event = {ts, [:sensor, :simulated], %{payload: %{names: [:elbow], positions: [0.5], velocities: [0.0], efforts: [0.0]}}}
iex> details = BB.TUI.Panels.Events.format_event_details(event)
iex> hd(details) =~ "efforts"
true
"""
@spec format_event_details({DateTime.t(), list(), term()}) :: [String.t()]
def format_event_details({_timestamp, _path, message}) do
payload = extract_payload(message)
format_payload_lines(payload)
end
defp extract_payload(%{payload: %{__struct__: _} = payload}), do: payload
defp extract_payload(%{payload: payload}) when is_map(payload), do: payload
defp extract_payload(other), do: other
defp format_payload_lines(%{__struct__: mod} = payload) do
type_line = " \u{250C} #{inspect(mod)}"
fields =
payload
|> Map.from_struct()
|> Enum.sort_by(fn {k, _} -> to_string(k) end)
field_lines =
fields
|> Enum.with_index()
|> Enum.map(fn {{key, val}, idx} ->
prefix = if idx == length(fields) - 1, do: "\u{2514}", else: "\u{2502}"
label = to_string(key) |> String.pad_trailing(14)
" #{prefix} #{label}#{format_value(val)}"
end)
[type_line | field_lines]
end
defp format_payload_lines(payload) when is_map(payload) do
fields = Enum.sort_by(payload, fn {k, _} -> to_string(k) end)
fields
|> Enum.with_index()
|> Enum.map(fn {{key, val}, idx} ->
prefix = if idx == length(fields) - 1, do: "\u{2514}", else: "\u{2502}"
label = to_string(key) |> String.pad_trailing(14)
" #{prefix} #{label}#{format_value(val)}"
end)
end
defp format_payload_lines(other) do
[" \u{2514} #{inspect(other, pretty: false, limit: 50)}"]
end
defp format_value(list) when is_list(list) do
items =
Enum.map(list, fn
f when is_float(f) -> :erlang.float_to_binary(f, decimals: 3)
other -> inspect(other)
end)
"[#{Enum.join(items, ", ")}]"
end
defp format_value(other), do: inspect(other)
# bb's command pubsub paths look like `[:command, :move, #Reference<…>]`.
# The execution_id reference is internal correlation and doesn't render
# via String.Chars, so drop reference segments and stringify the rest.
defp format_path(path) do
path
|> Enum.reject(&is_reference/1)
|> Enum.map_join(".", &to_string/1)
end
defp format_goal(goal) when map_size(goal) == 0, do: "(no args)"
defp format_goal(goal) do
goal
|> Enum.sort_by(fn {k, _} -> to_string(k) end)
|> Enum.map_join(" ", fn {k, v} -> "#{k}=#{inspect(v, limit: 5)}" end)
end
@doc """
Produces a short summary string for an event based on its path and payload.
## Examples
iex> BB.TUI.Panels.Events.summarize([:sensor, :sim], %{payload: %{names: [:a, :b], positions: [1.0, 2.0]}})
"JointState 2 joint(s)"
iex> BB.TUI.Panels.Events.summarize([:state_machine], %{payload: %{from: :armed, to: :idle}})
"armed \u{2192} idle"
iex> BB.TUI.Panels.Events.summarize([:actuator, :waist], %{payload: %{position: 1.57}})
"waist \u{2190} position 1.570"
iex> BB.TUI.Panels.Events.summarize([:command, :move, :ref], %{payload: %{status: :started, data: %{goal: %{angle: 1.5}}}})
"move started angle=1.5"
iex> BB.TUI.Panels.Events.summarize([:command, :home, :ref], %{payload: %{status: :started, data: %{goal: %{}}}})
"home started (no args)"
iex> BB.TUI.Panels.Events.summarize([:command, :home, :ref], %{payload: %{status: :succeeded, data: %{result: :ok}}})
"home \u{2714} :ok"
iex> BB.TUI.Panels.Events.summarize([:command, :move, :ref], %{payload: %{status: :failed, data: %{reason: :timeout}}})
"move \u{2718} :timeout"
iex> BB.TUI.Panels.Events.summarize([:command, :move, :ref], %{payload: %{status: :cancelled}})
"move cancelled"
iex> BB.TUI.Panels.Events.summarize([:param, :speed], %{payload: %{new_value: 42}})
"speed = 42"
iex> BB.TUI.Panels.Events.summarize([:unknown], %{payload: :something})
":something"
"""
@spec summarize(list(), term()) :: String.t()
def summarize([:sensor | _], %{payload: %{names: names, positions: _}}) do
"JointState #{length(names)} joint(s)"
end
def summarize([:state_machine | _], %{payload: %{from: from, to: to}}) do
"#{from} \u{2192} #{to}"
end
def summarize([:actuator | rest], %{payload: %{position: position}})
when is_number(position) do
joint = Enum.map_join(rest, ".", &to_string/1)
"#{joint} \u{2190} position #{:erlang.float_to_binary(position / 1, decimals: 3)}"
end
def summarize([:command, name, _execution_id], %{
payload: %{status: :started, data: %{goal: goal}}
}) do
"#{name} started #{format_goal(goal)}"
end
def summarize([:command, name, _execution_id], %{
payload: %{status: :succeeded, data: %{result: result}}
}) do
"#{name} \u{2714} #{inspect(result, limit: 30)}"
end
def summarize([:command, name, _execution_id], %{
payload: %{status: :failed, data: %{reason: reason}}
}) do
"#{name} \u{2718} #{inspect(reason, limit: 30)}"
end
def summarize([:command, name, _execution_id], %{payload: %{status: :cancelled}}) do
"#{name} cancelled"
end
def summarize([:param | rest], %{payload: %{new_value: val}}) do
param_name = Enum.map_join(rest, ".", &to_string/1)
"#{param_name} = #{inspect(val)}"
end
def summarize(_path, %{payload: payload}) do
inspect(payload, pretty: false, limit: 30)
end
def summarize(_path, message) do
inspect(message, pretty: false, limit: 30)
end
end