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 ()),
),
)
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),
),
)
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)),
)
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)))
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)))
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}",
),
),
)
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),
),
)
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)
),
),
)
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)),),
),
),
)
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)),
),
)
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
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.
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] = 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[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[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
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. |
opens
|
Where the list opens:
TYPE:
|
opens_on
|
When the list opens, as React Aria's
TYPE:
|
ends
|
What Down on the last option and Up on the first do, as for
TYPE:
|
shown_rows
|
How many options the list shows before it scrolls.
TYPE:
|
style
|
Merged over the field's box's style from
TYPE:
|
styles
|
How the field and the list are drawn.
TYPE:
|
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
field: TextFieldStyles = TextFieldStyles()
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.