Flicker.CursorContext (Flicker v0.1.0)

Copy Markdown View Source

The cursor-context state machine (Spec 003) — a pure function that, given typed input, a cursor position, and the active facet registry, says what the user is typing right now: a facet key, a facet value, or free text. The dropdown reads this to pick its result source (key suggestions, a facet's value picklist / nested search, or plain free-text handling) and its keyboard behaviour, without needing a browser to test any of it.

This module has no LiveView dependency — classify/3 is deterministic and never raises, so it is exercised directly by unit and property tests against arbitrary strings and cursor positions.

Cursor position

cursor is a codepoint offset into input (0 is before the first character, String.length(input) is after the last) — the same unit String.length/1 and String.slice/3 use. A caller wiring this to a real <input>'s selectionStart (a UTF-16 code-unit offset) converts between the two with from_utf16_offset/2 (and parses the raw event payload with parse_selection_start/1 first — see Flicker.Components.Search/ Flicker.Components.Select, or the higher-level Flicker.FacetSuggest.classify/3, which does both steps for you); out-of-range values (negative, or past the end of input) are clamped rather than raising.

States

  • {:key, prefix} — the cursor sits inside a token before any facet operator has been typed (stat| inside stat, or a plain free-text word not yet followed by :/>/etc.). prefix is the token's text from its start up to the cursor — filter facet-key suggestions by it.
  • {:value, facet, prefix} — the token up to the operator resolves to a known facet (status: → %Flicker.Facet{key: :status}) and the operator itself is legal for it (: is always legal; a symbol operator like >= must be in the facet's :operators). prefix is the value text typed so far (quote/escape-unwrapped if the value is quoted), truncated at the cursor — filter the facet's picklist / nested search by it. The value need not cast cleanly yet — that validation belongs to Flicker.Query.parse/2, not here, or the state would never fire while an enum value is still mid-type.
  • :text — the token up to the operator does not resolve to a known, legal facet (unknown key, or a symbol operator the facet doesn't allow) and the cursor sits at or past that operator — this token is headed for free text once Flicker.Query.parse/2 sees it, and there is no facet to drive value suggestions from.

Trigger character

Passing a Flicker.Trigger as the optional fourth argument (Spec 024) gates {:key, _} behind an explicit gesture: only a token opening with a trigger grapheme (@stat) reports a key prefix, and it reports it without the trigger, so the completion is the ordinary canonical token. Every other bare word is :text. {:value, _, _} is untouched — a facet typed out in full is still recognised, since gating recognition would break pasted and URL-restored queries.

Summary

Types

The text of the token from its start up to the cursor.

t()

What the cursor is currently positioned to type.

Functions

Classifies what cursor is positioned to type in input, given the active facets.

Converts utf16_offset — a UTF-16 code-unit offset, the unit a real <input>'s selectionStart reports — into the codepoint offset classify/3/token_start/2 expect (see the "Cursor position" section above). A no-op for the common case: every codepoint Flicker's own facet grammar cares about (ASCII, plus the Unicode letters/digits key_char?/1 allows in a key) is exactly one UTF-16 code unit. Only a codepoint outside the Basic Multilingual Plane — an emoji typed into a free-text facet value, say — is encoded as a two-unit surrogate pair in UTF-16 and needs collapsing to the single codepoint it is.

Parses a selectionStart value out of an event payload the colocated hook sends (Spec 003's cursor-tracking follow-up, now closed). Every value pushed over the LiveView socket arrives as a string (phx-value-* attributes and pushed event payloads are always strings), so this is the one place both Flicker.Components.Search and Flicker.Components.Select parse it back into an integer — or nil for anything that isn't a clean non-negative integer, which is exactly what Flicker.FacetSuggest.classify/3 treats as "no position reported yet" and falls back to the end of the text for (the dead-render / very first keystroke case, before the hook's first event has landed).

The {start, stop} codepoint bounds of the token containing cursor — token_start/2 is elem(token_bounds(input, cursor), 0). stop is that same token's end offset (one past its last codepoint), or start itself when the cursor sits in a whitespace gap between tokens / empty input (matching token_start/2's own empty-virtual-token behaviour there).

The start offset (codepoint index) of the token containing cursor, using the same quote-aware tokenizer classify/3 does — a double-quoted span counts as one token even when it contains whitespace (worker:"Casey N| is a single token starting at 0, not split at the space before N).

Types

prefix()

@type prefix() :: String.t()

The text of the token from its start up to the cursor.

t()

@type t() :: {:key, prefix()} | {:value, Flicker.Facet.t(), prefix()} | :text

What the cursor is currently positioned to type.

Functions

classify(input, cursor, facets, trigger \\ nil)

@spec classify(String.t(), integer(), [Flicker.Facet.t()], Flicker.Trigger.t() | nil) ::
  t()

Classifies what cursor is positioned to type in input, given the active facets.

Pure and total: any input (empty, malformed, arbitrary Unicode) and any cursor (including out-of-range integers, which are clamped to 0..String.length(input)) produce one of the three states in t/0 — never an exception.

Examples

iex> Flicker.CursorContext.classify("stat", 4, [%Flicker.Facet{key: :status}])
{:key, "stat"}

iex> Flicker.CursorContext.classify("status:", 7, [%Flicker.Facet{key: :status}])
{:value, %Flicker.Facet{key: :status}, ""}

iex> Flicker.CursorContext.classify("status:active", 10, [%Flicker.Facet{key: :status}])
{:value, %Flicker.Facet{key: :status}, "act"}

iex> Flicker.CursorContext.classify("bogus:active", 13, [])
:text

iex> Flicker.CursorContext.classify("", 0, [])
{:key, ""}

With a Flicker.Trigger (Spec 024), {:key, _} fires only for a token that opens with a trigger grapheme — the trigger itself is dropped from the reported prefix, and the facets considered are that trigger's own scope:

iex> facets = [%Flicker.Facet{key: :status}]
...> Flicker.CursorContext.classify("@stat", 5, facets, %{"@" => :all})
{:key, "stat"}

iex> Flicker.CursorContext.classify("stat", 4, [%Flicker.Facet{key: :status}], %{"@" => :all})
:text

...but a facet typed out in full is still recognised, against the whole registry — a pasted or URL-restored query must not stop working because a trigger is configured:

iex> facets = [%Flicker.Facet{key: :status}]
...> Flicker.CursorContext.classify("status:acti", 11, facets, %{"@" => :all})
{:value, %Flicker.Facet{key: :status}, "acti"}

from_utf16_offset(input, utf16_offset)

@spec from_utf16_offset(String.t(), non_neg_integer()) :: non_neg_integer()

Converts utf16_offset — a UTF-16 code-unit offset, the unit a real <input>'s selectionStart reports — into the codepoint offset classify/3/token_start/2 expect (see the "Cursor position" section above). A no-op for the common case: every codepoint Flicker's own facet grammar cares about (ASCII, plus the Unicode letters/digits key_char?/1 allows in a key) is exactly one UTF-16 code unit. Only a codepoint outside the Basic Multilingual Plane — an emoji typed into a free-text facet value, say — is encoded as a two-unit surrogate pair in UTF-16 and needs collapsing to the single codepoint it is.

Examples

iex> Flicker.CursorContext.from_utf16_offset("status:active", 7)
7

iex> Flicker.CursorContext.from_utf16_offset("😀status", 3)
2

parse_selection_start(value)

@spec parse_selection_start(String.t() | integer() | nil) :: non_neg_integer() | nil

Parses a selectionStart value out of an event payload the colocated hook sends (Spec 003's cursor-tracking follow-up, now closed). Every value pushed over the LiveView socket arrives as a string (phx-value-* attributes and pushed event payloads are always strings), so this is the one place both Flicker.Components.Search and Flicker.Components.Select parse it back into an integer — or nil for anything that isn't a clean non-negative integer, which is exactly what Flicker.FacetSuggest.classify/3 treats as "no position reported yet" and falls back to the end of the text for (the dead-render / very first keystroke case, before the hook's first event has landed).

Examples

iex> Flicker.CursorContext.parse_selection_start("7")
7

iex> Flicker.CursorContext.parse_selection_start(nil)
nil

iex> Flicker.CursorContext.parse_selection_start("nope")
nil

token_bounds(input, cursor)

@spec token_bounds(String.t(), integer()) :: {non_neg_integer(), non_neg_integer()}

The {start, stop} codepoint bounds of the token containing cursor — token_start/2 is elem(token_bounds(input, cursor), 0). stop is that same token's end offset (one past its last codepoint), or start itself when the cursor sits in a whitespace gap between tokens / empty input (matching token_start/2's own empty-virtual-token behaviour there).

Used by Flicker.FacetSuggest.replace_current_token/3 to know not just where a chosen suggestion's replacement text starts, but where the token being completed ends — so completing a token the cursor has been moved back into (mid-token editing, Spec 003's cursor-tracking follow-up, now closed) never clobbers whatever free text or other facet tokens follow it.

Examples

iex> Flicker.CursorContext.token_bounds("foo bar stat", 12)
{8, 12}

iex> Flicker.CursorContext.token_bounds("status:acti tier:legendary", 10)
{0, 11}

token_start(input, cursor)

@spec token_start(String.t(), integer()) :: non_neg_integer()

The start offset (codepoint index) of the token containing cursor, using the same quote-aware tokenizer classify/3 does — a double-quoted span counts as one token even when it contains whitespace (worker:"Casey N| is a single token starting at 0, not split at the space before N).

Used by Flicker.FacetSuggest.replace_current_token/3 to find the boundary to splice a chosen suggestion in at, so replacing a token never clips mid-quote.

Examples

iex> Flicker.CursorContext.token_start("foo bar stat", 12)
8

iex> Flicker.CursorContext.token_start(~s(worker:"Casey N), 16)
0