Skip to content

Commit 07f5c1a

Browse files
mario-dgmario-dg
andauthored
feat: add search functionality to select and checkbox prompt, based on #42 (#374)
* feat: add search functionality to select and checkbox prompt This commit is heavily inspired by [gbataille's](https://github.com/gbataille) [PR](#42) * chore: bump minor version, because of new feature * chore: bump version in pyproject.toml * feat: changed prefix filter to search filter, allow all/invert with ctrl Instead of a prefix filter, the search filter is now searched within all entries. This seems to be more common than a prefix search. Using the search functionality disabled the ability to select all options or invert the selection in the checkbox control. If the search filter is enabled, these two functionalities can now be used with the key modifier ctrl. Updated the displayed instructions to match the changes made. * fix: reverted version bump --------- Co-authored-by: mario-dg <[email protected]>
1 parent 2aeed69 commit 07f5c1a

File tree

8 files changed

+401
-10
lines changed

8 files changed

+401
-10
lines changed

examples/checkbox_search.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import questionary
2+
from examples import custom_style_dope
3+
4+
zoo_animals = [
5+
"Lion",
6+
"Tiger",
7+
"Elephant",
8+
"Giraffe",
9+
"Zebra",
10+
"Panda",
11+
"Kangaroo",
12+
"Gorilla",
13+
"Chimpanzee",
14+
"Orangutan",
15+
"Hippopotamus",
16+
"Rhinoceros",
17+
"Leopard",
18+
"Cheetah",
19+
"Polar Bear",
20+
"Grizzly Bear",
21+
"Penguin",
22+
"Flamingo",
23+
"Peacock",
24+
"Ostrich",
25+
"Emu",
26+
"Koala",
27+
"Sloth",
28+
"Armadillo",
29+
"Meerkat",
30+
"Lemur",
31+
"Red Panda",
32+
"Wolf",
33+
"Fox",
34+
"Otter",
35+
"Sea Lion",
36+
"Walrus",
37+
"Seal",
38+
"Crocodile",
39+
"Alligator",
40+
"Python",
41+
"Boa Constrictor",
42+
"Iguana",
43+
"Komodo Dragon",
44+
"Tortoise",
45+
"Turtle",
46+
"Parrot",
47+
"Toucan",
48+
"Macaw",
49+
"Hyena",
50+
"Jaguar",
51+
"Anteater",
52+
"Capybara",
53+
"Bison",
54+
"Moose",
55+
]
56+
57+
58+
if __name__ == "__main__":
59+
toppings = (
60+
questionary.checkbox(
61+
"Select animals for your zoo",
62+
choices=zoo_animals,
63+
validate=lambda a: (
64+
True if len(a) > 0 else "You must select at least one zoo animal"
65+
),
66+
style=custom_style_dope,
67+
use_jk_keys=False,
68+
use_search_filter=True,
69+
).ask()
70+
or []
71+
)
72+
73+
print(
74+
f"Alright let's create our zoo with following animals: {', '.join(toppings)}."
75+
)

examples/select_search.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# -*- coding: utf-8 -*-
2+
"""Example for a select question type with search enabled.
3+
4+
Run example by typing `python -m examples.select_search` in your console."""
5+
from pprint import pprint
6+
7+
import questionary
8+
from examples import custom_style_dope
9+
from questionary import Choice
10+
from questionary import Separator
11+
from questionary import prompt
12+
13+
14+
def ask_pystyle(**kwargs):
15+
# create the question object
16+
question = questionary.select(
17+
"What do you want to do?",
18+
qmark="😃",
19+
choices=[
20+
"Order a pizza",
21+
"Make a reservation",
22+
"Cancel a reservation",
23+
"Modify your order",
24+
Separator(),
25+
"Ask for opening hours",
26+
Choice("Contact support", disabled="Unavailable at this time"),
27+
"Talk to the receptionist",
28+
],
29+
style=custom_style_dope,
30+
use_jk_keys=False,
31+
use_search_filter=True,
32+
**kwargs,
33+
)
34+
35+
# prompt the user for an answer
36+
return question.ask()
37+
38+
39+
def ask_dictstyle(**kwargs):
40+
questions = [
41+
{
42+
"type": "select",
43+
"name": "theme",
44+
"message": "What do you want to do?",
45+
"choices": [
46+
"Order a pizza",
47+
"Make a reservation",
48+
"Cancel a reservation",
49+
"Modify your order",
50+
Separator(),
51+
"Ask for opening hours",
52+
{"name": "Contact support", "disabled": "Unavailable at this time"},
53+
"Talk to the receptionist",
54+
],
55+
}
56+
]
57+
58+
return prompt(questions, style=custom_style_dope, **kwargs)
59+
60+
61+
if __name__ == "__main__":
62+
pprint(ask_pystyle())

questionary/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@
3939
("qmark", "fg:#5f819d"), # token in front of the question
4040
("question", "bold"), # question text
4141
("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question
42+
(
43+
"search_success",
44+
"noinherit fg:#00FF00 bold",
45+
), # submitted answer text behind the question
46+
(
47+
"search_none",
48+
"noinherit fg:#FF0000 bold",
49+
), # submitted answer text behind the question
4250
("pointer", ""), # pointer used in select and checkbox prompts
4351
("selected", ""), # style for a selected item of a checkbox
4452
("separator", ""), # separator in lists

questionary/prompts/checkbox.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import string
12
from typing import Any
23
from typing import Callable
34
from typing import Dict
@@ -37,6 +38,7 @@ def checkbox(
3738
use_arrow_keys: bool = True,
3839
use_jk_keys: bool = True,
3940
use_emacs_keys: bool = True,
41+
use_search_filter: Union[str, bool, None] = False,
4042
instruction: Optional[str] = None,
4143
show_description: bool = True,
4244
**kwargs: Any,
@@ -105,6 +107,14 @@ def checkbox(
105107
106108
use_emacs_keys: Allow the user to select items from the list using
107109
`Ctrl+N` (down) and `Ctrl+P` (up) keys.
110+
111+
use_search_filter: Flag to enable search filtering. Typing some string will
112+
filter the choices to keep only the ones that contain the
113+
search string.
114+
Note that activating this option disables "vi-like"
115+
navigation as "j" and "k" can be part of a prefix and
116+
therefore cannot be used for navigation
117+
108118
instruction: A message describing how to navigate the menu.
109119
110120
show_description: Display description of current selection if available.
@@ -119,6 +129,11 @@ def checkbox(
119129
"Emacs keys."
120130
)
121131

132+
if use_jk_keys and use_search_filter:
133+
raise ValueError(
134+
"Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix."
135+
)
136+
122137
merged_style = merge_styles_default(
123138
[
124139
# Disable the default inverted colours bottom-toolbar behaviour (for
@@ -179,8 +194,9 @@ def get_prompt_tokens() -> List[Tuple[str, str]]:
179194
"class:instruction",
180195
"(Use arrow keys to move, "
181196
"<space> to select, "
182-
"<a> to toggle, "
183-
"<i> to invert)",
197+
f"<{'ctrl-a' if use_search_filter else 'a'}> to toggle, "
198+
f"<{'ctrl-a' if use_search_filter else 'i'}> to invert"
199+
f"{', type to filter' if use_search_filter else ''})",
184200
)
185201
)
186202
return tokens
@@ -225,7 +241,7 @@ def toggle(_event):
225241

226242
perform_validation(get_selected_values())
227243

228-
@bindings.add("i", eager=True)
244+
@bindings.add(Keys.ControlI if use_search_filter else "i", eager=True)
229245
def invert(_event):
230246
inverted_selection = [
231247
c.value
@@ -238,7 +254,7 @@ def invert(_event):
238254

239255
perform_validation(get_selected_values())
240256

241-
@bindings.add("a", eager=True)
257+
@bindings.add(Keys.ControlA if use_search_filter else "a", eager=True)
242258
def all(_event):
243259
all_selected = True # all choices have been selected
244260
for c in ic.choices:
@@ -265,6 +281,17 @@ def move_cursor_up(event):
265281
while not ic.is_selection_valid():
266282
ic.select_previous()
267283

284+
if use_search_filter:
285+
286+
def search_filter(event):
287+
ic.add_search_character(event.key_sequence[0].key)
288+
289+
for character in string.printable:
290+
if character in string.whitespace:
291+
continue
292+
bindings.add(character, eager=True)(search_filter)
293+
bindings.add(Keys.Backspace, eager=True)(search_filter)
294+
268295
if use_arrow_keys:
269296
bindings.add(Keys.Down, eager=True)(move_cursor_down)
270297
bindings.add(Keys.Up, eager=True)(move_cursor_up)

questionary/prompts/common.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
from prompt_toolkit.filters import Always
1313
from prompt_toolkit.filters import Condition
1414
from prompt_toolkit.filters import IsDone
15+
from prompt_toolkit.keys import Keys
1516
from prompt_toolkit.layout import ConditionalContainer
1617
from prompt_toolkit.layout import FormattedTextControl
1718
from prompt_toolkit.layout import HSplit
1819
from prompt_toolkit.layout import Layout
1920
from prompt_toolkit.layout import Window
21+
from prompt_toolkit.layout.dimension import LayoutDimension
2022
from prompt_toolkit.styles import Style
2123
from prompt_toolkit.validation import ValidationError
2224
from prompt_toolkit.validation import Validator
@@ -236,6 +238,7 @@ class InquirerControl(FormattedTextControl):
236238
choices: List[Choice]
237239
default: Optional[Union[str, Choice, Dict[str, Any]]]
238240
selected_options: List[Any]
241+
search_filter: Union[str, None] = None
239242
use_indicator: bool
240243
use_shortcuts: bool
241244
use_arrow_keys: bool
@@ -307,6 +310,7 @@ def __init__(
307310
self.submission_attempted = False
308311
self.error_message = None
309312
self.selected_options = []
313+
self.found_in_search = False
310314

311315
self._init_choices(choices, pointed_at)
312316
self._assign_shortcut_keys()
@@ -375,9 +379,19 @@ def _init_choices(
375379

376380
self.choices.append(choice)
377381

382+
@property
383+
def filtered_choices(self):
384+
if not self.search_filter:
385+
return self.choices
386+
filtered = [
387+
c for c in self.choices if self.search_filter.lower() in c.title.lower()
388+
]
389+
self.found_in_search = len(filtered) > 0
390+
return filtered if self.found_in_search else self.choices
391+
378392
@property
379393
def choice_count(self) -> int:
380-
return len(self.choices)
394+
return len(self.filtered_choices)
381395

382396
def _get_choice_tokens(self):
383397
tokens = []
@@ -457,7 +471,7 @@ def append(index: int, choice: Choice):
457471
tokens.append(("", "\n"))
458472

459473
# prepare the select choices
460-
for i, c in enumerate(self.choices):
474+
for i, c in enumerate(self.filtered_choices):
461475
append(i, c)
462476

463477
current = self.get_pointed_at()
@@ -499,7 +513,7 @@ def select_next(self) -> None:
499513
self.pointed_at = (self.pointed_at + 1) % self.choice_count
500514

501515
def get_pointed_at(self) -> Choice:
502-
return self.choices[self.pointed_at]
516+
return self.filtered_choices[self.pointed_at]
503517

504518
def get_selected_values(self) -> List[Choice]:
505519
# get values not labels
@@ -509,6 +523,39 @@ def get_selected_values(self) -> List[Choice]:
509523
if (not isinstance(c, Separator) and c.value in self.selected_options)
510524
]
511525

526+
def add_search_character(self, char: Keys) -> None:
527+
"""Adds a character to the search filter"""
528+
if char == Keys.Backspace:
529+
self.remove_search_character()
530+
else:
531+
if self.search_filter is None:
532+
self.search_filter = str(char)
533+
else:
534+
self.search_filter += str(char)
535+
536+
# Make sure that the selection is in the bounds of the filtered list
537+
self.pointed_at = 0
538+
539+
def remove_search_character(self) -> None:
540+
if self.search_filter and len(self.search_filter) > 1:
541+
self.search_filter = self.search_filter[:-1]
542+
else:
543+
self.search_filter = None
544+
545+
def get_search_string_tokens(self):
546+
if self.search_filter is None:
547+
return None
548+
549+
return [
550+
("", "\n"),
551+
("class:question-mark", "/ "),
552+
(
553+
"class:search_success" if self.found_in_search else "class:search_none",
554+
self.search_filter,
555+
),
556+
("class:question-mark", "..."),
557+
]
558+
512559

513560
def build_validator(validate: Any) -> Optional[Validator]:
514561
if validate:
@@ -563,6 +610,10 @@ def create_inquirer_layout(
563610
)
564611
_fix_unecessary_blank_lines(ps)
565612

613+
@Condition
614+
def has_search_string():
615+
return ic.get_search_string_tokens() is not None
616+
566617
validation_prompt: PromptSession = PromptSession(
567618
bottom_toolbar=lambda: ic.error_message, **kwargs
568619
)
@@ -572,6 +623,13 @@ def create_inquirer_layout(
572623
[
573624
ps.layout.container,
574625
ConditionalContainer(Window(ic), filter=~IsDone()),
626+
ConditionalContainer(
627+
Window(
628+
height=LayoutDimension.exact(2),
629+
content=FormattedTextControl(ic.get_search_string_tokens),
630+
),
631+
filter=has_search_string & ~IsDone(),
632+
),
575633
ConditionalContainer(
576634
validation_prompt.layout.container,
577635
filter=Condition(lambda: ic.error_message is not None),

0 commit comments

Comments
 (0)