Raxol.UI.Harness.OverlayPicker (Raxol v2.6.1)

View Source

Pure footer-region overlay picker primitive: a filterable list rendered as a fixed-height block of Raxol.Harness.Surface.ViewText-shaped view maps, driven entirely by Raxol.UI.Harness.InputEvent-normalized events. No process, no I/O, no device -- state is a plain map (matching Raxol.Harness.Surface's own plain-map style, not a struct), and every function here is (t(), ...) -> t() | result.

This is the host-agnostic core the harness's footer viewport grows to accommodate (see Raxol.Harness.Surface.open_overlay/3 and Raxol.UI.Rendering.PaintAuthority.InlineAuthority.set_footer_rows/2): a query row plus a scrollable window of matches, anchored above the composer, never a centered modal over history.

Raxol.UI.Components.Harness.Picker vs. this module

Raxol.UI.Components.Harness.Picker is the Component-tree variant: fuzzy-ranked matching with an inline preview, mounted through the normal Preparer -> LayoutEngine -> UIRenderer pipeline, awaiting a Component host. This module is the answer for the OTHER substrate -- the byte-level, pinned-footer-viewport rendering path (InlineAuthority/FlatAuthority) that has no Component tree to mount anything into at all. The two are siblings serving different hosts, not a migration of one into the other.

filter_fn is the fuzzy seam

filter_fn (arity 3: (query, items, label_fn) -> [item]) is exactly where a future fuzzy ranker -- the same kind of scorer Raxol.UI.Components.Harness.Picker already uses for its own matching -- drops in without touching handle_key/2's dispatch at all: swap the option, keep every keystroke/selection/render mechanism unchanged. The default is deliberately simple and honest about it: case-insensitive substring matching (label_fn.(item) downcased, query downcased, String.contains?/2), substring-first rather than fuzzy-first, because a wrong ranking in a filterable list is a worse failure than a merely literal one. fuzzy_filter/3 is that ranker, shipped: a Raxol.UI.ListScorer adapter a host opts into via filter_fn: &OverlayPicker.fuzzy_filter/3, the substring default staying exactly as-is for hosts that never opt in.

height/1 is computed from the FULL items list, never the current matches/1 result: 1 + min(max(length(items), 1), max_visible). This is deliberate -- the footer viewport this picker is hosted inside (Surface.open_overlay/3) grows the DECSTBM split ONCE, at open, to fit this claimed height. If height instead tracked the live match count, every keystroke that narrowed (or widened) the result set would have to re-pin the terminal's scroll region mid-interaction -- a visible flicker, and a much harder invariant to keep honest under InlineAuthority's seal-time-only history contract. A picker that claims its full potential height up front, then pads unused rows with blank lines, costs a few empty footer rows in exchange for zero re-pinning churn.

State shape

%{
  items: [term()],
  label_fn: (term() -> String.t()),
  filter_fn: (String.t(), [term()], (term() -> String.t()) -> [term()]),
  query: String.t(),
  selected: non_neg_integer(),
  offset: non_neg_integer(),
  max_visible: pos_integer(),
  title: String.t() | nil
}

Rendering: text leaves only, exactly height/1 of them

render/1 returns a %{type: :column, children: [...]} view map whose children are ALWAYS exactly height(t) %{type: :text} leaves: one query row, then height(t) - 1 item rows (padded with blank leaves when there are fewer matches than that). This is the row-accounting contract Raxol.Harness.Surface's footer budget depends on -- ViewText.lines/3 never needs to guess how many physical rows this picker's view map produces, because it is fixed by construction. No width truncation happens here: ViewText.lines/3 is the one trust boundary that owns display-width truncation and control-byte sanitization (see that module's moduledoc), so this module hands it full, untruncated content.

Every item label passes through a newline-flatten (String.replace(label, ["\r\n", "\n", "\r"], " ")) before rendering: ViewText.lines/3 splits a :text leaf's content on embedded newlines into MULTIPLE collected lines (its own trust-boundary contract, "one row-accounting bug turned into the multiple rows it actually is") -- correct for arbitrary content, but wrong for a picker row, where one item must always be one footer row or the fixed height/1 budget silently overflows into padding rows nobody asked for. Flattening here, before that split ever runs, keeps one item == one row true regardless of what a label contains.

Summary

Functions

The default :max_visible item-row cap new/2 uses. Exposed so hosts (Raxol.Harness.Surface.open_overlay/3) clamp against THIS value rather than re-encoding the literal -- one source of truth.

The fuzzy seam cashing in: a filter_fn-shaped adapter over Raxol.UI.ListScorer.rank/3 -- ranked (score-descending, original-order tiebreak) subsequence matching instead of the default's plain substring test. Hosts opt in via filter_fn: &OverlayPicker.fuzzy_filter/3; the default filter stays substring-only on purpose (see the moduledoc's "filter_fn is the fuzzy seam" section).

Handles one normalized InputEvent.t(). Returns {:continue, t()} to keep the overlay open with updated state, {:picked, item} when Enter committed a selection, or :dismissed when ESC was handled directly (host-agnostic -- in the assembled harness surface, the Keymap's :overlay guard captures ESC before it ever reaches this function; see Raxol.UI.Harness.Keymap).

The rows this overlay claims -- fixed at construction over the FULL item list, never the current filtered match count (see moduledoc, "no per-keystroke footer re-pin").

The current query's matches -- filter_fn applied to items.

Builds a fresh picker over items.

Renders the fixed-height view map (see moduledoc, "Rendering"): one query-row leaf followed by exactly height(t) - 1 item-row leaves.

Types

filter_fn()

@type filter_fn() :: (String.t(), [item()], label_fn() -> [item()])

item()

@type item() :: term()

label_fn()

@type label_fn() :: (item() -> String.t())

t()

@type t() :: %{
  items: [item()],
  label_fn: label_fn(),
  filter_fn: filter_fn(),
  query: String.t(),
  selected: non_neg_integer(),
  offset: non_neg_integer(),
  max_visible: pos_integer(),
  title: String.t() | nil
}

Functions

default_max_visible()

@spec default_max_visible() :: pos_integer()

The default :max_visible item-row cap new/2 uses. Exposed so hosts (Raxol.Harness.Surface.open_overlay/3) clamp against THIS value rather than re-encoding the literal -- one source of truth.

fuzzy_filter(query, items, label_fn)

@spec fuzzy_filter(String.t(), [item()], label_fn()) :: [item()]

The fuzzy seam cashing in: a filter_fn-shaped adapter over Raxol.UI.ListScorer.rank/3 -- ranked (score-descending, original-order tiebreak) subsequence matching instead of the default's plain substring test. Hosts opt in via filter_fn: &OverlayPicker.fuzzy_filter/3; the default filter stays substring-only on purpose (see the moduledoc's "filter_fn is the fuzzy seam" section).

handle_key(t, norm)

@spec handle_key(t(), Raxol.UI.Harness.InputEvent.t()) ::
  {:continue, t()} | {:picked, item()} | :dismissed

Handles one normalized InputEvent.t(). Returns {:continue, t()} to keep the overlay open with updated state, {:picked, item} when Enter committed a selection, or :dismissed when ESC was handled directly (host-agnostic -- in the assembled harness surface, the Keymap's :overlay guard captures ESC before it ever reaches this function; see Raxol.UI.Harness.Keymap).

height(map)

@spec height(t()) :: pos_integer()

The rows this overlay claims -- fixed at construction over the FULL item list, never the current filtered match count (see moduledoc, "no per-keystroke footer re-pin").

matches(map)

@spec matches(t()) :: [item()]

The current query's matches -- filter_fn applied to items.

new(items, opts \\ [])

@spec new(
  [item()],
  keyword()
) :: t()

Builds a fresh picker over items.

Options

  • :label_fn (default &to_string/1) -- derives the search key (and the rendered label) for a non-string item.
  • :max_visible (default 8) -- the cap on item rows (excluding the query row) height/1 claims.
  • :title (default nil) -- prefixed to the query row when present.
  • :filter_fn (default case-insensitive substring, see moduledoc) -- the fuzzy seam.

render(t)

@spec render(t()) :: map()

Renders the fixed-height view map (see moduledoc, "Rendering"): one query-row leaf followed by exactly height(t) - 1 item-row leaves.