diff --git a/examples/checkbox_search.py b/examples/checkbox_search.py new file mode 100644 index 00000000..24cf7e3b --- /dev/null +++ b/examples/checkbox_search.py @@ -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)}." + ) diff --git a/examples/select_search.py b/examples/select_search.py new file mode 100644 index 00000000..ac453630 --- /dev/null +++ b/examples/select_search.py @@ -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()) diff --git a/questionary/constants.py b/questionary/constants.py index 94c5ff4f..845dd787 100644 --- a/questionary/constants.py +++ b/questionary/constants.py @@ -39,6 +39,14 @@ ("qmark", "fg:#5f819d"), # token in front of the question ("question", "bold"), # question text ("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question + ( + "search_success", + "noinherit fg:#00FF00 bold", + ), # submitted answer text behind the question + ( + "search_none", + "noinherit fg:#FF0000 bold", + ), # submitted answer text behind the question ("pointer", ""), # pointer used in select and checkbox prompts ("selected", ""), # style for a selected item of a checkbox ("separator", ""), # separator in lists diff --git a/questionary/prompts/checkbox.py b/questionary/prompts/checkbox.py index 25161e42..465892b1 100644 --- a/questionary/prompts/checkbox.py +++ b/questionary/prompts/checkbox.py @@ -1,3 +1,4 @@ +import string from typing import Any from typing import Callable from typing import Dict @@ -37,6 +38,7 @@ def checkbox( use_arrow_keys: bool = True, use_jk_keys: bool = True, use_emacs_keys: bool = True, + use_search_filter: Union[str, bool, None] = False, instruction: Optional[str] = None, show_description: bool = True, **kwargs: Any, @@ -105,6 +107,14 @@ def checkbox( use_emacs_keys: Allow the user to select items from the list using `Ctrl+N` (down) and `Ctrl+P` (up) keys. + + use_search_filter: Flag to enable search filtering. Typing some string will + filter the choices to keep only the ones that contain the + search string. + Note that activating this option disables "vi-like" + navigation as "j" and "k" can be part of a prefix and + therefore cannot be used for navigation + instruction: A message describing how to navigate the menu. show_description: Display description of current selection if available. @@ -119,6 +129,11 @@ def checkbox( "Emacs keys." ) + if use_jk_keys and use_search_filter: + raise ValueError( + "Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix." + ) + merged_style = merge_styles_default( [ # Disable the default inverted colours bottom-toolbar behaviour (for @@ -179,8 +194,9 @@ def get_prompt_tokens() -> List[Tuple[str, str]]: "class:instruction", "(Use arrow keys to move, " " to select, " - " to toggle, " - " to invert)", + f"<{'ctrl-a' if use_search_filter else 'a'}> to toggle, " + f"<{'ctrl-a' if use_search_filter else 'i'}> to invert" + f"{', type to filter' if use_search_filter else ''})", ) ) return tokens @@ -225,7 +241,7 @@ def toggle(_event): perform_validation(get_selected_values()) - @bindings.add("i", eager=True) + @bindings.add(Keys.ControlI if use_search_filter else "i", eager=True) def invert(_event): inverted_selection = [ c.value @@ -238,7 +254,7 @@ def invert(_event): perform_validation(get_selected_values()) - @bindings.add("a", eager=True) + @bindings.add(Keys.ControlA if use_search_filter else "a", eager=True) def all(_event): all_selected = True # all choices have been selected for c in ic.choices: @@ -265,6 +281,17 @@ def move_cursor_up(event): while not ic.is_selection_valid(): ic.select_previous() + if use_search_filter: + + def search_filter(event): + ic.add_search_character(event.key_sequence[0].key) + + for character in string.printable: + if character in string.whitespace: + continue + bindings.add(character, eager=True)(search_filter) + bindings.add(Keys.Backspace, eager=True)(search_filter) + if use_arrow_keys: bindings.add(Keys.Down, eager=True)(move_cursor_down) bindings.add(Keys.Up, eager=True)(move_cursor_up) diff --git a/questionary/prompts/common.py b/questionary/prompts/common.py index 827629db..2c738d9d 100644 --- a/questionary/prompts/common.py +++ b/questionary/prompts/common.py @@ -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() + ] + 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), diff --git a/questionary/prompts/select.py b/questionary/prompts/select.py index 41121bd0..e41dbe56 100644 --- a/questionary/prompts/select.py +++ b/questionary/prompts/select.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import string from typing import Any from typing import Dict from typing import Optional @@ -34,6 +35,7 @@ def select( use_indicator: bool = False, use_jk_keys: bool = True, use_emacs_keys: bool = True, + use_search_filter: bool = False, show_selected: bool = False, show_description: bool = True, instruction: Optional[str] = None, @@ -109,6 +111,13 @@ def select( `Ctrl+N` (down) and `Ctrl+P` (up) keys. Arrow keys, j/k keys, emacs keys and shortcuts are not mutually exclusive. + use_search_filter: Flag to enable search filtering. Typing some string will + filter the choices to keep only the ones that contain the + search string. + Note that activating this option disables "vi-like" + navigation as "j" and "k" can be part of a prefix and + therefore cannot be used for navigation + show_selected: Display current selection choice at the bottom of list. show_description: Display description of current selection if available. @@ -124,6 +133,11 @@ def select( ) ) + if use_jk_keys and use_search_filter: + raise ValueError( + "Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix." + ) + if use_shortcuts and use_jk_keys: if any(getattr(c, "shortcut_key", "") in ["j", "k"] for c in choices): raise ValueError( @@ -179,11 +193,11 @@ def get_prompt_tokens(): tokens.append(("class:instruction", instruction)) else: if use_shortcuts and use_arrow_keys: - instruction_msg = "(Use shortcuts or arrow keys)" + instruction_msg = f"(Use shortcuts or arrow keys{', type to filter' if use_search_filter else ''})" elif use_shortcuts and not use_arrow_keys: - instruction_msg = "(Use shortcuts)" + instruction_msg = f"(Use shortcuts{', type to filter' if use_search_filter else ''})" else: - instruction_msg = "(Use arrow keys)" + instruction_msg = f"(Use arrow keys{', type to filter' if use_search_filter else ''})" tokens.append(("class:instruction", instruction_msg)) return tokens @@ -229,6 +243,15 @@ def move_cursor_up(event): while not ic.is_selection_valid(): ic.select_previous() + if use_search_filter: + + def search_filter(event): + ic.add_search_character(event.key_sequence[0].key) + + for character in string.printable: + bindings.add(character, eager=True)(search_filter) + bindings.add(Keys.Backspace, eager=True)(search_filter) + if use_arrow_keys: bindings.add(Keys.Down, eager=True)(move_cursor_down) bindings.add(Keys.Up, eager=True)(move_cursor_up) diff --git a/tests/prompts/test_checkbox.py b/tests/prompts/test_checkbox.py index 4b7c8404..50da9bd8 100644 --- a/tests/prompts/test_checkbox.py +++ b/tests/prompts/test_checkbox.py @@ -350,3 +350,42 @@ def test_fail_on_no_method_to_move_selection(): with pytest.raises(ValueError): feed_cli_with_input("checkbox", message, text, **kwargs) + + +def test_select_filter_first_choice(): + message = "Foo message" + kwargs = {"choices": ["foo", "bar", "bazz"]} + text = KeyInputs.SPACE + KeyInputs.ENTER + "\r" + + result, cli = feed_cli_with_input( + "checkbox", + message, + text, + use_search_filter=True, + use_jk_keys=False, + **kwargs, + ) + assert result == ["foo"] + + +def test_select_filter_multiple_after_search(): + message = "Foo message" + kwargs = {"choices": ["foo", "bar", "bazz", "buzz"]} + text = ( + KeyInputs.SPACE + + "bu" + + KeyInputs.SPACE + + KeyInputs.BACK + + KeyInputs.BACK + + "\r" + ) + + result, cli = feed_cli_with_input( + "checkbox", + message, + text, + use_search_filter=True, + use_jk_keys=False, + **kwargs, + ) + assert result == ["foo", "buzz"] diff --git a/tests/prompts/test_select.py b/tests/prompts/test_select.py index 1d3eaa57..8e1194c3 100644 --- a/tests/prompts/test_select.py +++ b/tests/prompts/test_select.py @@ -384,3 +384,102 @@ def test_select_default_has_arrow_keys(): result, cli = feed_cli_with_input("select", message, text, **kwargs) assert result == "bazz" + + +def test_select_filter_with_jk_movement_exception(): + message = "Foo message" + kwargs = { + "choices": ["foo", "bazz"], + "use_arrow_keys": True, + "use_shortcuts": False, + } + text = "2" + KeyInputs.ENTER + "\r" + with pytest.raises(ValueError): + feed_cli_with_input("select", message, text, use_search_filter=True, **kwargs) + + +def test_filter_prefix_one_letter(): + message = "Foo message" + kwargs = {"choices": ["abc", "def", "ghi", "jkl"]} + text = "g" + KeyInputs.ENTER + "\r" + + result, cli = feed_cli_with_input( + "select", + message, + text, + use_search_filter=True, + use_jk_keys=False, + **kwargs, + ) + assert result == "ghi" + + +def test_filter_prefix_multiple_letters(): + message = "Foo message" + kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]} + text = "j" + "j" + KeyInputs.ENTER + "\r" + + result, cli = feed_cli_with_input( + "select", + message, + text, + use_search_filter=True, + use_jk_keys=False, + **kwargs, + ) + assert result == "jja" + + +def test_select_filter_handle_backspace(): + message = "Foo message" + kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]} + text = "j" + "j" + KeyInputs.BACK + KeyInputs.ENTER + "\r" + + result, cli = feed_cli_with_input( + "select", + message, + text, + use_search_filter=True, + use_jk_keys=False, + **kwargs, + ) + assert result == "jkl" + + message = "Foo message" + kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]} + text = ( + "j" + + "j" + + KeyInputs.BACK + + KeyInputs.BACK + + KeyInputs.BACK + + KeyInputs.BACK + + KeyInputs.ENTER + + "\r" + ) + + result, cli = feed_cli_with_input( + "select", + message, + text, + use_search_filter=True, + use_jk_keys=False, + **kwargs, + ) + assert result == "abc" + + +def test_select_goes_back_to_top_after_filtering(): + message = "Foo message" + kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]} + text = KeyInputs.DOWN + KeyInputs.DOWN + "j" + KeyInputs.ENTER + "\r" + + result, cli = feed_cli_with_input( + "select", + message, + text, + use_search_filter=True, + use_jk_keys=False, + **kwargs, + ) + assert result == "jkl"