From 2e7f4cc904d1b1e135931c9878ad0635ca1c3df2 Mon Sep 17 00:00:00 2001 From: mario-dg Date: Wed, 29 May 2024 21:27:10 +0200 Subject: [PATCH] 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) --- examples/checkbox_search.py | 75 ++++++++++++++++++++++++ examples/select_search.py | 62 ++++++++++++++++++++ questionary/constants.py | 8 +++ questionary/prompts/checkbox.py | 27 +++++++++ questionary/prompts/common.py | 66 ++++++++++++++++++++- questionary/prompts/select.py | 23 ++++++++ tests/prompts/test_checkbox.py | 39 ++++++++++++ tests/prompts/test_select.py | 101 ++++++++++++++++++++++++++++++++ 8 files changed, 398 insertions(+), 3 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 0000000..7536809 --- /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_prefix_filter_search=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 0000000..d38ad9d --- /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_prefix_filter_search=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 94c5ff4..845dd78 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 25161e4..9f4c08d 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_prefix_filter_search: Union[str, bool, None] = False, instruction: Optional[str] = None, show_description: bool = True, **kwargs: Any, @@ -105,6 +107,15 @@ def checkbox( use_emacs_keys: Allow the user to select items from the list using `Ctrl+N` (down) and `Ctrl+P` (up) keys. + + use_prefix_filter_search: ["case_sensitive", "case_insensitive", None | False] + Flag to enable prefix filter. Typing some prefix will + filter the choices to keep only the one that match + the prefix. + 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 +130,11 @@ def checkbox( "Emacs keys." ) + if use_jk_keys and use_prefix_filter_search: + 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 @@ -265,6 +281,17 @@ def move_cursor_up(event): while not ic.is_selection_valid(): ic.select_previous() + if use_prefix_filter_search: + + 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 827629d..b072f2d 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] + prefix_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,21 @@ def _init_choices( self.choices.append(choice) + @property + def filtered_choices(self): + if not self.prefix_search_filter: + return self.choices + filtered = [ + c + for c in self.choices + if c.title.lower().startswith(self.prefix_search_filter.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 +441,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 +483,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 +493,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.prefix_search_filter is None: + self.prefix_search_filter = str(char) + else: + self.prefix_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.prefix_search_filter and len(self.prefix_search_filter) > 1: + self.prefix_search_filter = self.prefix_search_filter[:-1] + else: + self.prefix_search_filter = None + + def get_search_string_tokens(self): + if self.prefix_search_filter is None: + return None + + return [ + ("", "\n"), + ("class:question-mark", "/ "), + ( + "class:search_success" if self.found_in_search else "class:search_none", + self.prefix_search_filter, + ), + ("class:question-mark", "..."), + ] + def build_validator(validate: Any) -> Optional[Validator]: if validate: @@ -531,6 +580,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 +593,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 41121bd..f870fe1 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_prefix_filter_search: 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_prefix_filter_search: Flag to enable prefix filter. Typing some prefix will + filter the choices to keep only the one that match + the prefix. + 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_prefix_filter_search: + 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( @@ -229,6 +243,15 @@ def move_cursor_up(event): while not ic.is_selection_valid(): ic.select_previous() + if use_prefix_filter_search: + + 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 4b7c840..d27bc4c 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_prefix_filter_search=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_prefix_filter_search=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 1d3eaa5..dfd0e40 100644 --- a/tests/prompts/test_select.py +++ b/tests/prompts/test_select.py @@ -384,3 +384,104 @@ 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_prefix_filter_search=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_prefix_filter_search=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_prefix_filter_search=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_prefix_filter_search=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_prefix_filter_search=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_prefix_filter_search=True, + use_jk_keys=False, + **kwargs, + ) + assert result == "jkl"