Focus management for multi-panel apps.
Focus is a tiny state machine over an ordered ring of focusable
IDs. You declare the IDs up front, feed every key event through
handle_key/2, and pattern-match on current/1 to decide which
widget receives the keystroke. handle_key/2 consumes
Tab / Shift+Tab (or your overrides) and passes everything else
through unchanged.
There is no process, no macro, no protocol — just a struct you keep
in your reducer state or ExRatatui.App model.
Caller pattern
def handle_event(%Event.Key{} = key, state) do
{focus, key} = Focus.handle_key(state.focus, key)
state = %{state | focus: focus}
case key do
nil ->
# consumed by Focus (Tab / Shift+Tab); nothing more to do
state
key ->
case Focus.current(focus) do
:search -> update_search(state, key)
:results -> update_results(state, key)
:details -> update_details(state, key)
end
end
endStyling the focused widget
Focus never touches widget structs. Use focused?/2 to decide the
style yourself:
border_style =
if Focus.focused?(focus, :search),
do: %Style{fg: :yellow},
else: %Style{fg: :gray}
%TextInput{
state: search_state,
block: %Block{borders: :all, border_style: border_style}
}Custom keys
Pass :next_keys / :prev_keys to new/2 as lists of
%ExRatatui.Event.Key{} structs. Only :code and :modifiers
matter — :kind is ignored, and :modifiers is compared as a set
(order-independent).
Focus.new([:a, :b, :c],
next_keys: [%Event.Key{code: "tab"}, %Event.Key{code: "right", modifiers: ["ctrl"]}],
prev_keys: [%Event.Key{code: "left", modifiers: ["ctrl"]}]
)
Summary
Functions
Returns the currently focused ID.
Jumps focus to a specific ID.
Returns true when id is the currently focused ID.
Routes a key event through the focus ring.
Builds a focus ring from an ordered list of IDs.
Advances focus to the next ID, wrapping from the last back to the first.
Retreats focus to the previous ID, wrapping from the first back to the last.
Types
@type id() :: atom()
@type t() :: %ExRatatui.Focus{ ids: [id(), ...], index: non_neg_integer(), next_keys: [ExRatatui.Event.Key.t()], prev_keys: [ExRatatui.Event.Key.t()] }
Functions
Returns the currently focused ID.
Examples
iex> ExRatatui.Focus.new([:a, :b, :c]) |> ExRatatui.Focus.current()
:a
iex> ExRatatui.Focus.new([:a, :b, :c], initial: :b) |> ExRatatui.Focus.current()
:b
Jumps focus to a specific ID.
Raises ArgumentError if id is not in the ring.
Returns true when id is the currently focused ID.
Examples
iex> focus = ExRatatui.Focus.new([:a, :b, :c])
iex> ExRatatui.Focus.focused?(focus, :a)
true
iex> ExRatatui.Focus.focused?(focus, :b)
false
@spec handle_key(t(), ExRatatui.Event.Key.t()) :: {t(), ExRatatui.Event.Key.t() | nil}
Routes a key event through the focus ring.
Returns {focus, nil} when the event matched a :next_keys or
:prev_keys entry (focus moved, event consumed). Returns
{focus, event} unchanged otherwise so the caller can forward it to
the currently focused widget.
Matching compares :code and :modifiers (as a set). :kind is
ignored.
Builds a focus ring from an ordered list of IDs.
Options
:initial— ID to start focused on (defaults to the first entry).:next_keys— list of%ExRatatui.Event.Key{}that advance focus (defaults to Tab).:prev_keys— list of%ExRatatui.Event.Key{}that retreat focus (defaults to Shift+Tab andback_tab).
Raises ArgumentError for an empty list, duplicate IDs, non-atom
entries, or an :initial that is not in ids.
Advances focus to the next ID, wrapping from the last back to the first.
Retreats focus to the previous ID, wrapping from the first back to the last.