From 07f5c1af9782465e6606a3aac3fd8c28c907726a Mon Sep 17 00:00:00 2001 From: Mario da Graca Date: Thu, 25 Jul 2024 00:37:41 +0200 Subject: [PATCH] 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](https://github.com/tmbo/questionary/pull/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 --- examples/checkbox_search.py | 75 +++++++++++++++++++++++++ examples/select_search.py | 62 +++++++++++++++++++++ questionary/constants.py | 8 +++ questionary/prompts/checkbox.py | 35 ++++++++++-- questionary/prompts/common.py | 64 ++++++++++++++++++++- questionary/prompts/select.py | 29 +++++++++- tests/prompts/test_checkbox.py | 39 +++++++++++++ tests/prompts/test_select.py | 99 +++++++++++++++++++++++++++++++++ 8 files changed, 401 insertions(+), 10 deletions(-) create mode 100644 examples/checkbox_search.py create mode 100644 examples/select_search.py 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 ad0fca21..d07d0e63 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 @@ -236,6 +238,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 @@ -307,6 +310,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() @@ -375,9 +379,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 = [] @@ -457,7 +471,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() @@ -499,7 +513,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 @@ -509,6 +523,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: @@ -563,6 +610,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 ) @@ -572,6 +623,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 c4b9221d..9aa37598 100644 --- a/tests/prompts/test_select.py +++ b/tests/prompts/test_select.py @@ -409,3 +409,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"