# `BB.TUI.State`
[🔗](https://github.com/mcass19/bb_tui/blob/v0.3.0/lib/bb/tui/state.ex#L1)

State struct and pure update functions for the BB TUI dashboard.

All state transitions are pure functions — no side effects, no process
communication. The `BB.TUI.App` module handles IO and delegates here
for state changes.

High-rate-stream controls live in the `BB.TUI.State.Throttle` substruct
(`throttle.debounce_ms`/`throttle.last_seen` back `append_event/3`'s log
debouncing; `throttle.render_pending?`/`throttle.flush_ms` drive
`BB.TUI.App`'s coalesced sensor re-render). See `BB.TUI.App` for the flow.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety, show_help?: false}}
    iex> state.ui.active_panel
    :safety

# `t`

```elixir
@type t() :: %BB.TUI.State{
  commands: BB.TUI.State.Commands.t(),
  events: BB.TUI.State.Events.t(),
  joints: BB.TUI.State.Joints.t(),
  node: node() | nil,
  observed: %{optional(term()) =&gt; %{display: map(), meta: map()}},
  parameters: BB.TUI.State.Parameters.t(),
  power: BB.TUI.State.Power.t(),
  renderers: %{optional([atom()]) =&gt; module()},
  robot: module(),
  robot_struct: term(),
  safety: BB.TUI.State.Safety.t(),
  throttle: BB.TUI.State.Throttle.t(),
  ui: BB.TUI.State.UI.t(),
  viz: BB.TUI.State.Viz.t()
}
```

# `append_event`

```elixir
@spec append_event(t(), list(), term()) :: t()
```

Appends an event to the event list, capping at 100.

Events are prepended (newest first) and the list is trimmed to
100 entries. When events are paused, the event is dropped.

Under high-rate streams, a repeat of the same `{path, payload-type}` seen
within `throttle.debounce_ms` (default 1s) is dropped so a fast sensor
cannot flood the log; distinct paths or payload types always pass through.
A debounce window of `0` disables this.

# `append_to_focused_arg`

```elixir
@spec append_to_focused_arg(t(), String.t()) :: t()
```

Appends a character to the focused argument's value.

# `arg_value`

```elixir
@spec arg_value(t(), atom(), map()) :: String.t()
```

Returns the current string value for an argument, falling back to the
argument's `:default` (rendered as a string).

# `backspace_focused_arg`

```elixir
@spec backspace_focused_arg(t()) :: t()
```

Deletes the last character from the focused argument's value.

# `clamp_position`

```elixir
@spec clamp_position(float(), map()) :: float()
```

Clamps a position value within a joint's limits.

Returns the position unchanged if the joint has no limits.

## Examples

    iex> BB.TUI.State.clamp_position(2.0, %{limits: %{lower: -1.0, upper: 1.0}})
    1.0

    iex> BB.TUI.State.clamp_position(-2.0, %{limits: %{lower: -1.0, upper: 1.0}})
    -1.0

    iex> BB.TUI.State.clamp_position(99.0, %{type: :continuous})
    99.0

# `clamp_to_bounds`

```elixir
@spec clamp_to_bounds(number(), {number() | nil, number() | nil} | nil) :: number()
```

Clamps a numeric value into `{min, max}` bounds. Either bound may be
`nil` to leave that side open. A `nil` bounds tuple returns `value`
unchanged.

## Examples

    iex> BB.TUI.State.clamp_to_bounds(5, {0, 10})
    5

    iex> BB.TUI.State.clamp_to_bounds(-3, {0, 10})
    0

    iex> BB.TUI.State.clamp_to_bounds(99, {0, 10})
    10

    iex> BB.TUI.State.clamp_to_bounds(99, {nil, 10})
    10

    iex> BB.TUI.State.clamp_to_bounds(-3, {0, nil})
    0

    iex> BB.TUI.State.clamp_to_bounds(7, nil)
    7

# `clear_events`

```elixir
@spec clear_events(t()) :: t()
```

Clears all events and resets scroll offset.

## Examples

    iex> list = [{~U[2026-01-01 00:00:00Z], [:test], %{}}]
    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: list, scroll_offset: 5}}
    iex> new_state = BB.TUI.State.clear_events(state)
    iex> {new_state.events.list, new_state.events.scroll_offset}
    {[], 0}

# `clear_render_pending`

```elixir
@spec clear_render_pending(t()) :: t()
```

Clears the pending-render flag once the coalesced frame has been rendered.

    iex> state = %BB.TUI.State{throttle: %BB.TUI.State.Throttle{render_pending?: true}}
    iex> BB.TUI.State.clear_render_pending(state).throttle.render_pending?
    false

# `cycle_focused_enum`

```elixir
@spec cycle_focused_enum(t(), :next | :prev) :: t()
```

Cycles the focused argument to the next (or previous) value in its
enum list. A no-op when not in edit mode or when the focused arg
isn't enum-typed.

Stores the chosen value as the leading-colon atom literal (`":foo"`)
so `parsed_args_for_selected/1` decodes it back to `:foo` when the
command executes.

# `cycle_panel`

```elixir
@spec cycle_panel(t()) :: t()
```

Cycles the active panel to the next one in order.

When `active_panel` is unknown (e.g. set out-of-band to a stale
value), resets to the first panel.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
    iex> BB.TUI.State.cycle_panel(state).ui.active_panel
    :commands

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :parameters}}
    iex> BB.TUI.State.cycle_panel(state).ui.active_panel
    :safety

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :unknown}}
    iex> BB.TUI.State.cycle_panel(state).ui.active_panel
    :safety

# `cycle_panel_back`

```elixir
@spec cycle_panel_back(t()) :: t()
```

Cycles the active panel to the previous one in order (Shift+Tab).

When `active_panel` is unknown, resets to the last panel so a stale
state still lands somewhere navigable.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :commands}}
    iex> BB.TUI.State.cycle_panel_back(state).ui.active_panel
    :safety

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
    iex> BB.TUI.State.cycle_panel_back(state).ui.active_panel
    :parameters

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :unknown}}
    iex> BB.TUI.State.cycle_panel_back(state).ui.active_panel
    :parameters

# `cycle_parameter_tab`

```elixir
@spec cycle_parameter_tab(t()) :: t()
```

Cycles to the next parameter tab, wrapping back to `:local`.

Resets `param_selected` so the new tab starts at the first row.

## Examples

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local, {:bridge, :mavlink}], tab_selected: 0, selected: 3}}
    iex> next = BB.TUI.State.cycle_parameter_tab(state)
    iex> next.parameters.tab_selected
    1
    iex> next.parameters.selected
    0

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local, {:bridge, :mavlink}], tab_selected: 1}}
    iex> BB.TUI.State.cycle_parameter_tab(state).parameters.tab_selected
    0

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local], tab_selected: 0}}
    iex> BB.TUI.State.cycle_parameter_tab(state).parameters.tab_selected
    0

# `cycle_render_mode`

```elixir
@spec cycle_render_mode(t()) :: t()
```

Cycles the visualization render mode to the next one in order, wrapping around.

## Examples

    iex> state = %BB.TUI.State{viz: %BB.TUI.State.Viz{render_mode: :auto}}
    iex> BB.TUI.State.cycle_render_mode(state).viz.render_mode
    :kitty

    iex> state = %BB.TUI.State{viz: %BB.TUI.State.Viz{render_mode: :ascii}}
    iex> BB.TUI.State.cycle_render_mode(state).viz.render_mode
    :auto

# `dismiss_event_detail`

```elixir
@spec dismiss_event_detail(t()) :: t()
```

Dismisses the event detail popup.

## Examples

    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{show_detail?: true}}
    iex> BB.TUI.State.dismiss_event_detail(state).events.show_detail?
    false

# `dismiss_force_disarm`

```elixir
@spec dismiss_force_disarm(t()) :: t()
```

Dismisses the force disarm confirmation popup.

## Examples

    iex> state = %BB.TUI.State{safety: %BB.TUI.State.Safety{confirm_force_disarm?: true}}
    iex> BB.TUI.State.dismiss_force_disarm(state).safety.confirm_force_disarm?
    false

# `enter_command_edit_mode`

```elixir
@spec enter_command_edit_mode(t()) :: t()
```

Enters argument-edit mode for the selected command, if it has arguments.

No-op when the selected command has no arguments — argument-less
commands execute directly on Enter.

## Examples

    iex> cmd = %{name: :move, arguments: [%{name: :angle, type: "float", default: 0.0}]}
    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0}}
    iex> BB.TUI.State.enter_command_edit_mode(state).commands.edit_mode?
    true

    iex> cmd = %{name: :home, arguments: []}
    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0}}
    iex> BB.TUI.State.enter_command_edit_mode(state).commands.edit_mode?
    false

# `exit_command_edit_mode`

```elixir
@spec exit_command_edit_mode(t()) :: t()
```

Exits argument-edit mode. Keeps `commands.form_values` intact.

# `focus_next_arg`

```elixir
@spec focus_next_arg(t()) :: t()
```

Focuses the next argument field, wrapping at the end.

# `focus_prev_arg`

```elixir
@spec focus_prev_arg(t()) :: t()
```

Focuses the previous argument field, wrapping at the start.

# `focused_arg`

```elixir
@spec focused_arg(t()) :: map() | nil
```

Returns the currently-focused command argument map, or `nil` when the
selected command has no arguments.

## Examples

    iex> cmd = %{name: :move, arguments: [%{name: :angle}, %{name: :side}]}
    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0, focused_arg: 1}}
    iex> BB.TUI.State.focused_arg(state)
    %{name: :side}

    iex> BB.TUI.State.focused_arg(%BB.TUI.State{commands: %BB.TUI.State.Commands{available: []}})
    nil

# `focused_arg_enum_values`

```elixir
@spec focused_arg_enum_values(t()) :: [atom()] | nil
```

Returns the enum-value list for the focused argument when the arg is
enum-typed (`{:in, [...]}` in the underlying Spark schema), otherwise
`nil`.

## Examples

    iex> cmd = %{name: :move, arguments: [%{name: :side, enum_values: [:left, :right]}]}
    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0, focused_arg: 0}}
    iex> BB.TUI.State.focused_arg_enum_values(state)
    [:left, :right]

    iex> cmd = %{name: :move, arguments: [%{name: :angle, enum_values: nil}]}
    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0, focused_arg: 0}}
    iex> BB.TUI.State.focused_arg_enum_values(state)
    nil

# `joint_step`

```elixir
@spec joint_step(map()) :: float()
```

Computes the step size for a joint based on its limits.

Returns `(upper - lower) / 100` for joints with limits, or a default
step of `π/50` (~3.6°) for unlimited joints.

## Examples

    iex> BB.TUI.State.joint_step(%{limits: %{lower: -1.0, upper: 1.0}})
    0.02

    iex> BB.TUI.State.joint_step(%{type: :continuous})
    :math.pi() / 50

# `jump_to_panel`

```elixir
@spec jump_to_panel(t(), atom()) :: t()
```

Jumps directly to the named panel, leaving everything else
unchanged. A no-op when the target isn't a known panel — so a
stray key never silently parks the dashboard in an unreachable
state.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
    iex> BB.TUI.State.jump_to_panel(state, :events).ui.active_panel
    :events

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
    iex> BB.TUI.State.jump_to_panel(state, :unknown).ui.active_panel
    :safety

# `limit_proximity`

```elixir
@spec limit_proximity(number() | nil, map()) :: :normal | :warning | :danger
```

Returns the proximity of a joint position to its nearest limit.

Returns `:danger` when within 5.0% of a limit,
`:warning` when within 15.0% of a limit,
or `:normal` otherwise.

Joints without limits always return `:normal`.

## Examples

    iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
    iex> BB.TUI.State.limit_proximity(0.0, joint)
    :normal

    iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
    iex> BB.TUI.State.limit_proximity(0.75, joint)
    :warning

    iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
    iex> BB.TUI.State.limit_proximity(0.96, joint)
    :danger

    iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
    iex> BB.TUI.State.limit_proximity(-0.96, joint)
    :danger

    iex> BB.TUI.State.limit_proximity(99.0, %{type: :continuous})
    :normal

# `mark_render_pending`

```elixir
@spec mark_render_pending(t()) :: t()
```

Flags that sensor-driven state changed and a coalesced re-render is due.

The reducer returns `render?: false` for sensor messages and sets this
flag; `BB.TUI.App`'s subscriptions callback then arms the one-shot
`:sensor_flush` tick that performs the single batched render.

    iex> BB.TUI.State.mark_render_pending(%BB.TUI.State{}).throttle.render_pending?
    true

# `next_tab`

```elixir
@spec next_tab(t()) :: t()
```

Switches to the next top-level tab, wrapping around.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_tab: :control}}
    iex> BB.TUI.State.next_tab(state).ui.active_tab
    :visualization

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_tab: :visualization}}
    iex> BB.TUI.State.next_tab(state).ui.active_tab
    :control

# `orbit_camera`

```elixir
@spec orbit_camera(t(), number(), number()) :: t()
```

Orbits the visualization camera by `yaw`/`pitch` deltas (radians).

# `panel_at`

```elixir
@spec panel_at(pos_integer()) :: atom() | nil
```

Returns the panel atom at a 1-based index, or `nil` when the index is
out of range. Mirror of `panel_number/1`, used by the number-key
jump handler.

## Examples

    iex> BB.TUI.State.panel_at(1)
    :safety

    iex> BB.TUI.State.panel_at(5)
    :parameters

    iex> BB.TUI.State.panel_at(9)
    nil

# `panel_number`

```elixir
@spec panel_number(atom()) :: pos_integer() | nil
```

Returns the 1-based number of a panel, suitable for number-key jump
hints in panel titles and help text. Returns `nil` for unknown
panels.

## Examples

    iex> BB.TUI.State.panel_number(:safety)
    1

    iex> BB.TUI.State.panel_number(:parameters)
    5

    iex> BB.TUI.State.panel_number(:unknown)
    nil

# `panels`

```elixir
@spec panels() :: [atom()]
```

Returns the ordered list of panel names for tab cycling.

## Examples

    iex> BB.TUI.State.panels()
    [:safety, :commands, :joints, :events, :parameters]

# `parameter_bounds`

```elixir
@spec parameter_bounds(t(), list()) :: {number() | nil, number() | nil} | nil
```

Returns `{min, max}` bounds for the parameter at `path` when the
Spark-style metadata declares them, otherwise `nil`.

Looks at `state.parameters.metadata[path].type` for the standard
`{head, opts}` shape used by `Spark.Options` and extracts the
`:min` / `:max` keyword values. Either bound may be absent (returned
as `nil`); both absent collapses to `nil` (no bounds).

## Examples

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:speed] => %{type: {:integer, [min: 0, max: 100]}}}}}
    iex> BB.TUI.State.parameter_bounds(state, [:speed])
    {0, 100}

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:gain] => %{type: {:float, [min: 0.0]}}}}}
    iex> BB.TUI.State.parameter_bounds(state, [:gain])
    {0.0, nil}

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:speed] => %{type: :integer}}}}
    iex> BB.TUI.State.parameter_bounds(state, [:speed])
    nil

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:speed] => %{type: {:integer, [doc: "rpm"]}}}}}
    iex> BB.TUI.State.parameter_bounds(state, [:speed])
    nil

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{}}}
    iex> BB.TUI.State.parameter_bounds(state, [:unknown])
    nil

# `parsed_args_for_selected`

```elixir
@spec parsed_args_for_selected(t()) :: map()
```

Returns the form values for the selected command, parsed by type.

Mirrors `BB.LiveView.Components.Command`'s `parse_value/1`:
`"true"`/`"false"` → boolean, `":foo"` → atom, numeric → number,
else string.

Falls back to `arg.default` for arguments the user has not touched.

## Examples

    iex> cmd = %{
    ...>   name: :move,
    ...>   arguments: [
    ...>     %{name: :angle, type: "float", default: 1.5},
    ...>     %{name: :side, type: "atom", default: :left}
    ...>   ]
    ...> }
    iex> state = %BB.TUI.State{
    ...>   commands: %BB.TUI.State.Commands{
    ...>     available: [cmd],
    ...>     selected: 0,
    ...>     form_values: %{move: %{angle: "2.5"}}
    ...>   }
    ...> }
    iex> BB.TUI.State.parsed_args_for_selected(state)
    %{angle: 2.5, side: :left}

# `prev_tab`

```elixir
@spec prev_tab(t()) :: t()
```

Switches to the previous top-level tab, wrapping around.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_tab: :control}}
    iex> BB.TUI.State.prev_tab(state).ui.active_tab
    :visualization

# `put_observed`

```elixir
@spec put_observed(t(), term(), %{display: map(), meta: map()}) :: t()
```

Records the latest observed entry for a renderer-supplied `slot_key`.

The dashboard keeps only the freshest `%{display: ..., meta: ...}` per
`slot_key` (a faster slot overwrites the previous reading rather than
accumulating; the event log carries the history). The status bar reads
`state.observed` to surface the freshest slot at a glance.

`bb_tui` never inspects `display` or `meta` beyond the generic keys the status
bar reads (`display.label`, `meta.seq`, `meta.freshness`) — both are supplied
verbatim by the consumer's renderer.

## Examples

    iex> state = BB.TUI.State.put_observed(%BB.TUI.State{}, {:wheels, :imu},
    ...>   %{display: %{label: "imu"}, meta: %{seq: 1, freshness: :fresh}})
    iex> state.observed
    %{{:wheels, :imu} => %{display: %{label: "imu"}, meta: %{seq: 1, freshness: :fresh}}}

# `put_remote_parameters`

```elixir
@spec put_remote_parameters(t(), atom(), [map()] | {:error, term()}) :: t()
```

Stores the latest remote-parameter snapshot for a bridge.

## Examples

    iex> next = BB.TUI.State.put_remote_parameters(%BB.TUI.State{}, :mavlink, [%{id: "PITCH_P", value: 0.1}])
    iex> next.parameters.remote
    %{mavlink: [%{id: "PITCH_P", value: 0.1}]}

# `remote_param_bounds`

```elixir
@spec remote_param_bounds(map()) :: {number() | nil, number() | nil} | nil
```

Returns `{min, max}` bounds for a remote parameter when the bridge
carries them as flat `:min` / `:max` keys (matching `bb_liveview`'s
shape), otherwise `nil`. Either bound may be `nil` to leave that side
open.

## Examples

    iex> BB.TUI.State.remote_param_bounds(%{id: "X", value: 1, min: 0, max: 100})
    {0, 100}

    iex> BB.TUI.State.remote_param_bounds(%{id: "X", value: 1, min: 0})
    {0, nil}

    iex> BB.TUI.State.remote_param_bounds(%{id: "X", value: 1})
    nil

# `remote_param_id`

```elixir
@spec remote_param_id(map()) :: String.t()
```

Returns the sort key used when rendering a remote parameter row.

Bridges typically use string ids (`"PITCH_P"`), but some (`BB.Bridge`
implementations are free to use atoms) return atom ids. Both
normalize to a binary so the panel and the navigation index agree on
ordering.

## Examples

    iex> BB.TUI.State.remote_param_id(%{id: "PITCH_P"})
    "PITCH_P"

    iex> BB.TUI.State.remote_param_id(%{id: :gain})
    "gain"

    iex> BB.TUI.State.remote_param_id(%{})
    ""

# `render_modes`

```elixir
@spec render_modes() :: [atom()]
```

Returns the ordered list of `Viewport3D` render modes.

## Examples

    iex> BB.TUI.State.render_modes()
    [:auto, :kitty, :sixel, :iterm2, :half_block, :braille, :ascii]

# `renderer_for`

```elixir
@spec renderer_for(t(), [atom()]) :: module() | nil
```

Returns the renderer module registered for `path`, or `nil`.

Registered renderers are keyed by a path *prefix*; a message's `path` matches
a prefix when the prefix is a leading sublist of the path (`[:demo]` matches
`[:demo]`, `[:demo, :imu]`, `[:demo, :imu, 1]`, …). When several prefixes
match, the **longest** wins — a routing-table style most-specific match — so a
consumer can register a broad `[:demo]` renderer and override a narrower
`[:demo, :raw]` one. Returns `nil` when no prefix matches, so the caller falls
through to the built-in handling.

## Examples

    iex> state = %BB.TUI.State{renderers: %{[:demo] => A, [:demo, :raw] => B}}
    iex> BB.TUI.State.renderer_for(state, [:demo, :imu])
    A

    iex> state = %BB.TUI.State{renderers: %{[:demo] => A, [:demo, :raw] => B}}
    iex> BB.TUI.State.renderer_for(state, [:demo, :raw, 1])
    B

    iex> state = %BB.TUI.State{renderers: %{[:demo] => A}}
    iex> BB.TUI.State.renderer_for(state, [:other])
    nil

    iex> BB.TUI.State.renderer_for(%BB.TUI.State{}, [:demo])
    nil

# `reset_camera`

```elixir
@spec reset_camera(t()) :: t()
```

Resets the visualization camera to the default framing.

# `scroll_down`

```elixir
@spec scroll_down(t()) :: t()
```

Scrolls the event panel down (newer events).

## Examples

    iex> list = [{~U[2026-01-01 00:00:00Z], [:test], %{}}]
    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: list, scroll_offset: 0}}
    iex> BB.TUI.State.scroll_down(state).events.scroll_offset
    0

# `scroll_help_down`

```elixir
@spec scroll_help_down(t()) :: t()
```

Scrolls the help popup down by one line.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true, help_scroll_offset: 0}}
    iex> BB.TUI.State.scroll_help_down(state).ui.help_scroll_offset
    1

# `scroll_help_up`

```elixir
@spec scroll_help_up(t()) :: t()
```

Scrolls the help popup up by one line.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true, help_scroll_offset: 0}}
    iex> BB.TUI.State.scroll_help_up(state).ui.help_scroll_offset
    0

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true, help_scroll_offset: 5}}
    iex> BB.TUI.State.scroll_help_up(state).ui.help_scroll_offset
    4

# `scroll_up`

```elixir
@spec scroll_up(t()) :: t()
```

Scrolls the event panel up (older events).

## Examples

    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{scroll_offset: 0}}
    iex> BB.TUI.State.scroll_up(state).events.scroll_offset
    0

    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{scroll_offset: 5}}
    iex> BB.TUI.State.scroll_up(state).events.scroll_offset
    4

# `select_next_command`

```elixir
@spec select_next_command(t()) :: t()
```

Selects the next command in the list.

## Examples

    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 0, available: [%{name: :a}, %{name: :b}]}}
    iex> BB.TUI.State.select_next_command(state).commands.selected
    1

    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 1, available: [%{name: :a}, %{name: :b}]}}
    iex> BB.TUI.State.select_next_command(state).commands.selected
    1

# `select_next_joint`

```elixir
@spec select_next_joint(t()) :: t()
```

Selects the next joint in the sorted list.

## Examples

    iex> entries = %{a: %{joint: %{}, position: 0.0}, b: %{joint: %{}, position: 0.0}}
    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries, selected: 0}}
    iex> BB.TUI.State.select_next_joint(state).joints.selected
    1

    iex> entries = %{a: %{joint: %{}, position: 0.0}, b: %{joint: %{}, position: 0.0}}
    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries, selected: 1}}
    iex> BB.TUI.State.select_next_joint(state).joints.selected
    1

# `select_next_param`

```elixir
@spec select_next_param(t()) :: t()
```

Selects the next parameter in the sorted list.

## Examples

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}, {[:b], 2}], selected: 0}}
    iex> BB.TUI.State.select_next_param(state).parameters.selected
    1

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}, {[:b], 2}], selected: 1}}
    iex> BB.TUI.State.select_next_param(state).parameters.selected
    1

# `select_prev_command`

```elixir
@spec select_prev_command(t()) :: t()
```

Selects the previous command in the list.

## Examples

    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 1}}
    iex> BB.TUI.State.select_prev_command(state).commands.selected
    0

    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 0}}
    iex> BB.TUI.State.select_prev_command(state).commands.selected
    0

# `select_prev_joint`

```elixir
@spec select_prev_joint(t()) :: t()
```

Selects the previous joint in the sorted list.

## Examples

    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{a: %{joint: %{}, position: 0.0}}, selected: 1}}
    iex> BB.TUI.State.select_prev_joint(state).joints.selected
    0

    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{a: %{joint: %{}, position: 0.0}}, selected: 0}}
    iex> BB.TUI.State.select_prev_joint(state).joints.selected
    0

# `select_prev_param`

```elixir
@spec select_prev_param(t()) :: t()
```

Selects the previous parameter in the sorted list.

## Examples

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}], selected: 1}}
    iex> BB.TUI.State.select_prev_param(state).parameters.selected
    0

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}], selected: 0}}
    iex> BB.TUI.State.select_prev_param(state).parameters.selected
    0

# `selected_command`

```elixir
@spec selected_command(t()) :: map() | nil
```

Returns the currently selected command map, or `nil`.

## Examples

    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [%{name: :a}, %{name: :b}], selected: 1}}
    iex> BB.TUI.State.selected_command(state)
    %{name: :b}

    iex> BB.TUI.State.selected_command(%BB.TUI.State{commands: %BB.TUI.State.Commands{available: []}})
    nil

# `selected_event`

```elixir
@spec selected_event(t()) :: {DateTime.t(), list(), term()} | nil
```

Returns the currently selected event, or nil if no events.

## Examples

    iex> list = [{~U[2026-01-01 00:00:00Z], [:test], %{payload: :ok}}]
    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: list, scroll_offset: 0}}
    iex> {_, [:test], _} = BB.TUI.State.selected_event(state)

    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: [], scroll_offset: 0}}
    iex> BB.TUI.State.selected_event(state)
    nil

# `selected_joint_name`

```elixir
@spec selected_joint_name(t()) :: atom() | nil
```

Returns the name of the currently selected joint, or nil if no joints exist.

## Examples

    iex> entries = %{elbow: %{joint: %{}, position: 0.0}, shoulder: %{joint: %{}, position: 0.0}}
    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries, selected: 1}}
    iex> BB.TUI.State.selected_joint_name(state)
    :shoulder

    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{}, selected: 0}}
    iex> BB.TUI.State.selected_joint_name(state)
    nil

# `selected_param`

```elixir
@spec selected_param(t()) :: {list(), term()} | nil
```

Returns the currently selected parameter as `{path, value}`, or nil.

Parameters are sorted by path to match the render order.

## Examples

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:b], 2}, {[:a], 1}], selected: 0}}
    iex> BB.TUI.State.selected_param(state)
    {[:a], 1}

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [], selected: 0}}
    iex> BB.TUI.State.selected_param(state)
    nil

# `selected_parameter_tab`

```elixir
@spec selected_parameter_tab(t()) :: :local | {:bridge, atom()}
```

Returns the currently selected parameter tab.

## Examples

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local, {:bridge, :mavlink}], tab_selected: 1}}
    iex> BB.TUI.State.selected_parameter_tab(state)
    {:bridge, :mavlink}

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local], tab_selected: 0}}
    iex> BB.TUI.State.selected_parameter_tab(state)
    :local

# `selected_remote_param`

```elixir
@spec selected_remote_param(t()) :: map() | nil
```

Returns the currently-focused remote parameter for the selected
bridge tab, or `nil` when the active tab is `:local`, the bridge has
no fetched list yet, or the fetch errored.

Sort order matches the panel's render (`Enum.sort_by(remote_param_id/1)`).

## Examples

    iex> remote = [%{id: "ROLL_P", value: 0.0}, %{id: "PITCH_P", value: 0.1}]
    iex> state = %BB.TUI.State{
    ...>   parameters: %BB.TUI.State.Parameters{
    ...>     tabs: [:local, {:bridge, :mavlink}],
    ...>     tab_selected: 1,
    ...>     remote: %{mavlink: remote},
    ...>     selected: 0
    ...>   }
    ...> }
    iex> BB.TUI.State.selected_remote_param(state)
    %{id: "PITCH_P", value: 0.1}

    iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local], tab_selected: 0}}
    iex> BB.TUI.State.selected_remote_param(state)
    nil

    iex> state = %BB.TUI.State{
    ...>   parameters: %BB.TUI.State.Parameters{
    ...>     tabs: [:local, {:bridge, :mavlink}],
    ...>     tab_selected: 1,
    ...>     remote: %{mavlink: {:error, :nodedown}}
    ...>   }
    ...> }
    iex> BB.TUI.State.selected_remote_param(state)
    nil

# `set_command_result`

```elixir
@spec set_command_result(t(), {:ok, term()} | {:error, term()}) :: t()
```

Sets the command execution result.

## Examples

    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{result: nil, executing: self()}}
    iex> new_state = BB.TUI.State.set_command_result(state, {:ok, :done})
    iex> {new_state.commands.result, new_state.commands.executing}
    {{:ok, :done}, nil}

# `set_joint_position`

```elixir
@spec set_joint_position(t(), atom(), float()) :: t()
```

Updates the position of a specific joint in state.

## Examples

    iex> entries = %{shoulder: %{joint: %{}, position: 0.0, target: nil}}
    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
    iex> BB.TUI.State.set_joint_position(state, :shoulder, 1.5).joints.entries.shoulder.position
    1.5

# `set_joint_target`

```elixir
@spec set_joint_target(t(), atom(), float() | nil) :: t()
```

Records the last-commanded target position for a joint. The panel
renders it as a secondary marker on the position bar so the operator
can see what the joint is moving toward. Pass `nil` to clear the
target (e.g. when the joint has reached it).

## Examples

    iex> entries = %{shoulder: %{joint: %{}, position: 0.0, target: nil}}
    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
    iex> BB.TUI.State.set_joint_target(state, :shoulder, 1.5).joints.entries.shoulder.target
    1.5

    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{}}}
    iex> BB.TUI.State.set_joint_target(state, :missing, 1.5).joints.entries
    %{}

# `set_parameter_tabs`

```elixir
@spec set_parameter_tabs(t(), [map()]) :: t()
```

Replaces the discovered parameter tabs and resets the selected tab.

Always keeps `:local` at the head, so cycling never lands in a state
where no local-parameter view is reachable.

## Examples

    iex> next = BB.TUI.State.set_parameter_tabs(%BB.TUI.State{}, [%{name: :mavlink}])
    iex> next.parameters.tabs
    [:local, {:bridge, :mavlink}]
    iex> next.parameters.tab_selected
    0

# `show_force_disarm`

```elixir
@spec show_force_disarm(t()) :: t()
```

Shows the force disarm confirmation popup.

## Examples

    iex> BB.TUI.State.show_force_disarm(%BB.TUI.State{}).safety.confirm_force_disarm?
    true

# `sorted_joint_names`

```elixir
@spec sorted_joint_names(t()) :: [atom()]
```

Returns sorted joint names, matching the render order of the joints panel.

## Examples

    iex> entries = %{elbow: %{joint: %{}, position: 0.0}, shoulder: %{joint: %{}, position: 0.0}}
    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
    iex> BB.TUI.State.sorted_joint_names(state)
    [:elbow, :shoulder]

# `start_command`

```elixir
@spec start_command(t(), term()) :: t()
```

Marks a command as currently executing.

## Examples

    iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{executing: nil, result: {:ok, :old}}}
    iex> pid = self()
    iex> new_state = BB.TUI.State.start_command(state, pid)
    iex> {new_state.commands.executing, new_state.commands.result}
    {pid, nil}

# `tabs`

```elixir
@spec tabs() :: [atom()]
```

Returns the ordered list of top-level tabs.

## Examples

    iex> BB.TUI.State.tabs()
    [:control, :visualization]

# `tick_throbber`

```elixir
@spec tick_throbber(t()) :: t()
```

Increments the throbber animation step.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{throbber_step: 3}}
    iex> BB.TUI.State.tick_throbber(state).ui.throbber_step
    4

# `toggle_event_detail`

```elixir
@spec toggle_event_detail(t()) :: t()
```

Toggles the event detail popup for the currently selected event.

## Examples

    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{show_detail?: false}}
    iex> BB.TUI.State.toggle_event_detail(state).events.show_detail?
    true

# `toggle_events_pause`

```elixir
@spec toggle_events_pause(t()) :: t()
```

Toggles the event stream pause state.

## Examples

    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{paused?: false}}
    iex> BB.TUI.State.toggle_events_pause(state).events.paused?
    true

    iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{paused?: true}}
    iex> BB.TUI.State.toggle_events_pause(state).events.paused?
    false

# `toggle_help`

```elixir
@spec toggle_help(t()) :: t()
```

Toggles the help overlay.

## Examples

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: false}}
    iex> BB.TUI.State.toggle_help(state).ui.show_help?
    true

    iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true}}
    iex> BB.TUI.State.toggle_help(state).ui.show_help?
    false

# `update_parameters`

```elixir
@spec update_parameters(t(), [{list(), term()}]) :: t()
```

Updates parameters from a parameter list.

`BB.Parameter.list/2` returns `{path, metadata}` tuples where metadata
is a map carrying `:value` plus schema-derived fields like `:type`,
`:doc`, and `:default`. The plain value is mirrored into
`state.parameters.list` so navigation code keeps working with simple
`{path, value}` tuples, while the rest of the metadata is stashed in
`state.parameters.metadata` keyed by path. Plain-value inputs (no
metadata map) leave the metadata side-channel untouched for that path.

## Examples

    iex> next = BB.TUI.State.update_parameters(%BB.TUI.State{}, [{[:speed], %{value: 100, type: :integer, doc: "rpm"}}])
    iex> next.parameters.list
    [{[:speed], 100}]
    iex> next.parameters.metadata
    %{[:speed] => %{type: :integer, doc: "rpm", default: nil}}

    iex> BB.TUI.State.update_parameters(%BB.TUI.State{}, [{[:speed], 42}]).parameters.list
    [{[:speed], 42}]

# `update_positions`

```elixir
@spec update_positions(t(), %{required(atom()) =&gt; float()}) :: t()
```

Updates joint positions from a sensor message.

Only updates joints that exist in the current state; unknown joint names
are silently ignored.

## Examples

    iex> entries = %{shoulder: %{joint: %{}, position: 0.0}}
    iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
    iex> BB.TUI.State.update_positions(state, %{shoulder: 42.0}).joints.entries.shoulder.position
    42.0

# `update_power`

```elixir
@spec update_power(t(), term()) :: t()
```

Records the latest battery or power telemetry from a sensor payload.

Recognizes `BB.Message.Sensor.BatteryState` and `BB.Message.Sensor.PowerState`
payloads and stashes the freshest of each in `state.power`; any other payload
passes through untouched. `BB.TUI.App` calls this for every `[:sensor | _]`
message, and the status bar renders the result.

## Examples

    iex> battery = %BB.Message.Sensor.BatteryState{voltage: 12.0, percentage: 0.8}
    iex> BB.TUI.State.update_power(%BB.TUI.State{}, battery).power.battery.percentage
    0.8

    iex> reading = %BB.Message.Sensor.PowerState{voltage: 11.5, current: 2.0}
    iex> BB.TUI.State.update_power(%BB.TUI.State{}, reading).power.power.voltage
    11.5

    iex> BB.TUI.State.update_power(%BB.TUI.State{}, %{names: [:a], positions: [0.0]}).power
    %BB.TUI.State.Power{}

# `update_safety`

```elixir
@spec update_safety(t(), atom(), atom()) :: t()
```

Updates safety and runtime state from a state machine message.

## Examples

    iex> state = BB.TUI.State.update_safety(%BB.TUI.State{}, :armed, :idle)
    iex> {state.safety.state, state.safety.runtime}
    {:armed, :idle}

# `viz_camera`

```elixir
@spec viz_camera(t()) :: ExRatatui.ThreeD.Camera.t()
```

Returns the visualization-tab camera, defaulting when unset.

# `viz_render_mode`

```elixir
@spec viz_render_mode(t()) :: atom()
```

Returns the visualization render mode.

# `zoom_camera`

```elixir
@spec zoom_camera(t(), number()) :: t()
```

Zooms the visualization camera; a positive `delta` moves farther from the robot.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
