Skip to content

Commit 5889c48

Browse files
authored
Merge pull request #5403 from Textualize/select-experiment
`Select.type_to_search`
2 parents 86e9353 + 8301c1c commit 5889c48

File tree

5 files changed

+283
-6
lines changed

5 files changed

+283
-6
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10-
1110
### Fixed
1211

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

1514
### Added
1615

1716
- 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
17+
- - 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
1818

1919
### Changed
2020

docs/widgets/select.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,15 @@ The following example presents a `Select` created using the `from_values` class
8888

8989
## Blank state
9090

91-
The widget `Select` has an option `allow_blank` for its constructor.
91+
The `Select` widget has an option `allow_blank` for its constructor.
9292
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].
9393
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.
9494

95+
## Type to search
9596

96-
## Reactive Attributes
97+
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`.
9798

99+
## Reactive Attributes
98100

99101
| Name | Type | Default | Description |
100102
|------------|--------------------------------|------------------------------------------------|-------------------------------------|

src/textual/widgets/_select.py

+91-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from textual.css.query import NoMatches
1414
from textual.message import Message
1515
from textual.reactive import var
16+
from textual.timer import Timer
1617
from textual.widgets import Static
1718
from textual.widgets._option_list import Option, OptionList
1819

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

63+
def __init__(self, type_to_search: bool = True) -> None:
64+
super().__init__()
65+
self._type_to_search = type_to_search
66+
"""If True (default), the user can type to search for a matching option and the cursor will jump to it."""
67+
68+
self._search_query: str = ""
69+
"""The current search query used to find a matching option and jump to it."""
70+
71+
self._search_reset_delay: float = 0.7
72+
"""The number of seconds to wait after the most recent key press before resetting the search query."""
73+
74+
def on_mount(self) -> None:
75+
def reset_query() -> None:
76+
self._search_query = ""
77+
78+
self._search_reset_timer = Timer(
79+
self, self._search_reset_delay, callback=reset_query
80+
)
81+
82+
def watch_has_focus(self, value: bool) -> None:
83+
self._search_query = ""
84+
if value:
85+
self._search_reset_timer._start()
86+
else:
87+
self._search_reset_timer.reset()
88+
self._search_reset_timer.stop()
89+
super().watch_has_focus(value)
90+
91+
async def _on_key(self, event: events.Key) -> None:
92+
if not self._type_to_search:
93+
return
94+
95+
self._search_reset_timer.reset()
96+
97+
if event.character is not None and event.is_printable:
98+
event.time = 0
99+
event.stop()
100+
event.prevent_default()
101+
102+
# Update the search query and jump to the next option that matches.
103+
self._search_query += event.character
104+
index = self._find_search_match(self._search_query)
105+
if index is not None:
106+
self.select(index)
107+
108+
def check_consume_key(self, key: str, character: str | None = None) -> bool:
109+
"""Check if the widget may consume the given key."""
110+
return (
111+
self._type_to_search and character is not None and character.isprintable()
112+
)
113+
62114
def select(self, index: int | None) -> None:
63115
"""Move selection.
64116
@@ -68,6 +120,38 @@ def select(self, index: int | None) -> None:
68120
self.highlighted = index
69121
self.scroll_to_highlight()
70122

123+
def _find_search_match(self, query: str) -> int | None:
124+
"""A simple substring search which favors options containing the substring
125+
earlier in the prompt.
126+
127+
Args:
128+
query: The substring to search for.
129+
130+
Returns:
131+
The index of the option that matches the query, or `None` if no match is found.
132+
"""
133+
best_match: int | None = None
134+
minimum_index: int | None = None
135+
136+
query = query.lower()
137+
for index, option in enumerate(self._options):
138+
prompt = option.prompt
139+
if isinstance(prompt, Text):
140+
lower_prompt = prompt.plain.lower()
141+
elif isinstance(prompt, str):
142+
lower_prompt = prompt.lower()
143+
else:
144+
continue
145+
146+
match_index = lower_prompt.find(query)
147+
if match_index != -1 and (
148+
minimum_index is None or match_index < minimum_index
149+
):
150+
best_match = index
151+
minimum_index = match_index
152+
153+
return best_match
154+
71155
def action_dismiss(self) -> None:
72156
"""Dismiss the overlay."""
73157
self.post_message(self.Dismiss())
@@ -295,6 +379,7 @@ def __init__(
295379
prompt: str = "Select",
296380
allow_blank: bool = True,
297381
value: SelectType | NoSelection = BLANK,
382+
type_to_search: bool = True,
298383
name: str | None = None,
299384
id: str | None = None,
300385
classes: str | None = None,
@@ -313,6 +398,7 @@ def __init__(
313398
value: Initial value selected. Should be one of the values in `options`.
314399
If no initial value is set and `allow_blank` is `False`, the widget
315400
will auto-select the first available option.
401+
type_to_search: If `True`, typing will search for options.
316402
name: The name of the select control.
317403
id: The ID of the control in the DOM.
318404
classes: The CSS classes of the control.
@@ -327,6 +413,7 @@ def __init__(
327413
self.prompt = prompt
328414
self._value = value
329415
self._setup_variables_for_options(options)
416+
self._type_to_search = type_to_search
330417
if tooltip is not None:
331418
self.tooltip = tooltip
332419

@@ -338,6 +425,7 @@ def from_values(
338425
prompt: str = "Select",
339426
allow_blank: bool = True,
340427
value: SelectType | NoSelection = BLANK,
428+
type_to_search: bool = True,
341429
name: str | None = None,
342430
id: str | None = None,
343431
classes: str | None = None,
@@ -357,6 +445,7 @@ def from_values(
357445
value: Initial value selected. Should be one of the values in `values`.
358446
If no initial value is set and `allow_blank` is `False`, the widget
359447
will auto-select the first available value.
448+
type_to_search: If `True`, typing will search for options.
360449
name: The name of the select control.
361450
id: The ID of the control in the DOM.
362451
classes: The CSS classes of the control.
@@ -372,6 +461,7 @@ def from_values(
372461
prompt=prompt,
373462
allow_blank=allow_blank,
374463
value=value,
464+
type_to_search=type_to_search,
375465
name=name,
376466
id=id,
377467
classes=classes,
@@ -496,7 +586,7 @@ def _watch_value(self, value: SelectType | NoSelection) -> None:
496586
def compose(self) -> ComposeResult:
497587
"""Compose Select with overlay and current value."""
498588
yield SelectCurrent(self.prompt)
499-
yield SelectOverlay()
589+
yield SelectOverlay(type_to_search=self._type_to_search)
500590

501591
def _on_mount(self, _event: events.Mount) -> None:
502592
"""Set initial values."""

0 commit comments

Comments
 (0)