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.
Fixed claimed height -- no per-keystroke footer re-pin
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
@type item() :: term()
@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
@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.
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).
@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).
@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").
The current query's matches -- filter_fn applied to items.
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/1claims.:title(defaultnil) -- prefixed to the query row when present.:filter_fn(default case-insensitive substring, see moduledoc) -- the fuzzy seam.
Renders the fixed-height view map (see moduledoc, "Rendering"): one
query-row leaf followed by exactly height(t) - 1 item-row leaves.