Skip to content

xnano.components.options

xnano.components.options

xnano.components.options


Display and search a list of choices with keyboard selection.

Classes:

  • Option

    A labeled choice with optional distinct value and disabled flag.

  • Options

    Always-visible, selectable choice list.

Functions:

  • get_fuzzy_match

    Score candidate against query as a fuzzy subsequence.

Attributes:

OptionsDirection module-attribute

OptionsDirection: TypeAlias = Literal[
    "top_to_bottom", "bottom_to_top"
]

Visual order of option rows in the list.

FilterMode module-attribute

FilterMode: TypeAlias = Union[
    Literal["fuzzy", "prefix", "none"],
    bool,
    Callable[[str, str], bool],
]

How query narrows the visible items.

  • "fuzzy" (or True): subsequence fuzzy match with scoring.
  • "prefix": keep items whose text starts with the query.
  • "none" (or False): show every item; the query is ignored, so an external driver can supply items that are already filtered.
  • a callable (query, item_text) -> bool: keep items it returns truthy for.

AcceptPolicy module-attribute

AcceptPolicy: TypeAlias = Literal[
    "replace", "extend", "if_prefix_only"
]

How resolve_submission reconciles typed text with the highlighted row.

  • "replace": accept the highlighted item's text.
  • "extend": keep exactly what the user typed.
  • "if_prefix_only": keep the selection while the typed text is still an incomplete prefix of it; otherwise honor the typed text (so trailing args such as "/vibe hungry 7" are not dropped).

OptionItem module-attribute

OptionItem: TypeAlias = 'str | Text | Option'

A single entry accepted by Options.items.

Option dataclass

Option(
    label: str | Any,
    value: Any = None,
    disabled: bool = False,
)

A labeled choice with optional distinct value and disabled flag.

Pass plain strings or Text values for simple choices. Use Option when the stored value differs from its label or the choice is disabled.

Attributes:

  • label (str | Any) –

    Display text (plain string or styled Text leaf).

  • value (Any) –

    Stored value; defaults to the plain label text when None.

  • disabled (bool) –

    When True, movement skips this entry and it cannot become the active selection via select.

Methods:

  • get_label_text

    Return the plain-text form of label.

  • get_value

    Return the stored value, falling back to the plain label.

label instance-attribute

label: str | Any

Display text (plain string or styled Text leaf).

value class-attribute instance-attribute

value: Any = None

Stored value; defaults to the plain label text when None.

disabled class-attribute instance-attribute

disabled: bool = False

When True, movement skips this entry.

get_label_text

get_label_text() -> str

Return the plain-text form of label.

Source code in xnano/components/options.py
def get_label_text(self) -> str:
    """Return the plain-text form of ``label``."""
    label = self.label
    if isinstance(label, str):
        return label
    value = getattr(label, "value", None)
    if isinstance(value, str):
        return value
    content = getattr(label, "content", None)
    if isinstance(content, str):
        return content
    return str(label)

get_value

get_value() -> Any

Return the stored value, falling back to the plain label.

Source code in xnano/components/options.py
def get_value(self) -> Any:
    """Return the stored value, falling back to the plain label."""
    if self.value is not None:
        return self.value
    return self.get_label_text()

Options dataclass

Options(
    items: Sequence[OptionItem] = (),
    query: str = "",
    filter: FilterMode = "fuzzy",
    accept: AcceptPolicy = "replace",
    searchable: bool = False,
    selected: int = 0,
    direction: OptionsDirection = "top_to_bottom",
    foreground: ColorLike | None = None,
    background: ColorLike | None = None,
    highlight_color: ColorLike = "black",
    highlight_background: ColorLike = "white",
    highlight_symbol: str = "> ",
    hovered: int | None = None,
    hover_symbol: str = "· ",
    match_color: ColorLike | None = "cyan",
    repeat_highlight_symbol: bool = False,
    focusable: bool = True,
    passthrough: Sequence[str] = (),
    *,
    visible: bool = True,
    z: int = 0,
    fit_content: bool = True
)

Bases: Component

Always-visible, selectable choice list.

Up/down move the selection (skipping disabled entries) and value reads the selected item:

class Picker(BaseGrid):
    themes: Options = Field(
        default=Options(items=THEME_NAMES),
    )

    @on_keyboard("enter")
    def _choose(self, ctx: Context) -> None:
        apply_theme(self.themes.value)

A plain list is not a search box: typing-to-filter is opt-in. Set searchable=True to turn the list into a fuzzy filter where printable keys edit query and matched characters are emphasized in match_color — a focused searchable list intentionally consumes those keys, so leave it off for a list that only browses, or the keys never reach your command hooks. Either way you can drive query reactively from another field.

Example

Options(items=("small", "medium", "large"), selected=1)

Attributes:

Methods:

  • select_value

    Select the visible item whose stored value equals value.

  • resolve_submission

    Reconcile typed composer text with the selection per accept.

  • move

    Move selected by delta, skipping disabled entries.

  • select

    Set selected to index within the filtered view.

  • clear_query

    Clear the filter query and reset selection to the first row.

  • handle_keyboard

    Apply a keyboard event while this Options is focused.

  • compose

    Compose interface-neutral Content for this Options.

  • after_render

    Record the painted area so hover can map a pointer to a row.

  • handle_hover

    Mark the row under the pointer as hovered (mouse hover).

  • component_post_init

    Initialize subclass state after dataclass fields are assigned.

  • get_frame

    Optional frame/panel chrome around composed content.

  • get_size

    Return the preferred cell size of this component.

  • before_render

    Called before rendering; returns the effective render area.

  • compose_extra_small

    Compose content when the viewport is extra small (< 40 cols).

  • compose_small

    Compose content when the viewport is small (40–79 cols).

  • compose_medium

    Compose content when the viewport is medium (80–119 cols).

  • compose_large

    Compose content when the viewport is large (120–159 cols).

  • compose_extra_large

    Compose content when the viewport is extra large (>= 160 cols).

  • handle_paste

    Optional paste handler while focused.

items class-attribute instance-attribute

items: Sequence[OptionItem] = ()

Entries to pick from (plain strings, Text, or Option).

query class-attribute instance-attribute

query: str = ''

Filter text. Edited by typing while focused when searchable; assign it from a hook to filter reactively.

filter class-attribute instance-attribute

filter: FilterMode = 'fuzzy'

How query narrows the visible items — see FilterMode.

"fuzzy"/True fuzzy-match, "prefix" prefix-match, "none"/ False show everything (for externally filtered items), or a (query, item_text) -> bool callable.

accept class-attribute instance-attribute

accept: AcceptPolicy = 'replace'

How resolve_submission reconciles typed text with the selection.

searchable class-attribute instance-attribute

searchable: bool = False

Whether typing while focused edits query directly.

Off by default: a plain Options browses with the arrow keys and lets every other key reach app hooks. Turn it on to make the list a fuzzy search box that consumes printable keys while focused.

selected class-attribute instance-attribute

selected: int = 0

Selection index within the filtered view.

direction class-attribute instance-attribute

direction: OptionsDirection = 'top_to_bottom'

Visual order of option rows in the list.

foreground class-attribute instance-attribute

foreground: ColorLike | None = None

Default foreground color for unselected rows (deprecated alias: color).

background class-attribute instance-attribute

background: ColorLike | None = None

Default background color for unselected rows.

highlight_color class-attribute instance-attribute

highlight_color: ColorLike = 'black'

Foreground of the selected row.

highlight_background class-attribute instance-attribute

highlight_background: ColorLike = 'white'

Background of the selected row.

highlight_symbol class-attribute instance-attribute

highlight_symbol: str = '> '

Symbol prepended to the selected row.

hovered class-attribute instance-attribute

hovered: int | None = None

Row under the pointer (filtered-view index), or None.

Set by :meth:handle_hover when mouse events are enabled; drawn as a dim hover_symbol so it reads as distinct from the selection.

hover_symbol class-attribute instance-attribute

hover_symbol: str = '· '

Symbol prepended to the hovered row (dim), distinct from selection.

match_color class-attribute instance-attribute

match_color: ColorLike | None = 'cyan'

Emphasis color for characters matched by query.

repeat_highlight_symbol class-attribute instance-attribute

repeat_highlight_symbol: bool = False

When True, every row shows the highlight symbol.

focusable class-attribute instance-attribute

focusable: bool = True

Whether this Options participates in field focus (tab order).

passthrough class-attribute instance-attribute

passthrough: Sequence[str] = ()

Key bindings this list never captures while focused.

owns_cursor class-attribute instance-attribute

owns_cursor: bool = dataclasses.field(
    default=True, init=False
)

Whether selection replaces the hardware caret.

filtered property

filtered: tuple[int, ...]

Indices into items currently visible, in display order.

Use this with visible_items to inspect the current filtered view.

visible_items property

visible_items: tuple[str, ...]

Plain text of the currently visible items, in display order.

value property

value: Any | None

Stored value of the selected item, or None when empty.

selected_item property

selected_item: OptionItem | None

The selected item object, or None when the view is empty.

selected_value property

selected_value: Any | None

Stable stored value of the selection (alias of value).

Use with select_value to preserve a selection across item rebuilds by value/id rather than by fragile filtered index.

selected_label property

selected_label: str

Plain display text of the selected item, or "" when empty.

visible class-attribute instance-attribute

visible: bool = dataclasses.field(
    default=True, kw_only=True
)

Whether this component paints at all.

z class-attribute instance-attribute

z: int = dataclasses.field(default=0, kw_only=True)

Stacking order among sibling content.

fit_content class-attribute instance-attribute

fit_content: bool = dataclasses.field(
    default=True, kw_only=True
)

When True, paint at natural size inside a larger slot.

focused property

focused: bool

Whether this component currently holds field focus.

select_value

select_value(value: Any) -> bool

Select the visible item whose stored value equals value.

Returns True when a match is found. The stable way to keep a selection pinned across item rebuilds: store the value, rebuild items, then re-select by value.

Source code in xnano/components/options.py
def select_value(self, value: Any) -> bool:
    """Select the visible item whose stored value equals ``value``.

    Returns ``True`` when a match is found. The stable way to keep a
    selection pinned across item rebuilds: store the value, rebuild
    ``items``, then re-select by value.
    """
    for index, (item_index, _) in enumerate(self._filtered()):
        if _item_value(self.items[item_index]) == value:
            self.selected = index
            return True
    return False

resolve_submission

resolve_submission(typed: str) -> str

Reconcile typed composer text with the selection per accept.

Removes the userland "did they Tab-complete this row or type past it?" logic. See AcceptPolicy for the per-policy behavior.

Source code in xnano/components/options.py
def resolve_submission(self, typed: str) -> str:
    """Reconcile typed composer text with the selection per ``accept``.

    Removes the userland "did they Tab-complete this row or type past
    it?" logic. See ``AcceptPolicy`` for the per-policy behavior.
    """
    typed = typed or ""
    selected = self.selected_label
    if self.accept == "extend" or not selected:
        return typed
    if self.accept == "replace":
        return selected
    # if_prefix_only
    typed_stripped = typed.strip()
    selected_stripped = selected.strip()
    if not typed_stripped:
        return selected
    typed_lower = typed_stripped.lower()
    selected_lower = selected_stripped.lower()
    if typed_lower == selected_lower or typed_lower.startswith(
        selected_lower + " "
    ):
        return typed
    if selected_lower.startswith(typed_lower):
        return selected
    return selected

move

move(delta: int) -> None

Move selected by delta, skipping disabled entries.

Movement stays within the currently filtered choices.

Parameters:

  • delta (int) –

    Steps to move; negative moves toward the start.

Source code in xnano/components/options.py
def move(self, delta: int) -> None:
    """Move ``selected`` by ``delta``, skipping disabled entries.

    Movement stays within the currently filtered choices.

    Args:
        delta: Steps to move; negative moves toward the start.
    """
    visible = self._filtered()
    visible_count = len(visible)
    if visible_count == 0:
        self.selected = 0
        return
    current = max(0, min(self.selected, visible_count - 1))
    if delta == 0:
        self.selected = current
        return
    step = 1 if delta > 0 else -1
    remaining = abs(delta)
    index = current
    while remaining > 0:
        next_index = index + step
        if next_index < 0 or next_index >= visible_count:
            break
        index = next_index
        item_index = visible[index][0]
        if not _item_disabled(self.items[item_index]):
            remaining -= 1
    self.selected = index
    # If we landed on a disabled entry (all remaining disabled),
    # snap back to the nearest enabled neighbor.
    self._ensure_enabled_selection()

select

select(index: int) -> None

Set selected to index within the filtered view.

Disabled entries are rejected; the selection is left unchanged when index points at a disabled item. Out-of-range indices are clamped.

Parameters:

  • index (int) –

    Target index in the filtered view.

Source code in xnano/components/options.py
def select(self, index: int) -> None:
    """Set ``selected`` to ``index`` within the filtered view.

    Disabled entries are rejected; the selection is left unchanged
    when ``index`` points at a disabled item. Out-of-range indices
    are clamped.

    Args:
        index: Target index in the filtered view.
    """
    visible = self._filtered()
    if not visible:
        self.selected = 0
        return
    clamped = max(0, min(index, len(visible) - 1))
    item_index = visible[clamped][0]
    if _item_disabled(self.items[item_index]):
        return
    self.selected = clamped

clear_query

clear_query() -> None

Clear the filter query and reset selection to the first row.

Source code in xnano/components/options.py
def clear_query(self) -> None:
    """Clear the filter query and reset selection to the first row."""
    self.query = ""
    self.selected = 0
    self._ensure_enabled_selection()

handle_keyboard

handle_keyboard(keyboard: 'KeyboardEventData') -> bool

Apply a keyboard event while this Options is focused.

Up/down move the selection (skipping disabled); Home/End jump to the first/last enabled entry. Printable characters and backspace edit query when searchable. Enter, tab, escape, and passthrough bindings are never consumed so hooks and focus navigation see them.

Parameters:

  • keyboard ('KeyboardEventData') –

    The keyboard event payload.

Returns:

  • bool

    True when the event was consumed.

Source code in xnano/components/options.py
def handle_keyboard(self, keyboard: "KeyboardEventData") -> bool:
    """Apply a keyboard event while this Options is focused.

    Up/down move the selection (skipping disabled); Home/End jump
    to the first/last enabled entry. Printable characters and
    backspace edit ``query`` when ``searchable``. Enter, tab,
    escape, and ``passthrough`` bindings are never consumed so hooks
    and focus navigation see them.

    Args:
        keyboard: The keyboard event payload.

    Returns:
        ``True`` when the event was consumed.
    """
    kind = keyboard.kind
    if kind is not None and kind not in ("press", "repeat"):
        return False
    if self.passthrough and keyboard.matches(*self.passthrough):
        return False
    if keyboard.matches("enter", "tab", "escape", "esc"):
        return False

    visible_count = len(self._filtered())
    if keyboard.matches("up"):
        self.move(-1)
        return True
    if keyboard.matches("down"):
        self.move(1)
        return True
    if keyboard.matches("home"):
        self.selected = self._first_enabled_index()
        return True
    if keyboard.matches("end"):
        self.selected = self._last_enabled_index() if visible_count else 0
        return True
    if not self.searchable:
        return False
    if keyboard.matches("backspace"):
        self.query = self.query[:-1]
        self.selected = 0
        self._ensure_enabled_selection()
        return True
    character = keyboard.character
    modifiers = keyboard.modifiers
    if (
        character is not None
        and len(character) == 1
        and character.isprintable()
        and character not in ("\n", "\r", "\t")
        # ctrl/alt chords are app shortcuts, not search text.
        and "ctrl" not in modifiers
        and "alt" not in modifiers
    ):
        self.query = self.query + character
        self.selected = 0
        self._ensure_enabled_selection()
        return True
    return False

compose

compose(ctx: 'ComponentRenderContext') -> Any

Compose interface-neutral Content for this Options.

Parameters:

  • ctx ('ComponentRenderContext') –

    Render-time scope for this paint.

Returns:

  • Any

    An Items list, or a vertical Stack with a query row

  • Any

    above the list when searchable.

Source code in xnano/components/options.py
def compose(self, ctx: "ComponentRenderContext") -> Any:
    """Compose interface-neutral Content for this Options.

    Args:
        ctx: Render-time scope for this paint.

    Returns:
        An ``Items`` list, or a vertical ``Stack`` with a query row
        above the list when ``searchable``.
    """
    from xnano.core.content import Stack

    visible = self._filtered()
    available = ctx.area.height - (1 if self.searchable else 0)
    if available > 0 and len(visible) > available:
        selected = max(0, min(self.selected, len(visible) - 1))
        start = min(
            max(selected - available // 2, 0),
            len(visible) - available,
        )
        window = visible[start : start + available]
        self._visible_start = start
        hovered = self.hovered
        self.selected = selected - start
        if hovered is not None:
            self.hovered = hovered - start
        try:
            items_content = self._compose_items(ctx, visible=window)
        finally:
            self.selected = selected
            self.hovered = hovered
    else:
        self._visible_start = 0
        items_content = self._compose_items(ctx, visible=visible)
    if not self.searchable:
        return items_content
    return Stack(
        children=(self._compose_query_row(), items_content),
        direction="vertical",
        z=self.z,
        visible=self.visible,
    )

after_render

after_render(
    ctx: "ComponentRenderContext", area: Any
) -> None

Record the painted area so hover can map a pointer to a row.

Source code in xnano/components/options.py
def after_render(self, ctx: "ComponentRenderContext", area: Any) -> None:
    """Record the painted area so hover can map a pointer to a row."""
    self._content_area = area

handle_hover

handle_hover(x: int, y: int) -> bool

Mark the row under the pointer as hovered (mouse hover).

Hover is a distinct indicator from the selection: the hovered row is set here and drawn with a dim hover_symbol. Moving off the rows clears it. Returns whether the hovered row changed.

Source code in xnano/components/options.py
def handle_hover(self, x: int, y: int) -> bool:
    """Mark the row under the pointer as hovered (mouse hover).

    Hover is a *distinct* indicator from the selection: the hovered row
    is set here and drawn with a dim ``hover_symbol``. Moving off the
    rows clears it. Returns whether the hovered row changed.
    """
    del x  # rows span the full width; only the row (y) matters
    area = self._content_area
    if area is None:
        return False
    pairs = self._filtered()
    # The query row (searchable) sits above the items; skip it.
    top = area.y + (1 if self.searchable else 0)
    row = y - top
    visible_count = min(
        len(pairs) - self._visible_start,
        max(0, area.height - (1 if self.searchable else 0)),
    )
    if self.direction == "bottom_to_top" and visible_count:
        row = (visible_count - 1) - row
    hovered = (
        self._visible_start + row
        if pairs and 0 <= row < visible_count
        else None
    )
    if hovered != self.hovered:
        self.hovered = hovered
        return True
    return False

component_post_init

component_post_init() -> None

Initialize subclass state after dataclass fields are assigned.

Override this method instead of __post_init__.

Source code in xnano/components/component.py
def component_post_init(self) -> None:
    """Initialize subclass state after dataclass fields are assigned.

    Override this method instead of ``__post_init__``.
    """
    return None

get_frame

get_frame() -> Any | None

Optional frame/panel chrome around composed content.

Source code in xnano/components/component.py
def get_frame(self) -> Any | None:
    """Optional frame/panel chrome around composed content."""
    return None

get_size

get_size(ctx: ComponentRenderContext[StateT]) -> Size

Return the preferred cell size of this component.

Source code in xnano/components/component.py
def get_size(self, ctx: ComponentRenderContext[StateT]) -> Size:
    """Return the preferred cell size of this component."""
    return Size(width=0, height=0)

before_render

before_render(
    ctx: ComponentRenderContext[StateT], area: "Area"
) -> "Area"

Called before rendering; returns the effective render area.

Source code in xnano/components/component.py
def before_render(
    self,
    ctx: ComponentRenderContext[StateT],
    area: "Area",
) -> "Area":
    """Called before rendering; returns the effective render area."""
    return area

compose_extra_small

compose_extra_small(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is extra small (< 40 cols).

Optional responsive counterpart to :meth:compose. When overridden, it is used instead of compose while the window is in this size tier. Overriding any compose_* variant opts the component into breakpoint dispatch; a component that overrides none pays no per-frame cost.

Source code in xnano/components/component.py
@responsive_noop
def compose_extra_small(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is extra small (< 40 cols).

    Optional responsive counterpart to :meth:`compose`. When
    overridden, it is used *instead of* ``compose`` while the window
    is in this size tier. Overriding any ``compose_*`` variant opts the
    component into breakpoint dispatch; a component that overrides none
    pays no per-frame cost.
    """
    return None

compose_small

compose_small(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is small (40–79 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_small(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is small (40–79 cols).

    See :meth:`compose_extra_small`.
    """
    return None

compose_medium

compose_medium(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is medium (80–119 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_medium(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is medium (80–119 cols).

    See :meth:`compose_extra_small`.
    """
    return None

compose_large

compose_large(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is large (120–159 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_large(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is large (120–159 cols).

    See :meth:`compose_extra_small`.
    """
    return None

compose_extra_large

compose_extra_large(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is extra large (>= 160 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_extra_large(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is extra large (>= 160 cols).

    See :meth:`compose_extra_small`.
    """
    return None

handle_paste

handle_paste(text: str) -> bool

Optional paste handler while focused.

Returns:

  • bool

    True when the paste was consumed.

Source code in xnano/components/component.py
def handle_paste(self, text: str) -> bool:
    """Optional paste handler while focused.

    Returns:
        ``True`` when the paste was consumed.
    """
    return False

get_fuzzy_match

get_fuzzy_match(
    query: str, candidate: str
) -> tuple[int, tuple[int, ...]] | None

Score candidate against query as a fuzzy subsequence.

Case-insensitive. Consecutive matches and word-start matches score higher; shorter candidates win ties.

Parameters:

  • query (str) –

    The needle typed by the user.

  • candidate (str) –

    The haystack item text.

Returns:

  • tuple[int, tuple[int, ...]] | None

    (score, matched_indices) when every query character appears

  • tuple[int, tuple[int, ...]] | None

    in order, otherwise None. An empty query matches with score

  • tuple[int, tuple[int, ...]] | None

    zero.

Source code in xnano/components/options.py
def get_fuzzy_match(
    query: str,
    candidate: str,
) -> tuple[int, tuple[int, ...]] | None:
    """Score ``candidate`` against ``query`` as a fuzzy subsequence.

    Case-insensitive. Consecutive matches and word-start matches score
    higher; shorter candidates win ties.

    Args:
        query: The needle typed by the user.
        candidate: The haystack item text.

    Returns:
        ``(score, matched_indices)`` when every query character appears
        in order, otherwise ``None``. An empty query matches with score
        zero.
    """
    # ponytail: naive O(n*m) scan; nucleo-matcher binding if item
    # counts ever hit five digits.
    if not query:
        return (0, ())
    lowered_query = query.lower()
    lowered_candidate = candidate.lower()
    indices: list[int] = []
    score = 0
    search_from = 0
    previous = -2
    for character in lowered_query:
        found = lowered_candidate.find(character, search_from)
        if found < 0:
            return None
        score += 1
        if found == previous + 1:
            score += 4
        if found == 0 or lowered_candidate[found - 1] in _WORD_BOUNDARIES:
            score += 2
        indices.append(found)
        previous = found
        search_from = found + 1
    score -= max(0, len(candidate) - len(query)) // 4
    return (score, tuple(indices))