-
Notifications
You must be signed in to change notification settings - Fork 92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add search functionality to select and checkbox prompt, based on #42 #374
Changes from all commits
2e7f4cc
a4f298e
20676bc
2f51bb5
632fa74
5f87d54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import questionary | ||
from examples import custom_style_dope | ||
|
||
zoo_animals = [ | ||
"Lion", | ||
"Tiger", | ||
"Elephant", | ||
"Giraffe", | ||
"Zebra", | ||
"Panda", | ||
"Kangaroo", | ||
"Gorilla", | ||
"Chimpanzee", | ||
"Orangutan", | ||
"Hippopotamus", | ||
"Rhinoceros", | ||
"Leopard", | ||
"Cheetah", | ||
"Polar Bear", | ||
"Grizzly Bear", | ||
"Penguin", | ||
"Flamingo", | ||
"Peacock", | ||
"Ostrich", | ||
"Emu", | ||
"Koala", | ||
"Sloth", | ||
"Armadillo", | ||
"Meerkat", | ||
"Lemur", | ||
"Red Panda", | ||
"Wolf", | ||
"Fox", | ||
"Otter", | ||
"Sea Lion", | ||
"Walrus", | ||
"Seal", | ||
"Crocodile", | ||
"Alligator", | ||
"Python", | ||
"Boa Constrictor", | ||
"Iguana", | ||
"Komodo Dragon", | ||
"Tortoise", | ||
"Turtle", | ||
"Parrot", | ||
"Toucan", | ||
"Macaw", | ||
"Hyena", | ||
"Jaguar", | ||
"Anteater", | ||
"Capybara", | ||
"Bison", | ||
"Moose", | ||
] | ||
|
||
|
||
if __name__ == "__main__": | ||
toppings = ( | ||
questionary.checkbox( | ||
"Select animals for your zoo", | ||
choices=zoo_animals, | ||
validate=lambda a: ( | ||
True if len(a) > 0 else "You must select at least one zoo animal" | ||
), | ||
style=custom_style_dope, | ||
use_jk_keys=False, | ||
use_search_filter=True, | ||
).ask() | ||
or [] | ||
) | ||
|
||
print( | ||
f"Alright let's create our zoo with following animals: {', '.join(toppings)}." | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
# -*- coding: utf-8 -*- | ||
"""Example for a select question type with search enabled. | ||
|
||
Run example by typing `python -m examples.select_search` in your console.""" | ||
from pprint import pprint | ||
|
||
import questionary | ||
from examples import custom_style_dope | ||
from questionary import Choice | ||
from questionary import Separator | ||
from questionary import prompt | ||
|
||
|
||
def ask_pystyle(**kwargs): | ||
# create the question object | ||
question = questionary.select( | ||
"What do you want to do?", | ||
qmark="😃", | ||
choices=[ | ||
"Order a pizza", | ||
"Make a reservation", | ||
"Cancel a reservation", | ||
"Modify your order", | ||
Separator(), | ||
"Ask for opening hours", | ||
Choice("Contact support", disabled="Unavailable at this time"), | ||
"Talk to the receptionist", | ||
], | ||
style=custom_style_dope, | ||
use_jk_keys=False, | ||
use_search_filter=True, | ||
**kwargs, | ||
) | ||
|
||
# prompt the user for an answer | ||
return question.ask() | ||
|
||
|
||
def ask_dictstyle(**kwargs): | ||
questions = [ | ||
{ | ||
"type": "select", | ||
"name": "theme", | ||
"message": "What do you want to do?", | ||
"choices": [ | ||
"Order a pizza", | ||
"Make a reservation", | ||
"Cancel a reservation", | ||
"Modify your order", | ||
Separator(), | ||
"Ask for opening hours", | ||
{"name": "Contact support", "disabled": "Unavailable at this time"}, | ||
"Talk to the receptionist", | ||
], | ||
} | ||
] | ||
|
||
return prompt(questions, style=custom_style_dope, **kwargs) | ||
|
||
|
||
if __name__ == "__main__": | ||
pprint(ask_pystyle()) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,11 +12,13 @@ | |
from prompt_toolkit.filters import Always | ||
from prompt_toolkit.filters import Condition | ||
from prompt_toolkit.filters import IsDone | ||
from prompt_toolkit.keys import Keys | ||
from prompt_toolkit.layout import ConditionalContainer | ||
from prompt_toolkit.layout import FormattedTextControl | ||
from prompt_toolkit.layout import HSplit | ||
from prompt_toolkit.layout import Layout | ||
from prompt_toolkit.layout import Window | ||
from prompt_toolkit.layout.dimension import LayoutDimension | ||
from prompt_toolkit.styles import Style | ||
from prompt_toolkit.validation import ValidationError | ||
from prompt_toolkit.validation import Validator | ||
|
@@ -204,6 +206,7 @@ class InquirerControl(FormattedTextControl): | |
choices: List[Choice] | ||
default: Optional[Union[str, Choice, Dict[str, Any]]] | ||
selected_options: List[Any] | ||
search_filter: Union[str, None] = None | ||
use_indicator: bool | ||
use_shortcuts: bool | ||
use_arrow_keys: bool | ||
|
@@ -275,6 +278,7 @@ def __init__( | |
self.submission_attempted = False | ||
self.error_message = None | ||
self.selected_options = [] | ||
self.found_in_search = False | ||
|
||
self._init_choices(choices, pointed_at) | ||
self._assign_shortcut_keys() | ||
|
@@ -343,9 +347,19 @@ def _init_choices( | |
|
||
self.choices.append(choice) | ||
|
||
@property | ||
def filtered_choices(self): | ||
if not self.search_filter: | ||
return self.choices | ||
filtered = [ | ||
c for c in self.choices if self.search_filter.lower() in c.title.lower() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm ok with this search mechanism, but it is not a prefix search like #42. Does this matter? Are there any downsides to searching for any occurrence of the substring? I suppose in the future, we can make the filter customisable, so it doesn't matter too much... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After using the prefix search for a few days it felt unnatural and not intuitive. I personally don't know any application that prefers a prefix search above a substring search. A fuzzy search would probably be the best, but add unnecessary complexity and/or dependencies to this library. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for explaining |
||
] | ||
self.found_in_search = len(filtered) > 0 | ||
return filtered if self.found_in_search else self.choices | ||
|
||
@property | ||
def choice_count(self) -> int: | ||
return len(self.choices) | ||
return len(self.filtered_choices) | ||
|
||
def _get_choice_tokens(self): | ||
tokens = [] | ||
|
@@ -425,7 +439,7 @@ def append(index: int, choice: Choice): | |
tokens.append(("", "\n")) | ||
|
||
# prepare the select choices | ||
for i, c in enumerate(self.choices): | ||
for i, c in enumerate(self.filtered_choices): | ||
append(i, c) | ||
|
||
current = self.get_pointed_at() | ||
|
@@ -467,7 +481,7 @@ def select_next(self) -> None: | |
self.pointed_at = (self.pointed_at + 1) % self.choice_count | ||
|
||
def get_pointed_at(self) -> Choice: | ||
return self.choices[self.pointed_at] | ||
return self.filtered_choices[self.pointed_at] | ||
|
||
def get_selected_values(self) -> List[Choice]: | ||
# get values not labels | ||
|
@@ -477,6 +491,39 @@ def get_selected_values(self) -> List[Choice]: | |
if (not isinstance(c, Separator) and c.value in self.selected_options) | ||
] | ||
|
||
def add_search_character(self, char: Keys) -> None: | ||
"""Adds a character to the search filter""" | ||
if char == Keys.Backspace: | ||
self.remove_search_character() | ||
else: | ||
if self.search_filter is None: | ||
self.search_filter = str(char) | ||
else: | ||
self.search_filter += str(char) | ||
|
||
# Make sure that the selection is in the bounds of the filtered list | ||
self.pointed_at = 0 | ||
|
||
def remove_search_character(self) -> None: | ||
if self.search_filter and len(self.search_filter) > 1: | ||
self.search_filter = self.search_filter[:-1] | ||
else: | ||
self.search_filter = None | ||
|
||
def get_search_string_tokens(self): | ||
if self.search_filter is None: | ||
return None | ||
|
||
return [ | ||
("", "\n"), | ||
("class:question-mark", "/ "), | ||
( | ||
"class:search_success" if self.found_in_search else "class:search_none", | ||
self.search_filter, | ||
), | ||
("class:question-mark", "..."), | ||
] | ||
|
||
|
||
def build_validator(validate: Any) -> Optional[Validator]: | ||
if validate: | ||
|
@@ -531,6 +578,10 @@ def create_inquirer_layout( | |
) | ||
_fix_unecessary_blank_lines(ps) | ||
|
||
@Condition | ||
def has_search_string(): | ||
return ic.get_search_string_tokens() is not None | ||
|
||
validation_prompt: PromptSession = PromptSession( | ||
bottom_toolbar=lambda: ic.error_message, **kwargs | ||
) | ||
|
@@ -540,6 +591,13 @@ def create_inquirer_layout( | |
[ | ||
ps.layout.container, | ||
ConditionalContainer(Window(ic), filter=~IsDone()), | ||
ConditionalContainer( | ||
Window( | ||
height=LayoutDimension.exact(2), | ||
content=FormattedTextControl(ic.get_search_string_tokens), | ||
), | ||
filter=has_search_string & ~IsDone(), | ||
), | ||
ConditionalContainer( | ||
validation_prompt.layout.container, | ||
filter=Condition(lambda: ic.error_message is not None), | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we happy to say that space can't be used in the search?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have to exclude it, since space is used to select an entry from the list. Otherwise we would have to come up with a rather complex solution to allow (de-)selection of an entry when the search is enabled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense