Skip to main content

lib/bb/tui/panels/status_bar.ex

defmodule BB.TUI.Panels.StatusBar do
  @moduledoc """
  Status bar — single-line bar at the bottom of the dashboard.

  Carries (in order) the robot module, a colored safety badge, a
  runtime-state pill, a battery / power segment (only when the robot has
  published electrical telemetry), the global key hints (`Tab` / `q` /
  `?`), and a set of context-sensitive key pills for the active panel.

  Every segment is a `%ExRatatui.Text.Span{}` so each piece can carry
  its own color: the safety badge changes color with the safety
  state (green / yellow / red / dim), the battery readout colors by
  remaining charge (`BB.TUI.Theme.battery_color/1`), key labels render
  as cyan pills (red for `q`), descriptions render dim. See
  `BB.TUI.Theme.brand_title/2`, `safety_badge/1`, `key_pill/2` for
  the underlying primitives.

  The battery segment prefers `BB.Message.Sensor.BatteryState` (charge
  percentage, or voltage when percentage is unmeasured) and falls back
  to `BB.Message.Sensor.PowerState` (bus voltage) when only the latter
  is available — making the charge level visible at a glance over SSH.

  Pure function — takes state, returns a widget struct.
  """

  alias BB.Message.Sensor.BatteryState
  alias BB.Message.Sensor.PowerState
  alias BB.TUI.State
  alias BB.TUI.Theme
  alias ExRatatui.Style
  alias ExRatatui.Text.{Line, Span}
  alias ExRatatui.Widgets.Paragraph

  @doc """
  Renders the status bar as a Paragraph widget.

  ## Examples

      iex> state = %BB.TUI.State{
      ...>   robot: MyApp.Robot, ui: %BB.TUI.State.UI{active_panel: :safety},
      ...>   safety: %BB.TUI.State.Safety{state: :armed, runtime: :idle}
      ...> }
      iex> %ExRatatui.Widgets.Paragraph{text: %ExRatatui.Text.Line{spans: spans}} =
      ...>   BB.TUI.Panels.StatusBar.render(state)
      iex> Enum.map_join(spans, "", & &1.content) =~ "MyApp.Robot"
      true

      iex> state = %BB.TUI.State{
      ...>   robot: MyApp.Robot, ui: %BB.TUI.State.UI{active_panel: :safety},
      ...>   safety: %BB.TUI.State.Safety{state: :armed, runtime: :idle}
      ...> }
      iex> %ExRatatui.Widgets.Paragraph{text: %ExRatatui.Text.Line{spans: spans}} =
      ...>   BB.TUI.Panels.StatusBar.render(state)
      iex> Enum.map_join(spans, "", & &1.content) =~ "ARMED"
      true
  """
  @spec render(State.t()) :: struct()
  def render(%State{} = state) do
    # No bg on the paragraph itself — `dim_span/1` paints `:dark_gray`
    # text, so a `bg: :dark_gray` strip would make every label
    # disappear. The pills carry the visual identity instead.
    %Paragraph{text: line(state)}
  end

  defp line(%State{} = state) do
    spans =
      [
        %Span{
          content: " #{inspect(state.robot)} ",
          style: %Style{fg: :white, modifiers: [:bold]}
        },
        Theme.dim_span("│"),
        %Span{content: " ", style: %Style{}},
        Theme.safety_badge(state.safety.state),
        %Span{content: " ", style: %Style{}},
        Theme.dim_span("│"),
        %Span{content: " ", style: %Style{}},
        runtime_pill(state.safety.runtime)
      ] ++
        power_spans(state) ++
        observed_spans(state) ++
        [%Span{content: "  ", style: %Style{}}] ++
        key_hints(state)

    %Line{spans: spans}
  end

  # Consumer-renderer readout: the freshest observed entry (max `meta.seq`),
  # rendered as an at-a-glance segment so a renderer-fed dashboard surfaces a
  # live value even when its data never reaches the joints/sensor panels.
  # Returns `[]` (no segment, no separator) until a renderer populates
  # `state.observed`. Stale entries (`meta.freshness == :stale`) dim. The bar
  # reads only the generic `display`/`meta` the renderer produced — no struct
  # knowledge.
  defp observed_spans(%State{observed: observed}) when map_size(observed) > 0 do
    {_slot, entry} =
      Enum.max_by(observed, fn {_slot, %{meta: meta}} -> Map.get(meta, :seq, 0) end)

    [
      %Span{content: " ", style: %Style{}},
      Theme.dim_span("│"),
      %Span{content: " ", style: %Style{}},
      observed_span(entry)
    ]
  end

  defp observed_spans(_state), do: []

  defp observed_span(%{display: display, meta: meta}) do
    fresh? = Map.get(meta, :freshness) != :stale

    style =
      if fresh? do
        %Style{fg: Theme.cyan(), modifiers: [:bold]}
      else
        %Style{fg: Theme.dim_text()}
      end

    mark = if fresh?, do: "\u{1F441}", else: "\u{26A0}"
    %Span{content: "#{mark} #{observed_label(display)}", style: style}
  end

  defp observed_label(%{label: label}) when is_binary(label), do: label
  defp observed_label(display), do: inspect(display)

  defp runtime_pill(runtime_state) do
    %Span{
      content: " #{runtime_state} ",
      style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
    }
  end

  # Battery wins over a bare power reading when both are present — charge
  # percentage is the more actionable number. Returns `[]` (no segment, no
  # separator) until the robot publishes electrical telemetry.
  defp power_spans(%State{power: %{battery: %BatteryState{} = battery}}) do
    power_segment(battery_span(battery))
  end

  defp power_spans(%State{power: %{power: %PowerState{} = reading}}) do
    power_segment(power_span(reading))
  end

  defp power_spans(_state), do: []

  defp power_segment(content_span) do
    [
      %Span{content: " ", style: %Style{}},
      Theme.dim_span("│"),
      %Span{content: " ", style: %Style{}},
      content_span
    ]
  end

  defp battery_span(%BatteryState{percentage: percentage} = battery) when is_number(percentage) do
    level = round(percentage * 100)

    %Span{
      content: "🔋 #{level}%#{charging_suffix(battery.power_supply_status)}",
      style: %Style{fg: Theme.battery_color(level), modifiers: [:bold]}
    }
  end

  defp battery_span(%BatteryState{voltage: voltage}) do
    %Span{
      content: "🔋 #{format_voltage(voltage)}",
      style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
    }
  end

  defp power_span(%PowerState{voltage: voltage}) do
    %Span{
      content: "⚡ #{format_voltage(voltage)}",
      style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
    }
  end

  defp charging_suffix(:charging), do: " ⚡"
  defp charging_suffix(_status), do: ""

  defp format_voltage(voltage) when is_number(voltage) do
    "#{:erlang.float_to_binary(voltage / 1, decimals: 1)}V"
  end

  defp key_hints(%State{ui: %{active_panel: panel}, safety: %{state: safety}} = state) do
    base =
      [
        Theme.key_pill("Tab"),
        Theme.dim_span(" panel "),
        Theme.key_pill("?"),
        Theme.dim_span(" help "),
        Theme.key_pill("q", :quit),
        Theme.dim_span(" quit ")
      ]

    extras =
      case panel do
        :parameters -> parameters_keys(state)
        _ -> panel_keys(panel, safety)
      end

    case extras do
      [] -> base
      _ -> base ++ [Theme.dim_span("  ") | extras]
    end
  end

  defp panel_keys(:safety, _safety) do
    [
      Theme.key_pill("a"),
      Theme.dim_span(" arm "),
      Theme.key_pill("d"),
      Theme.dim_span(" disarm ")
    ]
  end

  defp panel_keys(:commands, _safety) do
    [
      Theme.key_pill("j/k"),
      Theme.dim_span(" select "),
      Theme.key_pill("⏎"),
      Theme.dim_span(" execute ")
    ]
  end

  defp panel_keys(:events, _safety) do
    [
      Theme.key_pill("j/k"),
      Theme.dim_span(" scroll "),
      Theme.key_pill("⏎"),
      Theme.dim_span(" detail "),
      Theme.key_pill("p"),
      Theme.dim_span(" pause "),
      Theme.key_pill("c"),
      Theme.dim_span(" clear ")
    ]
  end

  defp panel_keys(:joints, safety) when safety in [:armed, :disarming] do
    [
      Theme.key_pill("j/k"),
      Theme.dim_span(" select "),
      Theme.key_pill("h/l"),
      Theme.dim_span(" adjust "),
      Theme.key_pill("H/L"),
      Theme.dim_span(" 10× ")
    ]
  end

  defp panel_keys(:joints, _safety) do
    [Theme.key_pill("j/k"), Theme.dim_span(" select ")]
  end

  defp panel_keys(_, _), do: []

  defp parameters_keys(%State{parameters: %{tabs: tabs}}) do
    base = [
      Theme.key_pill("j/k"),
      Theme.dim_span(" select "),
      Theme.key_pill("h/l"),
      Theme.dim_span(" adjust "),
      Theme.key_pill("⏎"),
      Theme.dim_span(" toggle ")
    ]

    if length(tabs) > 1 do
      base ++ [Theme.key_pill("t"), Theme.dim_span(" tab ")]
    else
      base
    end
  end
end