Skip to main content

lib/bb/tui/panels/joints.ex

defmodule BB.TUI.Panels.Joints do
  @moduledoc """
  Joint control panel — displays joint positions with type, units, and
  visual position bars.

  Shows each joint's name, type (rev/pri/con), current position in
  human-readable units (degrees or mm), and a bar indicating position
  within the joint's limits. Simulated joints are marked with a SIM tag.

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

  alias BB.TUI.State
  alias BB.TUI.Theme
  alias ExRatatui.Style
  alias ExRatatui.Text.{Line, Span}
  alias ExRatatui.Widgets.Block
  alias ExRatatui.Widgets.Table

  @bar_width 16

  @doc """
  Renders the joints panel as a Table widget with columns for
  name, type, position with units, and a visual position bar.

  ## Examples

      iex> entries = %{shoulder: %{joint: %{name: :shoulder, type: :revolute, limits: %{lower: -1.57, upper: 1.57}}, position: 0.0}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
      iex> %ExRatatui.Widgets.Table{header: header} = BB.TUI.Panels.Joints.render(state, false)
      iex> header
      ["Joint", "Type", "Position", "Target"]
  """
  @spec render(State.t(), boolean()) :: struct()
  def render(%State{joints: %{entries: entries, selected: selected}}, focused?) do
    rows =
      entries
      |> Enum.sort_by(fn {name, _} -> name end)
      |> Enum.map(fn {name, joint_data} ->
        %{position: pos, joint: joint} = joint_data
        target = Map.get(joint_data, :target)
        proximity = State.limit_proximity(pos, joint)

        [
          name_span(name, joint),
          format_type(joint),
          position_span(pos, joint, proximity),
          position_bar_line(pos, joint, proximity, target)
        ]
      end)

    %Table{
      rows: rows,
      header: ["Joint", "Type", "Position", "Target"],
      widths: [
        {:percentage, 20},
        {:percentage, 8},
        {:percentage, 15},
        {:min, @bar_width + 14}
      ],
      selected: if(focused? and rows != [], do: selected),
      highlight_style: Theme.highlight_style(),
      highlight_symbol: "\u{25B6} ",
      block: %Block{
        title: title_line(),
        borders: [:all],
        border_type: :rounded,
        border_style: Theme.border_style(focused?)
      }
    }
  end

  @doc """
  Formats a joint name, appending SIM tag for simulated joints.

  A joint is simulated when it has an empty actuators list.

  ## Examples

      iex> BB.TUI.Panels.Joints.format_name(:elbow, %{actuators: []})
      "elbow SIM"

      iex> BB.TUI.Panels.Joints.format_name(:elbow, %{actuators: [:motor]})
      "elbow"

      iex> BB.TUI.Panels.Joints.format_name(:elbow, %{})
      "elbow"
  """
  @spec format_name(atom(), map()) :: String.t()
  def format_name(name, %{actuators: []}), do: to_string(name) <> " SIM"
  def format_name(name, _joint), do: to_string(name)

  @doc """
  Formats the joint type as a short label.

  ## Examples

      iex> BB.TUI.Panels.Joints.format_type(%{type: :revolute})
      "rev"

      iex> BB.TUI.Panels.Joints.format_type(%{type: :prismatic})
      "pri"

      iex> BB.TUI.Panels.Joints.format_type(%{type: :continuous})
      "con"

      iex> BB.TUI.Panels.Joints.format_type(%{type: :fixed})
      "fix"

      iex> BB.TUI.Panels.Joints.format_type(%{})
      "-"
  """
  @spec format_type(map()) :: String.t()
  def format_type(%{type: :revolute}), do: "rev"
  def format_type(%{type: :prismatic}), do: "pri"
  def format_type(%{type: :continuous}), do: "con"
  def format_type(%{type: :fixed}), do: "fix"
  def format_type(_), do: "-"

  @doc """
  Formats position with appropriate units based on joint type.

  Revolute/continuous joints show degrees, prismatic joints show millimeters.

  ## Examples

      iex> BB.TUI.Panels.Joints.format_position(1.5708, %{type: :revolute})
      "90.0\u00B0"

      iex> BB.TUI.Panels.Joints.format_position(0.030, %{type: :prismatic})
      "30.0 mm"

      iex> BB.TUI.Panels.Joints.format_position(nil, %{type: :revolute})
      "N/A"
  """
  @spec format_position(number() | nil, map()) :: String.t()
  def format_position(nil, _joint), do: "N/A"

  def format_position(pos, %{type: :prismatic}) do
    mm = pos * 1000
    "#{float_to_str(mm)} mm"
  end

  def format_position(pos, _joint) do
    degrees = pos * 180.0 / :math.pi()
    "#{float_to_str(degrees)}\u00B0"
  end

  @doc """
  Formats a joint limit value in human-readable units (degrees or mm).

  ## Examples

      iex> BB.TUI.Panels.Joints.format_limit(1.5708, %{type: :revolute})
      "90"

      iex> BB.TUI.Panels.Joints.format_limit(-1.5708, %{type: :revolute})
      "-90"

      iex> BB.TUI.Panels.Joints.format_limit(0.037, %{type: :prismatic})
      "37"
  """
  @spec format_limit(number(), map()) :: String.t()
  def format_limit(val, %{type: :prismatic}) do
    mm = round(val * 1000)
    Integer.to_string(mm)
  end

  def format_limit(val, _joint) do
    degrees = round(val * 180.0 / :math.pi())
    Integer.to_string(degrees)
  end

  defp get_limits(%{limits: %{lower: lower, upper: upper}})
       when not is_nil(lower) and not is_nil(upper),
       do: {lower, upper}

  defp get_limits(_), do: nil

  defp marker_char(:normal), do: "\u{25CF}"
  defp marker_char(:warning), do: "\u{25C6}"
  defp marker_char(:danger), do: "\u{25C9}"

  defp proximity_suffix(:normal), do: ""
  defp proximity_suffix(:warning), do: " !"
  defp proximity_suffix(:danger), do: " !!"

  defp float_to_str(val) when is_float(val), do: :erlang.float_to_binary(val, decimals: 1)
  defp float_to_str(val) when is_integer(val), do: "#{val}.0"

  # ── Rich-text cell helpers ─────────────────────────────────

  defp name_span(name, %{actuators: []}) do
    %Line{
      spans: [
        %Span{content: to_string(name), style: %Style{fg: :white}},
        %Span{content: " SIM", style: Theme.sim_style()}
      ]
    }
  end

  defp name_span(name, _joint) do
    %Span{content: to_string(name), style: %Style{fg: :white}}
  end

  defp position_span(pos, joint, proximity) do
    text = format_position(pos, joint) <> proximity_suffix(proximity)
    style = %Style{fg: Theme.proximity_color(proximity), modifiers: position_modifiers(proximity)}
    %Span{content: text, style: style}
  end

  defp title_line do
    spans =
      Theme.panel_badge_spans(State.panel_number(:joints)) ++
        [%Span{content: "Joint Control ", style: Theme.panel_title_style()}]

    %Line{spans: spans}
  end

  defp position_modifiers(:normal), do: []
  defp position_modifiers(_), do: [:bold]

  defp position_bar_line(nil, _joint, _proximity, _target), do: ""
  defp position_bar_line(_pos, %{type: :continuous}, _proximity, _target), do: ""

  defp position_bar_line(pos, joint, proximity, target) do
    case get_limits(joint) do
      {lower, upper} when upper > lower ->
        marker_pos = ratio_to_cell(pos, lower, upper)
        target_cell = target_cell(target, lower, upper, marker_pos)
        track_spans = bar_track_spans(marker_pos, target_cell, proximity)
        low_label = format_limit(lower, joint)
        high_label = format_limit(upper, joint)

        %Line{
          spans:
            [%Span{content: "#{low_label} ", style: %Style{fg: Theme.dim_text()}}] ++
              track_spans ++
              [%Span{content: " #{high_label}", style: %Style{fg: Theme.dim_text()}}]
        }

      _ ->
        ""
    end
  end

  defp bar_track_spans(marker_pos, target_cell, proximity) do
    marker_style = %Style{fg: Theme.proximity_color(proximity), modifiers: [:bold]}
    marker = marker_char(proximity)

    0..(@bar_width - 1)
    |> Enum.map(&cell_span(&1, marker_pos, marker, marker_style, target_cell))
  end

  defp cell_span(cell, marker_pos, marker, marker_style, _target_cell) when cell == marker_pos,
    do: %Span{content: marker, style: marker_style}

  defp cell_span(cell, _marker_pos, _marker, _marker_style, target_cell) when cell == target_cell,
    do: %Span{content: target_marker(), style: target_style()}

  defp cell_span(_cell, _marker_pos, _marker, _marker_style, _target_cell),
    do: %Span{content: "\u{2500}", style: %Style{fg: Theme.dim_border()}}

  defp ratio_to_cell(value, lower, upper) do
    ratio = (value - lower) / (upper - lower)
    ratio = max(0.0, min(1.0, ratio))
    round(ratio * (@bar_width - 1))
  end

  defp target_cell(nil, _lower, _upper, _marker_cell), do: nil

  defp target_cell(target, lower, upper, marker_cell) do
    cell = ratio_to_cell(target, lower, upper)
    if cell == marker_cell, do: nil, else: cell
  end

  defp target_marker, do: "\u{25CB}"
  defp target_style, do: %Style{fg: Theme.cyan(), modifiers: [:bold]}
end