From 159e2682ec3f28a49b8ebee7ad152b06b888b5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Bataille?= Date: Sun, 2 Feb 2020 10:56:01 +0100 Subject: [PATCH 1/5] test: vanilla filter tests --- tests/prompts/test_select.py | 18 ++++++++++++++++++ tests/utils.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/prompts/test_select.py b/tests/prompts/test_select.py index 04489aec..f1d29937 100644 --- a/tests/prompts/test_select.py +++ b/tests/prompts/test_select.py @@ -144,3 +144,21 @@ def test_select_empty_choices(): with pytest.raises(ValueError): feed_cli_with_input("select", message, text, **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, **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, **kwargs) + assert result == "jja" diff --git a/tests/utils.py b/tests/utils.py index 0388e1a5..f6c0fcd2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -27,7 +27,7 @@ def feed_cli_with_input(_type, message, texts, sleep_time=1, **kwargs): Create a Prompt, feed it with the given user input and return the CLI object. - You an provide multiple texts, the feeder will async sleep for `sleep_time` + You can provide multiple texts, the feeder will async sleep for `sleep_time` This returns a (result, Application) tuple. """ From b96a4d4e8d9ebfe4eac4b911dec1862b8ef02c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Bataille?= Date: Sun, 2 Feb 2020 10:56:20 +0100 Subject: [PATCH 2/5] feat: keybindings for search filter --- questionary/prompts/common.py | 1 + questionary/prompts/select.py | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/questionary/prompts/common.py b/questionary/prompts/common.py index 3a2b8438..93f188b1 100644 --- a/questionary/prompts/common.py +++ b/questionary/prompts/common.py @@ -155,6 +155,7 @@ def __init__( self.is_answered = False self.choices = [] self.selected_options = [] + self.prefix_search_filter = "" self._init_choices(choices) self._assign_shortcut_keys() diff --git a/questionary/prompts/select.py b/questionary/prompts/select.py index ba3a2677..ae83fb5f 100644 --- a/questionary/prompts/select.py +++ b/questionary/prompts/select.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +import string from typing import Any, Dict, List, Optional, Text, Union from prompt_toolkit.application import Application @@ -22,6 +22,7 @@ def select( use_shortcuts: bool = False, use_indicator: bool = False, use_pointer: bool = True, + use_prefix_filter_search: bool = False, instruction: Text = None, **kwargs: Any ) -> Question: @@ -59,6 +60,13 @@ def select( use_pointer: Flag to enable the pointer in front of the currently highlighted element. + + 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 Returns: Question: Question instance, ready to be prompted (using `.ask()`). """ @@ -138,19 +146,28 @@ def select_choice(event): else: @bindings.add(Keys.Down, eager=True) - @bindings.add("j", eager=True) def move_cursor_down(event): ic.select_next() while not ic.is_selection_valid(): ic.select_next() @bindings.add(Keys.Up, eager=True) - @bindings.add("k", eager=True) def move_cursor_up(event): ic.select_previous() while not ic.is_selection_valid(): ic.select_previous() + if use_prefix_filter_search: + def search_filter(event): + pass + + for character in string.printable: + bindings.add(character, eager=True)(search_filter) + else: + # Enable vi-like navigation + bindings.add("j", eager=True)(move_cursor_down) + bindings.add("k", eager=True)(move_cursor_up) + @bindings.add(Keys.ControlM, eager=True) def set_answer(event): ic.is_answered = True From 506c2aeb462f4459e75437ea15c8433eb351b7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Bataille?= Date: Sun, 2 Feb 2020 15:52:23 +0100 Subject: [PATCH 3/5] feat: filtering based on keyboard input --- questionary/prompts/common.py | 36 +++++++++++++++++++++++++---- questionary/prompts/select.py | 3 ++- tests/prompts/test_select.py | 43 +++++++++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/questionary/prompts/common.py b/questionary/prompts/common.py index 93f188b1..07c337a4 100644 --- a/questionary/prompts/common.py +++ b/questionary/prompts/common.py @@ -2,6 +2,7 @@ import inspect from prompt_toolkit import PromptSession from prompt_toolkit.filters import IsDone, Always +from prompt_toolkit.keys import Keys from prompt_toolkit.layout import ( FormattedTextControl, Layout, @@ -155,7 +156,7 @@ def __init__( self.is_answered = False self.choices = [] self.selected_options = [] - self.prefix_search_filter = "" + self.prefix_search_filter = None self._init_choices(choices) self._assign_shortcut_keys() @@ -209,9 +210,18 @@ def _init_choices(self, choices): self.choices.append(choice) + @property + def filtered_choices(self): + if not self.prefix_search_filter: + return self.choices + else: + return [ + c for c in self.choices if c.title.startswith(self.prefix_search_filter) + ] + @property def choice_count(self): - return len(self.choices) + return len(self.filtered_choices) def _get_choice_tokens(self): tokens = [] @@ -293,7 +303,7 @@ def append(index, 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) if self.use_shortcuts: @@ -324,7 +334,7 @@ def select_next(self): self.pointed_at = (self.pointed_at + 1) % self.choice_count def get_pointed_at(self): - return self.choices[self.pointed_at] + return self.filtered_choices[self.pointed_at] def get_selected_values(self): # get values not labels @@ -334,6 +344,24 @@ def get_selected_values(self): if (not isinstance(c, Separator) and c.value in self.selected_options) ] + def add_search_character(self, char: Keys) -> None: + 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 build_validator(validate: Any) -> Optional[Validator]: if validate: diff --git a/questionary/prompts/select.py b/questionary/prompts/select.py index ae83fb5f..f3f826b0 100644 --- a/questionary/prompts/select.py +++ b/questionary/prompts/select.py @@ -159,10 +159,11 @@ def move_cursor_up(event): if use_prefix_filter_search: def search_filter(event): - pass + 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) else: # Enable vi-like navigation bindings.add("j", eager=True)(move_cursor_down) diff --git a/tests/prompts/test_select.py b/tests/prompts/test_select.py index f1d29937..2d154ea7 100644 --- a/tests/prompts/test_select.py +++ b/tests/prompts/test_select.py @@ -151,7 +151,9 @@ def test_filter_prefix_one_letter(): kwargs = {"choices": ["abc", "def", "ghi", "jkl"]} text = "g" + KeyInputs.ENTER + "\r" - result, cli = feed_cli_with_input("select", message, text, **kwargs) + result, cli = feed_cli_with_input( + "select", message, text, use_prefix_filter_search=True, **kwargs + ) assert result == "ghi" @@ -160,5 +162,42 @@ def test_filter_prefix_multiple_letters(): kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]} text = "j" + "j" + KeyInputs.ENTER + "\r" - result, cli = feed_cli_with_input("select", message, text, **kwargs) + result, cli = feed_cli_with_input( + "select", message, text, use_prefix_filter_search=True, **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, **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, **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, **kwargs + ) + assert result == "jkl" From 657afce0a6c8963b36ab0061900256053f31ab7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Bataille?= Date: Mon, 3 Feb 2020 07:46:18 +0100 Subject: [PATCH 4/5] feat: bottom search bar --- questionary/prompts/common.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/questionary/prompts/common.py b/questionary/prompts/common.py index 07c337a4..1e361700 100644 --- a/questionary/prompts/common.py +++ b/questionary/prompts/common.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import inspect from prompt_toolkit import PromptSession -from prompt_toolkit.filters import IsDone, Always +from prompt_toolkit.filters import Always, Condition, IsDone from prompt_toolkit.keys import Keys from prompt_toolkit.layout import ( FormattedTextControl, @@ -10,6 +10,7 @@ ConditionalContainer, Window, ) +from prompt_toolkit.layout.dimension import LayoutDimension as D from prompt_toolkit.validation import Validator, ValidationError from typing import Optional, Any, List, Text, Dict, Union, Callable, Tuple @@ -362,6 +363,17 @@ def remove_search_character(self) -> None: 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', self.prefix_search_filter), + ('class:question-mark', '...'), + ] + def build_validator(validate: Any) -> Optional[Validator]: if validate: @@ -414,8 +426,22 @@ def create_inquirer_layout( _fix_unecessary_blank_lines(ps) + @Condition + def has_search_string(): + return ic.get_search_string_tokens() is not None + return Layout( HSplit( - [ps.layout.container, ConditionalContainer(Window(ic), filter=~IsDone())] + [ + ps.layout.container, + ConditionalContainer(Window(ic), filter=~IsDone()), + ConditionalContainer( + Window( + height=D.exact(2), + content=FormattedTextControl(ic.get_search_string_tokens) + ), + filter=has_search_string & ~IsDone() # noqa # pylint:disable=invalid-unary-operand-type + ), + ] ) ) From 925edf5debcc88e262b7729039410fc3a51bc5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Bataille?= Date: Mon, 3 Feb 2020 09:10:01 +0100 Subject: [PATCH 5/5] feat: default style for search bar --- questionary/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/questionary/constants.py b/questionary/constants.py index 93835891..898afafe 100644 --- a/questionary/constants.py +++ b/questionary/constants.py @@ -35,6 +35,7 @@ ("qmark", "fg:#5f819d"), # token in front of the question ("question", "bold"), # question text ("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question + ("search", "noinherit fg:#FF6600 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