ExRatatui.Focus (ExRatatui v0.10.1)

Copy Markdown View Source

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
end

Styling 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"]}]
)

Mouse routing

Associate each focusable ID with a hit-test %ExRatatui.Layout.Rect{} after computing layout (typically inside a %Event.Resize{} handler or any state change that affects geometry). handle_mouse/2 then focuses the widget under a left-click, passing the event through so the underlying widget can also react.

def handle_event(%Event.Resize{width: w, height: h}, state) do
  [search_rect, body_rect] =
    Layout.split(%Rect{x: 0, y: 0, width: w, height: h}, :vertical,
      [{:length, 3}, {:min, 0}])

  focus =
    state.focus
    |> Focus.set_region(:search, search_rect)
    |> Focus.set_region(:body, body_rect)

  %{state | focus: focus}
end

def handle_event(%Event.Mouse{} = mouse, state) do
  {focus, mouse} = Focus.handle_mouse(state.focus, mouse)
  # mouse is always returned for downstream handling — left-click
  # focuses the region's ID; scroll/drag/right-click are pass-through.
  ...
end

Scroll-wheel routing is intentionally not built in: the conventional contract is "scroll goes to the focused widget", which the app can implement by inspecting Focus.current/1 after handle_mouse/2 returns. Apps that prefer "scroll goes to the widget under the cursor" can call Focus.at/3 directly.

Summary

Functions

Returns the focusable ID whose region contains the point (x, y), or nil if no registered region contains the point.

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.

Routes a mouse 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.

Returns the region registered for id, or nil if none is registered.

Associates a hit-test region with a focusable ID.

Batch-registers multiple regions in one call.

Types

id()

@type id() :: atom()

t()

@type t() :: %ExRatatui.Focus{
  ids: [id(), ...],
  index: non_neg_integer(),
  next_keys: [ExRatatui.Event.Key.t()],
  prev_keys: [ExRatatui.Event.Key.t()],
  regions: %{required(id()) => ExRatatui.Layout.Rect.t()}
}

Functions

at(focus, x, y)

@spec at(t(), non_neg_integer(), non_neg_integer()) :: id() | nil

Returns the focusable ID whose region contains the point (x, y), or nil if no registered region contains the point.

When regions overlap, the smallest one (by area) wins — overlap usually means a focusable widget sits inside a larger focusable container, and the leaf should claim the click.

Examples

iex> alias ExRatatui.{Focus, Layout.Rect}
iex> focus =
...>   Focus.new([:a, :b])
...>   |> Focus.set_region(:a, %Rect{x: 0, y: 0, width: 10, height: 10})
...>   |> Focus.set_region(:b, %Rect{x: 2, y: 2, width: 2, height: 2})
iex> Focus.at(focus, 3, 3)
:b
iex> Focus.at(focus, 8, 8)
:a
iex> Focus.at(focus, 50, 50)
nil

current(focus)

@spec current(t()) :: id()

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

focus(focus, id)

@spec focus(t(), id()) :: t()

Jumps focus to a specific ID.

Raises ArgumentError if id is not in the ring.

focused?(focus, id)

@spec focused?(t(), id()) :: boolean()

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

handle_key(focus, event)

@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.

handle_mouse(focus, event)

@spec handle_mouse(t(), ExRatatui.Event.Mouse.t()) :: {t(), ExRatatui.Event.Mouse.t()}

Routes a mouse event through the focus ring.

On a left-button down event inside a registered region, focus moves to that region's ID and the event is passed through so the underlying widget can also react (toggle a checkbox, place a cursor, start a drag). Every other mouse event — clicks outside any registered region, right/middle clicks, scroll, drag, move, up — is returned unchanged with focus untouched.

Returns {focus, event} regardless. Mirrors handle_key/2 shape so the same caller pattern ({focus, event} = Focus.handle_*(focus, event)) works for both event types.

Examples

iex> alias ExRatatui.{Focus, Event, Layout.Rect}
iex> focus =
...>   Focus.new([:a, :b])
...>   |> Focus.set_region(:a, %Rect{x: 0, y: 0, width: 10, height: 3})
...>   |> Focus.set_region(:b, %Rect{x: 0, y: 3, width: 10, height: 3})
iex> click = %Event.Mouse{kind: "down", button: "left", x: 5, y: 4}
iex> {focus, _event} = Focus.handle_mouse(focus, click)
iex> Focus.current(focus)
:b

new(ids, opts \\ [])

@spec new(
  [id(), ...],
  keyword()
) :: t()

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 and back_tab).

Raises ArgumentError for an empty list, duplicate IDs, non-atom entries, or an :initial that is not in ids.

next(focus)

@spec next(t()) :: t()

Advances focus to the next ID, wrapping from the last back to the first.

prev(focus)

@spec prev(t()) :: t()

Retreats focus to the previous ID, wrapping from the first back to the last.

region(focus, id)

@spec region(t(), id()) :: ExRatatui.Layout.Rect.t() | nil

Returns the region registered for id, or nil if none is registered.

set_region(focus, id, rect)

@spec set_region(t(), id(), ExRatatui.Layout.Rect.t()) :: t()

Associates a hit-test region with a focusable ID.

Apps call this after computing layout (typically inside a %Event.Resize{} handler, or any state change that affects the on-screen geometry of the focusable widgets). handle_mouse/2 uses the registered regions to focus the widget under a click.

Raises ArgumentError if id is not in the ring.

Examples

iex> focus = ExRatatui.Focus.new([:search, :results])
iex> rect = %ExRatatui.Layout.Rect{x: 0, y: 0, width: 40, height: 3}
iex> focus |> ExRatatui.Focus.set_region(:search, rect) |> ExRatatui.Focus.region(:search)
%ExRatatui.Layout.Rect{x: 0, y: 0, width: 40, height: 3}

set_regions(focus, regions)

@spec set_regions(t(), %{required(id()) => ExRatatui.Layout.Rect.t()}) :: t()

Batch-registers multiple regions in one call.

Equivalent to calling set_region/3 for each entry. Raises if any ID is missing from the ring.

Examples

iex> focus = ExRatatui.Focus.new([:a, :b])
iex> rects = %{
...>   a: %ExRatatui.Layout.Rect{x: 0, y: 0, width: 10, height: 1},
...>   b: %ExRatatui.Layout.Rect{x: 0, y: 1, width: 10, height: 1}
...> }
iex> focus |> ExRatatui.Focus.set_regions(rects) |> ExRatatui.Focus.region(:b)
%ExRatatui.Layout.Rect{x: 0, y: 1, width: 10, height: 1}