Skip to content

Commit

Permalink
Merge pull request #5403 from Textualize/select-experiment
Browse files Browse the repository at this point in the history
`Select.type_to_search`
  • Loading branch information
willmcgugan authored Dec 18, 2024
2 parents 86e9353 + 8301c1c commit 5889c48
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 6 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased


### Fixed

- Fixed `Pilot.click` not working with `times` parameter https://github.com/Textualize/textual/pull/5398

### Added

- Added `from_app_focus` to `Focus` event to indicate if a widget is being focused because the app itself has regained focus or not https://github.com/Textualize/textual/pull/5379
- - Added `Select.type_to_search` which allows you to type to move the cursor to a matching option https://github.com/Textualize/textual/pull/5403

### Changed

Expand Down
6 changes: 4 additions & 2 deletions docs/widgets/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,15 @@ The following example presents a `Select` created using the `from_values` class

## Blank state

The widget `Select` has an option `allow_blank` for its constructor.
The `Select` widget has an option `allow_blank` for its constructor.
If set to `True`, the widget may be in a state where there is no selection, in which case its value will be the special constant [`Select.BLANK`][textual.widgets.Select.BLANK].
The auxiliary methods [`Select.is_blank`][textual.widgets.Select.is_blank] and [`Select.clear`][textual.widgets.Select.clear] provide a convenient way to check if the widget is in this state and to set this state, respectively.

## Type to search

## Reactive Attributes
The `Select` widget has a `type_to_search` attribute which allows you to type to move the cursor to a matching option when the widget is expanded. To disable this behavior, set the attribute to `False`.

## Reactive Attributes

| Name | Type | Default | Description |
|------------|--------------------------------|------------------------------------------------|-------------------------------------|
Expand Down
92 changes: 91 additions & 1 deletion src/textual/widgets/_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from textual.css.query import NoMatches
from textual.message import Message
from textual.reactive import var
from textual.timer import Timer
from textual.widgets import Static
from textual.widgets._option_list import Option, OptionList

Expand Down Expand Up @@ -59,6 +60,57 @@ class UpdateSelection(Message):
option_index: int
"""The index of the new selection."""

def __init__(self, type_to_search: bool = True) -> None:
super().__init__()
self._type_to_search = type_to_search
"""If True (default), the user can type to search for a matching option and the cursor will jump to it."""

self._search_query: str = ""
"""The current search query used to find a matching option and jump to it."""

self._search_reset_delay: float = 0.7
"""The number of seconds to wait after the most recent key press before resetting the search query."""

def on_mount(self) -> None:
def reset_query() -> None:
self._search_query = ""

self._search_reset_timer = Timer(
self, self._search_reset_delay, callback=reset_query
)

def watch_has_focus(self, value: bool) -> None:
self._search_query = ""
if value:
self._search_reset_timer._start()
else:
self._search_reset_timer.reset()
self._search_reset_timer.stop()
super().watch_has_focus(value)

async def _on_key(self, event: events.Key) -> None:
if not self._type_to_search:
return

self._search_reset_timer.reset()

if event.character is not None and event.is_printable:
event.time = 0
event.stop()
event.prevent_default()

# Update the search query and jump to the next option that matches.
self._search_query += event.character
index = self._find_search_match(self._search_query)
if index is not None:
self.select(index)

def check_consume_key(self, key: str, character: str | None = None) -> bool:
"""Check if the widget may consume the given key."""
return (
self._type_to_search and character is not None and character.isprintable()
)

def select(self, index: int | None) -> None:
"""Move selection.
Expand All @@ -68,6 +120,38 @@ def select(self, index: int | None) -> None:
self.highlighted = index
self.scroll_to_highlight()

def _find_search_match(self, query: str) -> int | None:
"""A simple substring search which favors options containing the substring
earlier in the prompt.
Args:
query: The substring to search for.
Returns:
The index of the option that matches the query, or `None` if no match is found.
"""
best_match: int | None = None
minimum_index: int | None = None

query = query.lower()
for index, option in enumerate(self._options):
prompt = option.prompt
if isinstance(prompt, Text):
lower_prompt = prompt.plain.lower()
elif isinstance(prompt, str):
lower_prompt = prompt.lower()
else:
continue

match_index = lower_prompt.find(query)
if match_index != -1 and (
minimum_index is None or match_index < minimum_index
):
best_match = index
minimum_index = match_index

return best_match

def action_dismiss(self) -> None:
"""Dismiss the overlay."""
self.post_message(self.Dismiss())
Expand Down Expand Up @@ -295,6 +379,7 @@ def __init__(
prompt: str = "Select",
allow_blank: bool = True,
value: SelectType | NoSelection = BLANK,
type_to_search: bool = True,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
Expand All @@ -313,6 +398,7 @@ def __init__(
value: Initial value selected. Should be one of the values in `options`.
If no initial value is set and `allow_blank` is `False`, the widget
will auto-select the first available option.
type_to_search: If `True`, typing will search for options.
name: The name of the select control.
id: The ID of the control in the DOM.
classes: The CSS classes of the control.
Expand All @@ -327,6 +413,7 @@ def __init__(
self.prompt = prompt
self._value = value
self._setup_variables_for_options(options)
self._type_to_search = type_to_search
if tooltip is not None:
self.tooltip = tooltip

Expand All @@ -338,6 +425,7 @@ def from_values(
prompt: str = "Select",
allow_blank: bool = True,
value: SelectType | NoSelection = BLANK,
type_to_search: bool = True,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
Expand All @@ -357,6 +445,7 @@ def from_values(
value: Initial value selected. Should be one of the values in `values`.
If no initial value is set and `allow_blank` is `False`, the widget
will auto-select the first available value.
type_to_search: If `True`, typing will search for options.
name: The name of the select control.
id: The ID of the control in the DOM.
classes: The CSS classes of the control.
Expand All @@ -372,6 +461,7 @@ def from_values(
prompt=prompt,
allow_blank=allow_blank,
value=value,
type_to_search=type_to_search,
name=name,
id=id,
classes=classes,
Expand Down Expand Up @@ -496,7 +586,7 @@ def _watch_value(self, value: SelectType | NoSelection) -> None:
def compose(self) -> ComposeResult:
"""Compose Select with overlay and current value."""
yield SelectCurrent(self.prompt)
yield SelectOverlay()
yield SelectOverlay(type_to_search=self._type_to_search)

def _on_mount(self, _event: events.Mount) -> None:
"""Set initial values."""
Expand Down
Loading

0 comments on commit 5889c48

Please sign in to comment.