Skip to content

xnano.components.dropdown

xnano.components.dropdown

xnano.components.dropdown


Choose an item from a collapsible, searchable list.

Classes:

Dropdown dataclass

Dropdown(
    items: Sequence[OptionItem] = (),
    query: str = "",
    filter: FilterMode = "fuzzy",
    accept: AcceptPolicy = "replace",
    searchable: bool = True,
    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] = (),
    open: bool = False,
    placeholder: str | Any | None = None,
    max_visible: int | None = None,
    close_on_select: bool = True,
    open_keys: Sequence[str] = ("enter", "space", "down"),
    close_keys: Sequence[str] = ("enter", "escape"),
    *,
    visible: bool = True,
    z: int = 0,
    fit_content: bool = True
)

Bases: Options

Collapsible choice list.

When closed, the dropdown shows the selected label or placeholder. When open, users can search and move through the available options.

class Form(BaseGrid):
    theme: Dropdown = Field(
        default=Dropdown(
            items=("dark", "light", "system"),
            placeholder="pick a theme",
        ),
    )

    @on_keyboard("enter")
def _choose(self, ctx: Context) -> None:
    if not self.theme.open:
        apply_theme(self.theme.value)
Example

Dropdown(items=("dark", "light"), placeholder="Choose a theme")

Attributes:

Methods:

  • handle_keyboard

    Open, close, search, or move through the dropdown.

  • compose

    Compose closed label or open options content.

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

  • after_render

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

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

  • 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_hover

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

searchable class-attribute instance-attribute

searchable: bool = True

Whether typing while open edits query.

A dropdown is a search box when expanded, so unlike a plain Options list it defaults to True; set False for a pick-only dropdown.

open class-attribute instance-attribute

open: bool = False

Whether the options list is expanded.

placeholder class-attribute instance-attribute

placeholder: str | Any | None = None

Closed-row text when nothing is selected (string or Text).

max_visible class-attribute instance-attribute

max_visible: int | None = None

Cap on open-list rows around the selection; None shows all.

close_on_select class-attribute instance-attribute

close_on_select: bool = True

Close after enter accepts the current selection.

open_keys class-attribute instance-attribute

open_keys: Sequence[str] = ('enter', 'space', 'down')

Bindings that open a closed dropdown.

close_keys class-attribute instance-attribute

close_keys: Sequence[str] = ('enter', 'escape')

Bindings that close an open dropdown.

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.

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.

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.

handle_keyboard

handle_keyboard(keyboard: 'KeyboardEventData') -> bool

Open, close, search, or move through the dropdown.

Opening keys expand a closed dropdown. Escape closes it without changing the selection. Enter accepts the selected value and closes it when close_on_select is enabled.

Parameters:

  • keyboard ('KeyboardEventData') –

    The keyboard event payload.

Returns:

  • bool

    True when the event was consumed.

Source code in xnano/components/dropdown.py
def handle_keyboard(self, keyboard: "KeyboardEventData") -> bool:
    """Open, close, search, or move through the dropdown.

    Opening keys expand a closed dropdown. Escape closes it without
    changing the selection. Enter accepts the selected value and closes
    it when ``close_on_select`` is enabled.

    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 not self.open:
        if self.open_keys and keyboard.matches(*self.open_keys):
            self.open = True
            self._ensure_enabled_selection()
            return True
        return False

    # Open: escape always dismisses without changing selection.
    if keyboard.matches("escape", "esc"):
        self.open = False
        return True

    if keyboard.matches("enter"):
        # Accept current selection; close only when configured.
        self._ensure_enabled_selection()
        if self.close_on_select:
            self.open = False
        # Bubble so application hooks can read ``value``.
        return False

    if self.close_keys and keyboard.matches(*self.close_keys):
        # Non-enter close keys (e.g. a custom binding) dismiss.
        if not keyboard.matches("enter"):
            self.open = False
            return True

    # While open, reuse Options movement / filter handling.
    return super().handle_keyboard(keyboard)

compose

compose(ctx: 'ComponentRenderContext') -> Any

Compose closed label or open options content.

Parameters:

  • ctx ('ComponentRenderContext') –

    Render-time scope for this paint.

Returns:

  • Closed ( Any ) –

    a single TextBlock row. Open: the same content

  • Any

    tree Options would produce, optionally windowed.

Source code in xnano/components/dropdown.py
def compose(self, ctx: "ComponentRenderContext") -> Any:
    """Compose closed label or open options content.

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

    Returns:
        Closed: a single ``TextBlock`` row. Open: the same content
        tree ``Options`` would produce, optionally windowed.
    """
    from xnano.core.content import Stack

    if not self.open:
        return self._compose_closed_row()

    window = self._windowed_visible()
    # Remap selected into the window for Items highlight, then
    # restore — selection state stays global on the component.
    global_selected = self.selected
    local = self._window_selected_offset(window)
    if local is not None:
        self.selected = local
    try:
        items_content = self._compose_items(ctx, visible=window)
    finally:
        self.selected = global_selected

    if not self.searchable:
        return items_content
    return Stack(
        children=(self._compose_query_row(), items_content),
        direction="vertical",
        z=self.z,
        visible=self.visible,
    )

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

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

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

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_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