Skip to content

Pickers

A picker narrows a list of options as you type and lets you choose one: a combobox, a command palette, a search box's suggestions, or fzf. Counterweight gives pickers their parts rather than a picker: Highlight, which option is highlighted as the options change, and the matchers, which options a typed text keeps. Each picker wires them to its own keys, state and drawing in a handler of its own, since pickers differ in exactly those. The combobox widget wires them the one way the WAI-ARIA combobox pattern does.

This page covers the parts, then each kind of picker built from them, then the widget. It doesn't cover positioning a list over the content around its field, which the autocomplete recipe does.

The parts

Matching

subsequence_matches keeps the options that hold the typed letters in order, with anything between them, as fzf matches, though it doesn't rank them, so py matches mypy as well as pytest. substring_matches keeps the ones that contain the text whole, and prefix_matches the ones that start with it, as Tab completion does.

All three compare text folded with casefold, so SS matches straße, keep every option for an empty text, and keep the options in their own order, but for the one the text names exactly, which comes first, so typing a whole name and pressing Enter chooses it, even when it's also part of a longer name above it. subsequence_matches escapes each letter with glob.escape, so a typed [ or ? matches itself. Each takes a key, the text to match an option by, so a picker can match a richer option on part of it and get the options back: substring_matches(text, processes, key=lambda process: f"{process.pid} {process.command}").

The caller filters, in the line that computes what to show, so a picker can match however it likes, as inline completion does.

Highlighting

A Highlight is which of a list of options is highlighted: the one whose key is its key, or, when none is, the one at its index. The key keeps it on its option as options before it come and go, as React Aria's focusedKey does, and the index is where it lands when its own option goes. Highlight() is the first option, Highlight(index=len(options) - 1) the last, and Highlight(key=current) the current one, wherever the list puts it.

It holds no options, so each method takes them: index_in is the highlighted option's index, moved is the highlight some options on, and landed is the highlight on the option it's on now, so it follows that option from then on. At the ends, moved wraps (ends="wrap", the default), stops ("stop", as the combobox pattern and fzf do), or releases the highlight to no option at all ("release", which returns None). Each method also takes the option's key, which defaults to the option itself, and is_disabled, which marks the options the highlight skips, such as headings.

Deciding from the latest state

Several key presses can arrive before the next render, when typing fast or typing before the first frame, and each runs a handler the last render made. A handler that only moves state along passes the setter a function, but one that decides something, whether to claim a key or which option to choose, reads the latest state at the top with get_state (see Deciding from the latest state), and matches the latest text again:

now = get_state(set_query)
now_shown = substring_matches(now.text, COMMANDS)
index = now.highlight.index_in(now_shown)

Otherwise Enter typed with the text before it chooses from the list as last rendered. A handler that decides from where the cursor is calls the text hook's latest_cursor(), since its cursor is where the last render placed it.

A long list

A list of options is a scroll container with a maximum height, and Down and Up have to keep the highlighted option in view, but focus stays in the text field, so moving focus can't do it, as it does for a list whose options take focus. The picker gives one ElementHandle to whichever option is highlighted, and every key it handles returns ScrollIntoView for that handle, with block="nearest", after whatever the text field's handler returned for a key it edits with. response_controls turns that handler's response into a tuple of controls, or None if it didn't handle the key, so the picker can return them together. ScrollIntoView names the element that has the handle in the render the key causes, so the list scrolls in the same frame the highlight moves, and only as far as it takes. This is how a web combobox does it, marking the highlighted option with aria-activedescendant and calling scrollIntoView({block: "nearest"}) on it.

Kinds of picker

Every picker below draws its text and its rows with the same two functions, and a picker that owns its text keeps it in a Query with the highlight, whose edit puts the highlight back on the first match inside the updater, so every edit resets it, whether it came from a key, a paste or a click:

CURSOR = Style(text_style=(text_color("slate", 900) | text_bg("sky", 300)).text_style)


@dataclass(frozen=True, slots=True)
class Query:
    """The text typed into a picker that owns it, and the highlight, which an edit puts back on the first match."""

    text: str = ""
    highlight: Highlight = Highlight()

    def edited(self, text: str) -> Query:
        return self if text == self.text else Query(text=text)


def field_row(field: TextInput, ghost: str = "") -> Div:
    """The text and its cursor, as `text_input` draws them, and `ghost` dimmed after them, starting in the cursor's cell."""
    at_cursor = field.at_cursor
    if ghost and not field.after_cursor:
        at_cursor, ghost = ghost[0], ghost[1:]
    return Div(
        style=row | overflow_x_scroll | stick_to_end_x,
        on_input=field.on_press,
        children=(
            Text(content=field.before_cursor, style=text_wrap_none),
            Text(content=at_cursor, style=text_wrap_none | CURSOR, handle=field.cursor_cell),
            Text(content=field.after_cursor, style=text_wrap_none),
            Text(content=ghost, style=text_wrap_none | text_color("slate", 500)),
        ),
    )


def option_rows[T](
    shown: Sequence[T],
    highlighted: int | None,
    row_handle: ElementHandle,
    choose: Callable[[T], InputResponse],
    label: Callable[[T], str] = str,
) -> Div:
    """A row per option, the highlighted one marked and given `row_handle`, each choosing its option on a click."""
    return Div(
        style=col | max_height(4) | overflow_y_scroll,
        children=tuple(
            Text(
                content=label(option),
                style=text_wrap_none | (text_bg("sky", 800) if index == highlighted else Style()),
                handle=row_handle if index == highlighted else None,
                on_input=on_click(partial(choose, option)),
            )
            for index, option in enumerate(shown)
        ),
    )

Every picker is a set of answers to the same few questions, and each answer shows up as a piece of state or a case in the handler:

Decision Choices How it shows in code
Where the options appear A popup; a list that's always there; not drawn, cycled into the text; ghost text A popup keeps whether it's open beside the highlight; an always-open list leaves Escape to bubble; cycling draws no rows; ghost text keeps no Highlight at all
What the value is Free text the options suggest; one of the options Free text: the parent owns the text, and choosing writes an option into it. An option: the picker owns the text, and choosing calls the parent with the option
What's highlighted after an edit The first match; nothing until Down or Up A Highlight, reset to Highlight(); or a Highlight \| None, reset to None, where Enter with None acts on the text
What moving does at the ends Wrap; stop; release the highlight ends= on moved; "release" goes with a Highlight \| None
When matches show Once something is typed; always substring_matches(text, OPTIONS) if text else (), or the call alone
What matches Letters in order; the text whole; a prefix; anything else The matcher, or an expression of the picker's own, with key= to match part of an option
How the highlight follows changing options By position; by the option's key key on Highlight's methods, and landed after an edit
Rows the highlight skips None; headings is_disabled= on Highlight's methods
What choosing does Fill in the text and close; act and let the parent close; act and stay The Enter case, and what the parent's callback does
Which keys do what The combobox pattern's; the command line's; the app's own The cases of the handler's match
How many One; several A frozenset of marks beside the highlight
Who owns the state The picker; its parent Where the use_state calls are

How the examples answer them:

Example Appear Value After an edit Ends Matching Follows Choosing
Combobox Popup Free text First match Stop Subsequence Position Fills in the text
Command palette Always An option First match Wrap Substring, always Position Acts
Search box Once typed Free text Nothing Release Substring, once typed Position Acts on the text or the option
Tab cycling Not drawn Free text Nothing Wrap Prefix Position Writes into the text
Inline completion Ghost text Free text (derived) (none) Prefix of history (none) Fills in the text
Refreshing rows Always An option First match Wrap Substring by key Key Acts and stays
Marks Always Several options First match Wrap Subsequence by key Key Acts on the marks
Headings Always An option First match Wrap Substring, by section Position, skipping headings Acts
Preview Always The highlight First match Wrap Substring Position (nothing)

A combobox with free text

The parent owns the text, an edit opens the list, Down or Up opens it too, with every option for an empty text, and Escape or choosing closes it. Since the parent owns the text, the highlight resets in a wrapper around the parent's setter, which compares the text before the edit with the text the setter settled at:

@dataclass(frozen=True, slots=True)
class Suggesting:
    highlight: Highlight = Highlight()
    is_open: bool = True


@component
def package_field(text: str, set_text: Setter[str]) -> Div:
    suggesting, set_suggesting = use_state(Suggesting(is_open=False))
    row_handle, _ = use_state(ElementHandle)

    def edit(edit_text: Callable[[str], str]) -> str:
        before = get_state(set_text)
        if (after := set_text(edit_text)) != before:
            set_suggesting(Suggesting())
        return after

    field = use_text_input(text, edit, auto_focus=True)
    shown = subsequence_matches(text, PACKAGES)
    is_open = field.focus.is_focused and suggesting.is_open and bool(shown)

    def choose(package: str) -> InputResponse:
        set_text(package)
        set_suggesting(Suggesting(is_open=False))
        return ()

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        now = get_state(set_suggesting)
        now_shown = subsequence_matches(get_state(set_text), PACKAGES)
        is_showing = now.is_open and bool(now_shown)
        index = now.highlight.index_in(now_shown)
        match event:
            case KeyPressed(key=Key.Down | Key.Up as key) if now_shown:
                by = 1 if key == Key.Down else -1
                if is_showing:
                    highlight = now.highlight.moved(now_shown, by, ends="stop")
                else:
                    highlight = Highlight(index=0 if by == 1 else len(now_shown) - 1)
                set_suggesting(Suggesting(highlight=highlight))
                return ScrollIntoView(row_handle, block="nearest")
            case KeyPressed(key=Key.Enter | Key.ControlJ | Key.Tab) if is_showing and index is not None:
                return choose(now_shown[index])
            case KeyPressed(key=Key.Escape) if is_showing:
                set_suggesting(replace(now, is_open=False))
                return ()
            case _:
                if (controls := response_controls(field.on_input(event, receiver))) is None:
                    return None
                return (*controls, ScrollIntoView(row_handle, block="nearest"))

    return Div(
        style=col | width(24),
        on_input=on_input,
        children=(
            field_row(field),
            *((option_rows(shown, suggesting.highlight.index_in(shown), row_handle, choose),) if is_open else ()),
        ),
    )

The packages matching "py", the second highlighted

 Add a package                
 py                           
 mypy                         
 pydantic                     
 pydantic-settings            
 pytest                       

A command palette

An always-open list whose value is one of its options: the text is the palette's own, there's no open or closed state, and Escape isn't handled, so it bubbles to whatever opened the palette:

@component
def command_palette(run: Callable[[str], InputResponse]) -> Div:
    query, set_query = use_state(Query())
    row_handle, _ = use_state(ElementHandle)
    field = use_text_input(query.text, lambda edit: set_query(lambda q: q.edited(edit(q.text))).text, auto_focus=True)
    shown = substring_matches(query.text, COMMANDS)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        now = get_state(set_query)
        now_shown = substring_matches(now.text, COMMANDS)
        index = now.highlight.index_in(now_shown)
        match event:
            case KeyPressed(key=Key.Down | Key.Up as key):
                set_query(replace(now, highlight=now.highlight.moved(now_shown, 1 if key == Key.Down else -1)))
                return ScrollIntoView(row_handle, block="nearest")
            case KeyPressed(key=Key.Enter | Key.ControlJ) if index is not None:
                return run(now_shown[index])
            case _:
                if (controls := response_controls(field.on_input(event, receiver))) is None:
                    return None
                return (*controls, ScrollIntoView(row_handle, block="nearest"))

    return Div(
        style=col | width(24) | border | border_lightrounded,
        on_input=on_input,
        children=(
            field_row(field),
            option_rows(shown, query.highlight.index_in(shown), row_handle, run),
        ),
    )

The commands containing "f", the second highlighted

 ╭──────────────────────╮     
 │f                     │     
 │Find in files         │     
 │Format document       │     
 │Open file             │     
 ╰──────────────────────╯     
 Ran: nothing yet             

A search box with manual selection

Nothing is highlighted until Down or Up, so Enter searches what was typed, or the suggestion the keys moved to, and moving past either end releases the highlight back to the text, as the combobox pattern's "returns focus to the combobox":

@dataclass(frozen=True, slots=True)
class Search:
    text: str = ""
    highlight: Highlight | None = None

    def edited(self, text: str) -> Search:
        return self if text == self.text else Search(text=text)


@component
def search_box(search: Callable[[str], InputResponse]) -> Div:
    query, set_query = use_state(Search())
    row_handle, _ = use_state(ElementHandle)
    field = use_text_input(query.text, lambda edit: set_query(lambda s: s.edited(edit(s.text))).text, auto_focus=True)
    shown = substring_matches(query.text, QUERIES) if query.text else ()

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        now = get_state(set_query)
        now_shown = substring_matches(now.text, QUERIES) if now.text else ()
        match event:
            case KeyPressed(key=Key.Down | Key.Up as key) if now_shown:
                by = 1 if key == Key.Down else -1
                highlight = (
                    Highlight(index=0 if by == 1 else len(now_shown) - 1)
                    if now.highlight is None
                    else now.highlight.moved(now_shown, by, ends="release")
                )
                set_query(replace(now, highlight=highlight))
                return ScrollIntoView(row_handle, block="nearest")
            case KeyPressed(key=Key.Enter | Key.ControlJ):
                index = None if now.highlight is None else now.highlight.index_in(now_shown)
                return search(now.text if index is None else now_shown[index])
            case _:
                if (controls := response_controls(field.on_input(event, receiver))) is None:
                    return None
                return (*controls, ScrollIntoView(row_handle, block="nearest"))

    highlighted = None if query.highlight is None else query.highlight.index_in(shown)
    return Div(
        style=col | width(24),
        on_input=on_input,
        children=(field_row(field), option_rows(shown, highlighted, row_handle, search)),
    )

The searches containing "python", the first highlighted

 Searched: nothing yet        
 python                       
 python asyncio               
 python dataclasses           
 python pathlib               

Tab cycling

As bash completes: no list is drawn, and Tab writes the next match into the text. The matches come from the text as it was when cycling started, which Cycling keeps, and any edit ends the cycle, since the line's edit drops it:

@dataclass(frozen=True, slots=True)
class Cycling:
    stem: str
    highlight: Highlight


@dataclass(frozen=True, slots=True)
class Line:
    text: str = ""
    cycling: Cycling | None = None

    def edited(self, text: str) -> Line:
        return self if text == self.text else Line(text=text)


@component
def command_line() -> Div:
    line, set_line = use_state(Line())
    field = use_text_input(
        line.text, lambda edit: set_line(lambda now: now.edited(edit(now.text))).text, auto_focus=True
    )

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        match event:
            case KeyPressed(key=Key.Tab | Key.BackTab as key):
                now = get_state(set_line)
                by = 1 if key == Key.Tab else -1
                stem = now.text if now.cycling is None else now.cycling.stem
                if not (matches := prefix_matches(stem, PROGRAMS)):
                    return ()
                if now.cycling is None:
                    highlight = Highlight(index=0 if by == 1 else len(matches) - 1).landed(matches)
                else:
                    highlight = now.cycling.highlight.moved(matches, by)
                set_line(Line(text=matches[highlight.index], cycling=Cycling(stem=stem, highlight=highlight)))
                return ()
            case _:
                return field.on_input(event, receiver)

    return Div(style=row | pad_x(1), on_input=on_input, children=(Text(content="$ "), field_row(field)))

The command line holding "cat", the second program starting with "c"

 $ cat                        

Inline completion

As fish suggests: the rest of the first line from history that starts with the text is drawn dimmed after it, and Right at the end of the text accepts it. There's no highlight to keep, since the suggestion is derived from the text. It matches with startswith rather than prefix_matches, so the suggestion keeps the case history has, and it reads whether the cursor is at the end with latest_cursor(), since a Left typed just before the Right moved it after the last render:

def suggestion(typed: str) -> str | None:
    """The first line of history that starts with `typed` and goes on past it, if `typed` isn't empty."""
    return next((line for line in HISTORY if line.startswith(typed) and line != typed), None) if typed else None


@component
def shell_prompt() -> Div:
    text, set_text = use_state("")
    field = use_text_input(text, set_text, auto_focus=True)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        match event:
            case KeyPressed(key=Key.Right) if (
                field.latest_cursor() == len(now := get_state(set_text)) and (line := suggestion(now)) is not None
            ):
                set_text(line)
                return ()
            case _:
                return field.on_input(event, receiver)

    ghost = line[len(text) :] if (line := suggestion(text)) is not None else ""
    return Div(style=row | pad_x(1), on_input=on_input, children=(Text(content="$ "), field_row(field, ghost=ghost)))

The prompt holding "git s", with "tatus" dimmed after it

 $ git status                           

Rows that refresh, with keys of their own

As a port or process manager: the rows come from the parent and change between renders, so the highlight follows its row by pid_of, the keys are the command line's, and Ctrl+X acts on the highlighted row without closing. An edit lands the highlight on the first match, so it follows that row from the first keystroke, which the query's own edited can't do, since landing needs the rows, so the picker writes its edit out:

@dataclass(frozen=True, slots=True)
class Process:
    pid: int
    command: str


def pid_of(process: Process) -> int:
    return process.pid


@component
def process_list(processes: tuple[Process, ...], stop: Callable[[Process], InputResponse]) -> Div:
    query, set_query = use_state(Query())
    row_handle, _ = use_state(ElementHandle)

    def matching(typed: str) -> tuple[Process, ...]:
        return substring_matches(typed, processes, key=lambda process: f"{process.pid} {process.command}")

    def edited(edit: Callable[[str], str]) -> Callable[[Query], Query]:
        def updated(query: Query) -> Query:
            if (text := edit(query.text)) == query.text:
                return query
            return Query(text=text, highlight=Highlight().landed(matching(text), pid_of))

        return updated

    field = use_text_input(query.text, lambda edit: set_query(edited(edit)).text, auto_focus=True)
    shown = matching(query.text)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        now = get_state(set_query)
        now_shown = matching(now.text)
        index = now.highlight.index_in(now_shown, pid_of)
        match event:
            case KeyPressed(key=Key.Down | Key.Tab | Key.ControlN):
                set_query(replace(now, highlight=now.highlight.moved(now_shown, 1, pid_of)))
                return ScrollIntoView(row_handle, block="nearest")
            case KeyPressed(key=Key.Up | Key.BackTab | Key.ControlP | Key.ControlK):
                set_query(replace(now, highlight=now.highlight.moved(now_shown, -1, pid_of)))
                return ScrollIntoView(row_handle, block="nearest")
            case KeyPressed(key=Key.ControlX) if index is not None:
                return stop(now_shown[index])
            case _:
                if (controls := response_controls(field.on_input(event, receiver))) is None:
                    return None
                return (*controls, ScrollIntoView(row_handle, block="nearest"))

    return Div(
        style=col | width(30),
        on_input=on_input,
        children=(
            field_row(field),
            option_rows(
                shown,
                query.highlight.index_in(shown, pid_of),
                row_handle,
                stop,
                label=lambda process: f"{process.pid:>5} {process.command}",
            ),
        ),
    )

The processes, with nginx highlighted after uvicorn appeared above it

 Ctrl+X stops a process         

  3301 uvicorn                  
  2048 postgres                 
  1203 nginx                    

Marking several options

The marks are kept beside the query, so text that hides a marked option leaves it marked, and the header counts every mark. Space toggles the highlighted option's mark, which works because branch names can't contain a space; a picker whose options can picks another key. A worktree with changes can't be marked, and Enter acts on the marks, doing nothing when there are none:

@dataclass(frozen=True, slots=True)
class Worktree:
    branch: str
    is_dirty: bool = False


WORKTREES = (
    Worktree(branch="feature/pickers"),
    Worktree(branch="fix/cursor", is_dirty=True),
    Worktree(branch="fix/resize"),
    Worktree(branch="docs/inline"),
)


def branch_of(worktree: Worktree) -> str:
    return worktree.branch


@component
def cleanup(remove: Callable[[frozenset[str]], InputResponse]) -> Div:
    query, set_query = use_state(Query())
    marked, set_marked = use_state(frozenset[str]())
    row_handle, _ = use_state(ElementHandle)
    field = use_text_input(query.text, lambda edit: set_query(lambda q: q.edited(edit(q.text))).text, auto_focus=True)
    shown = subsequence_matches(query.text, WORKTREES, key=branch_of)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        now = get_state(set_query)
        now_shown = subsequence_matches(now.text, WORKTREES, key=branch_of)
        index = now.highlight.index_in(now_shown, branch_of)
        match event:
            case KeyPressed(key=Key.Down | Key.Up as key):
                set_query(
                    replace(now, highlight=now.highlight.moved(now_shown, 1 if key == Key.Down else -1, branch_of))
                )
                return ScrollIntoView(row_handle, block="nearest")
            case KeyPressed(key=" ") if index is not None:
                if not (worktree := now_shown[index]).is_dirty:
                    set_marked(lambda marked: marked ^ {worktree.branch})
                return ()
            case KeyPressed(key=Key.Enter | Key.ControlJ):
                return remove(marks) if (marks := get_state(set_marked)) else ()
            case _:
                if (controls := response_controls(field.on_input(event, receiver))) is None:
                    return None
                return (*controls, ScrollIntoView(row_handle, block="nearest"))

    def label(worktree: Worktree) -> str:
        return (
            f"{'x' if worktree.branch in marked else ' '} {worktree.branch}{' (changes)' if worktree.is_dirty else ''}"
        )

    return Div(
        style=col | width(30),
        on_input=on_input,
        children=(
            Text(content=f"{len(marked)} marked"),
            field_row(field),
            option_rows(shown, query.highlight.index_in(shown, branch_of), row_handle, lambda _: (), label=label),
        ),
    )

Two of four worktrees marked

 2 marked                       

 x feature/pickers              
   fix/cursor (changes)         
 x fix/resize                   
   docs/inline                  
 Removed: nothing yet           

Marks are a recipe rather than part of the library, since the key that marks collides with typing in a way each picker settles differently.

Headings the highlight skips

The rows include a heading over each section, which is_disabled marks, so Down and Up pass over them, and the highlight resolves past one, as Downshift's isItemDisabled and React Aria's disabledKeys do. A heading is a row like any other, so its section's options come and go under it as the text narrows them:

@dataclass(frozen=True, slots=True)
class Row:
    name: str
    is_heading: bool = False


def is_heading(row: Row) -> bool:
    return row.is_heading


SECTIONS = {"CPython": ("3.13", "3.14", "3.15", "3.15t"), "PyPy": ("pypy3.10", "pypy3.11")}


def rows_for(text: str) -> tuple[Row, ...]:
    """The versions that contain `text`, each section's under its heading, leaving out sections with none."""
    return tuple(
        row
        for heading, versions in SECTIONS.items()
        if (matched := substring_matches(text, versions))
        for row in (Row(name=heading, is_heading=True), *(Row(name=version) for version in matched))
    )


@component
def python_picker(choose: Callable[[str], InputResponse]) -> Div:
    query, set_query = use_state(Query())
    row_handle, _ = use_state(ElementHandle)
    field = use_text_input(query.text, lambda edit: set_query(lambda q: q.edited(edit(q.text))).text, auto_focus=True)
    shown = rows_for(query.text)
    highlighted = query.highlight.index_in(shown, is_disabled=is_heading)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        now = get_state(set_query)
        now_shown = rows_for(now.text)
        match event:
            case KeyPressed(key=Key.Down | Key.Up as key):
                by = 1 if key == Key.Down else -1
                set_query(replace(now, highlight=now.highlight.moved(now_shown, by, is_disabled=is_heading)))
                return ScrollIntoView(row_handle, block="nearest")
            case KeyPressed(key=Key.Enter | Key.ControlJ) if (
                index := now.highlight.index_in(now_shown, is_disabled=is_heading)
            ) is not None:
                return choose(now_shown[index].name)
            case _:
                if (controls := response_controls(field.on_input(event, receiver))) is None:
                    return None
                return (*controls, ScrollIntoView(row_handle, block="nearest"))

    return Div(
        style=col | width(20),
        on_input=on_input,
        children=(
            field_row(field),
            *(
                Text(
                    content=row.name if row.is_heading else f"  {row.name}",
                    style=text_wrap_none
                    | (text_bold | text_color("amber", 300) if row.is_heading else Style())
                    | (text_bg("sky", 800) if index == highlighted else Style()),
                    handle=row_handle if index == highlighted else None,
                )
                for index, row in enumerate(shown)
            ),
        ),
    )

The Python versions under their headings, pypy3.10 highlighted after Down four times

 Python 3.15          

 CPython              
   3.13               
   3.14               
   3.15               
   3.15t              
 PyPy                 
   pypy3.10           
   pypy3.11           

A picker whose headings are of another type than its options can draw them between the options and highlight only the options instead, which keeps the type of the option it chooses.

A preview of the highlighted option

The parent owns the text and the highlight, and passes them down with their setter, so it can preview the highlighted option beside the list, and agrees with the list about which option that is, since both ask index_in:

COLORS: tuple[ColorName, ...] = ("red", "orange", "amber", "lime", "green", "teal", "sky", "indigo", "violet", "pink")


@component
def color_list(query: Query, set_query: Setter[Query]) -> Div:
    row_handle, _ = use_state(ElementHandle)
    field = use_text_input(query.text, lambda edit: set_query(lambda q: q.edited(edit(q.text))).text, auto_focus=True)
    shown = substring_matches(query.text, COLORS)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        match event:
            case KeyPressed(key=Key.Down | Key.Up as key):
                by = 1 if key == Key.Down else -1
                set_query(lambda q: replace(q, highlight=q.highlight.moved(substring_matches(q.text, COLORS), by)))
                return ScrollIntoView(row_handle, block="nearest")
            case _:
                if (controls := response_controls(field.on_input(event, receiver))) is None:
                    return None
                return (*controls, ScrollIntoView(row_handle, block="nearest"))

    def highlight(color: ColorName) -> InputResponse:
        set_query(lambda q: replace(q, highlight=Highlight(key=color)))
        return ()

    return Div(
        style=col | width(14),
        on_input=on_input,
        children=(field_row(field), option_rows(shown, query.highlight.index_in(shown), row_handle, highlight)),
    )


@component
def swatches() -> Div:
    query, set_query = use_state(Query())
    shown = substring_matches(query.text, COLORS)
    index = query.highlight.index_in(shown)
    return Div(
        style=row | gap(2) | pad_x(1),
        children=(
            color_list(query, set_query),
            Div(
                style=size(12, 5)
                | (content_color(shown[index], 500) if index is not None else border | border_lightrounded),
                children=(Text(content="nothing" if index is None else shown[index], style=pad_x(1)),),
            ),
        ),
    )

A list of colors beside a swatch of the highlighted one, amber

                  amber       
 red                          
 orange                       
 amber                        
 lime                         

The combobox widget

combobox is the combobox with free text, drawn as a text_input whose list opens in a box over the content below it, or above it near the bottom of the screen, clipped only by the screen:

@component
def dependency_form() -> Div:
    package, set_package = use_state("")
    group, set_group = use_state("")
    return Div(
        style=col | pad_x(1),
        on_input=tab_focus,
        children=(
            combobox(package, set_package, PACKAGES, placeholder="Package", style=width(24), auto_focus=True),
            text_input(group, set_group, placeholder="Group", style=width(24)),
        ),
    )

The package field's suggestions over the group field below it

 ╭──────────────────────╮     
 │ py                   │     
 ╰──────────────────────╯     
 ╭──────────────────────╮     
 │ mypy                 │     
 │ pydantic             │     
 │ pydantic-settings    │     
 │ pytest               │     
 │ pytest-asyncio       │     
 ╰──────────────────────╯     

Its keys are the WAI-ARIA combobox pattern's, but for Tab, which chooses, as in editors' completion lists. opens_on says whether an edit opens the list or focus does, as React Aria's menuTrigger does, ends what the ends do, defaulting to stopping there, as the pattern and fzf do, and opens which side it opens on, from Rects.screen. For other keys, build a picker of your own from the parts, as the examples above do.

API

counterweight.headless.Highlight dataclass

Highlight(key: object = None, index: int = 0)

Which of a list of options is highlighted: the one whose key is key, or, when none is, the one at index, clamped to the first and the last. The key keeps the highlight on its option while options before it come and go, as React Aria's focusedKey does, and the index is where it lands when its own option goes. Highlight() highlights the first option, Highlight(index=len(options) - 1) the last, and Highlight(key=current) the current one, wherever it is. A key of None is no key, so options MUST NOT have None as their key.

Each method takes the options, key, which is each option's key and defaults to the option itself, and is_disabled, which marks the options the highlight skips, such as headings, as Downshift's isItemDisabled does; with no is_disabled, every option is enabled.

key class-attribute instance-attribute

key: object = None

index class-attribute instance-attribute

index: int = 0

index_in

index_in(
    options: Sequence[T],
    key: Callable[[T], object] = identity,
    is_disabled: Callable[[T], bool] | None = None,
) -> int | None

The highlighted option's index in options, or None if there are no enabled options to highlight. A key or an index on a disabled option resolves to the next enabled option after it, or, when none follows, the last enabled one before it, so Highlight() is the first enabled option and Highlight(index=len(options) - 1) the last.

landed

landed(
    options: Sequence[T],
    key: Callable[[T], object] = identity,
    is_disabled: Callable[[T], bool] | None = None,
) -> Highlight

The highlight on the option it's on in options, by that option's key and index, so it follows that option from now on, rather than the one it held before, if that comes back.

moved

moved(
    options: Sequence[T],
    by: int,
    key: Callable[[T], object] = ...,
    is_disabled: Callable[[T], bool] | None = ...,
    *,
    ends: Literal["wrap", "stop"] = "wrap",
) -> Highlight
moved(
    options: Sequence[T],
    by: int,
    key: Callable[[T], object] = ...,
    is_disabled: Callable[[T], bool] | None = ...,
    *,
    ends: Literal["wrap", "stop", "release"],
) -> Highlight | None
moved(
    options: Sequence[T],
    by: int,
    key: Callable[[T], object] = identity,
    is_disabled: Callable[[T], bool] | None = None,
    *,
    ends: Literal["wrap", "stop", "release"] = "wrap",
) -> Highlight | None

The highlight by enabled options on in options, back for a negative by. At either end, ends="wrap" carries on from the other end; "stop" stays on the first or the last enabled option, so by=len(options) is End; and "release" is None for a move past either end, no highlight, as the combobox pattern's Down on the last option "returns focus to the combobox". With no enabled option to highlight, it's the highlight unchanged.

counterweight.headless.subsequence_matches

subsequence_matches(
    text: str, options: Iterable[S]
) -> tuple[S, ...]
subsequence_matches(
    text: str,
    options: Iterable[T],
    key: Callable[[T], str],
) -> tuple[T, ...]
subsequence_matches(
    text: str,
    options: Iterable[T],
    key: Callable[[T], str] = str,
) -> tuple[T, ...]

The options whose key holds the letters of text in order, with anything between them, ignoring case, as fzf matches, though without ranking them, in their own order but for the one text names exactly, which comes first, so typing a whole name and choosing the first match chooses it. Every option matches an empty text.

counterweight.headless.substring_matches

substring_matches(
    text: str, options: Iterable[S]
) -> tuple[S, ...]
substring_matches(
    text: str,
    options: Iterable[T],
    key: Callable[[T], str],
) -> tuple[T, ...]
substring_matches(
    text: str,
    options: Iterable[T],
    key: Callable[[T], str] = str,
) -> tuple[T, ...]

The options whose key contains text, ignoring case, in their own order but for the one text names exactly, which comes first. Every option matches an empty text.

counterweight.headless.prefix_matches

prefix_matches(
    text: str, options: Iterable[S]
) -> tuple[S, ...]
prefix_matches(
    text: str,
    options: Iterable[T],
    key: Callable[[T], str],
) -> tuple[T, ...]
prefix_matches(
    text: str,
    options: Iterable[T],
    key: Callable[[T], str] = str,
) -> tuple[T, ...]

The options whose key starts with text, ignoring case, as Tab completion matches, in their own order but for the one text names exactly, which comes first. Every option matches an empty text.

counterweight.widgets.combobox

combobox(
    value: str,
    set_value: Editor[str],
    options: Sequence[str],
    placeholder: str = "",
    opens: Literal["auto", "below", "above"] = "auto",
    opens_on: Literal["input", "focus"] = "input",
    ends: Literal["wrap", "stop", "release"] = "stop",
    shown_rows: int = 5,
    style: Style = default,
    styles: ComboboxStyles = ComboboxStyles(),
    auto_focus: bool = False,
    handle: FocusHandle | None = None,
    is_focusable: bool = True,
    in_focus_order: bool = True,
) -> Div

A text_input whose text suggests options in a list over the content below it, or above it, as the WAI-ARIA combobox pattern does. The list holds the options subsequence_matches keeps for the text, every option for an empty text, at most shown_rows of them in view, scrolling the rest, and is clipped only by the screen. It takes value, set_value, placeholder and the focus parameters as text_input does, and keeps which option is highlighted and whether the list is open itself. Choosing an option sets the text to it and closes the list.

Key Open, with an option highlighted Open, nothing highlighted Closed
Down, Up Highlight the next or previous option, at the ends as ends says Highlight the first or the last Open, highlighting the first or the last
Enter, Ctrl+J, Tab Choose it Bubble Bubble
Escape Close Close Bubble
Anything else The text field's The text field's The text field's

Tab chooses, as in editors' completion lists, since a field that completes as you type has nothing better for it to do while its list is open. An open list that nothing matches says so, and keeps Down and Up, but Enter, Ctrl+J and Tab bubble, as they do with nothing highlighted: the value is free text, so with nothing to choose they do what they would in a field with no list, such as submitting a form or moving focus, as they do from a web page's <input>. Ctrl+J chooses as Enter does, since a terminal still in cooked mode turns an Enter typed ahead into a line feed. Each key decides from the text and the highlight as every key before it left them, even before a render. Hovering a row highlights it, and clicking it chooses it, keeping focus in the field. For other keys, build a picker of your own from Highlight and the matchers.

PARAMETER DESCRIPTION
options

What the text suggests, in the order the list shows them, but for the one the text names exactly, which comes first.

TYPE: Sequence[str]

opens

Where the list opens: "below" or "above" the field, or "auto", below while it fits between the field and the bottom of the screen (Rects.screen), and otherwise on the side with more room, which it decides from the last layout before the frame is painted, as the web's position-try-fallbacks: flip-block does in layout.

TYPE: Literal['auto', 'below', 'above'] DEFAULT: 'auto'

opens_on

When the list opens, as React Aria's menuTrigger says: "input" on an edit, and "focus" whenever the field is focused, until Escape or choosing closes it, opening again when the field is focused again, with nothing highlighted until Down, Up or an edit, so Tab and Enter move on as they would without it. In either, Down or Up opens it.

TYPE: Literal['input', 'focus'] DEFAULT: 'input'

ends

What Down on the last option and Up on the first do, as for Highlight.moved: "stop" stays, as the combobox pattern and fzf do, "wrap" carries on from the other end, and "release" highlights nothing, so Enter bubbles, until Down or Up highlights an end again.

TYPE: Literal['wrap', 'stop', 'release'] DEFAULT: 'stop'

shown_rows

How many options the list shows before it scrolls.

TYPE: int DEFAULT: 5

style

Merged over the field's box's style from styles, as for a size (width(24)).

TYPE: Style DEFAULT: default

styles

How the field and the list are drawn.

TYPE: ComboboxStyles DEFAULT: ComboboxStyles()

counterweight.widgets.ComboboxStyles dataclass

ComboboxStyles(
    *,
    field: TextFieldStyles = TextFieldStyles(),
    list_box: Style = border
    | border_lightrounded
    | border_color("slate", 600)
    | content_color("slate", 900)
    | padding_color("slate", 900),
    option: Style = pad_x(1) | text_bg("slate", 900),
    highlighted_option: Style = text_bg("indigo", 800),
    no_matches: Style = text_color("slate", 500),
)

How combobox draws its field and its list. Pass the fields you want to change: ComboboxStyles(highlighted_option=text_bg("teal", 800)).

field class-attribute instance-attribute

How the field's box, its cursor and its placeholder are drawn, as for text_input.

list_box class-attribute instance-attribute

list_box: Style = (
    border
    | border_lightrounded
    | border_color("slate", 600)
    | content_color("slate", 900)
    | padding_color("slate", 900)
)

The box around the list, which draws over the content beside the field, so its colors fill it.

option class-attribute instance-attribute

option: Style = pad_x(1) | text_bg('slate', 900)

Each option's row.

highlighted_option class-attribute instance-attribute

highlighted_option: Style = text_bg('indigo', 800)

Merged over option for the highlighted option's row.

no_matches class-attribute instance-attribute

no_matches: Style = text_color('slate', 500)

Merged over option for the row that says nothing matches the text.