Skip to content

Commit 2705b78

Browse files
committed
Add simple "type to search" functionality to Select
1 parent 5cb6cd0 commit 2705b78

File tree

1 file changed

+63
-1
lines changed

1 file changed

+63
-1
lines changed

src/textual/widgets/_select.py

Lines changed: 63 additions & 1 deletion
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,49 @@ 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+
self._search_query: str = ""
67+
68+
def on_mount(self) -> None:
69+
def reset_query() -> None:
70+
self._search_query = ""
71+
72+
self._search_reset_timer = Timer(self, 1.0, callback=reset_query)
73+
74+
def watch_has_focus(self, value: bool) -> None:
75+
self._search_query = ""
76+
if value:
77+
self._search_reset_timer._start()
78+
else:
79+
self._search_reset_timer.reset()
80+
self._search_reset_timer.stop()
81+
super().watch_has_focus(value)
82+
83+
async def _on_key(self, event: events.Key) -> None:
84+
if not self._type_to_search:
85+
return
86+
87+
self._search_reset_timer.reset()
88+
89+
if event.character is not None and event.is_printable:
90+
event.time = 0
91+
event.stop()
92+
event.prevent_default()
93+
94+
# Update the search query and jump to the next option that matches.
95+
self._search_query += event.character
96+
index = self._find_search_match(self._search_query)
97+
if index is not None:
98+
self.select(index)
99+
100+
def check_consume_key(self, key: str, character: str | None = None) -> bool:
101+
"""Check if the widget may consume the given key."""
102+
return (
103+
self._type_to_search and character is not None and character.isprintable()
104+
)
105+
62106
def select(self, index: int | None) -> None:
63107
"""Move selection.
64108
@@ -68,6 +112,18 @@ def select(self, index: int | None) -> None:
68112
self.highlighted = index
69113
self.scroll_to_highlight()
70114

115+
def _find_search_match(self, query: str) -> int | None:
116+
"""Find the first index"""
117+
for index, option in enumerate(self._options):
118+
prompt = option.prompt
119+
if isinstance(prompt, Text):
120+
if query in prompt.plain:
121+
return index
122+
elif isinstance(prompt, str):
123+
if query in prompt:
124+
return index
125+
return None
126+
71127
def action_dismiss(self) -> None:
72128
"""Dismiss the overlay."""
73129
self.post_message(self.Dismiss())
@@ -295,6 +351,7 @@ def __init__(
295351
prompt: str = "Select",
296352
allow_blank: bool = True,
297353
value: SelectType | NoSelection = BLANK,
354+
type_to_search: bool = True,
298355
name: str | None = None,
299356
id: str | None = None,
300357
classes: str | None = None,
@@ -313,6 +370,7 @@ def __init__(
313370
value: Initial value selected. Should be one of the values in `options`.
314371
If no initial value is set and `allow_blank` is `False`, the widget
315372
will auto-select the first available option.
373+
type_to_search: If `True`, typing will search for options.
316374
name: The name of the select control.
317375
id: The ID of the control in the DOM.
318376
classes: The CSS classes of the control.
@@ -327,6 +385,7 @@ def __init__(
327385
self.prompt = prompt
328386
self._value = value
329387
self._setup_variables_for_options(options)
388+
self._type_to_search = type_to_search
330389
if tooltip is not None:
331390
self.tooltip = tooltip
332391

@@ -338,6 +397,7 @@ def from_values(
338397
prompt: str = "Select",
339398
allow_blank: bool = True,
340399
value: SelectType | NoSelection = BLANK,
400+
type_to_search: bool = True,
341401
name: str | None = None,
342402
id: str | None = None,
343403
classes: str | None = None,
@@ -357,6 +417,7 @@ def from_values(
357417
value: Initial value selected. Should be one of the values in `values`.
358418
If no initial value is set and `allow_blank` is `False`, the widget
359419
will auto-select the first available value.
420+
type_to_search: If `True`, typing will search for options.
360421
name: The name of the select control.
361422
id: The ID of the control in the DOM.
362423
classes: The CSS classes of the control.
@@ -372,6 +433,7 @@ def from_values(
372433
prompt=prompt,
373434
allow_blank=allow_blank,
374435
value=value,
436+
type_to_search=type_to_search,
375437
name=name,
376438
id=id,
377439
classes=classes,
@@ -496,7 +558,7 @@ def _watch_value(self, value: SelectType | NoSelection) -> None:
496558
def compose(self) -> ComposeResult:
497559
"""Compose Select with overlay and current value."""
498560
yield SelectCurrent(self.prompt)
499-
yield SelectOverlay()
561+
yield SelectOverlay(type_to_search=self._type_to_search)
500562

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

0 commit comments

Comments
 (0)