diff --git a/questionary/prompts/checkbox.py b/questionary/prompts/checkbox.py index 6562baac..a9797a15 100644 --- a/questionary/prompts/checkbox.py +++ b/questionary/prompts/checkbox.py @@ -27,6 +27,8 @@ def checkbox( pointer: Optional[str] = DEFAULT_SELECTED_POINTER, style: Optional[Style] = None, initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None, + use_arrow_keys: bool = True, + use_jk_keys: bool = True, **kwargs: Any, ) -> Question: """Ask the user to select from a list of items. @@ -85,10 +87,21 @@ def checkbox( initial_choice: A value corresponding to a selectable item in the choices, to initially set the pointer position to. + use_arrow_keys: Allow the user to select items from the list using + arrow keys. + + use_jk_keys: Allow the user to select items from the list using + `j` (down) and `k` (up) keys. + Returns: :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). """ + if not (use_arrow_keys or use_jk_keys): + raise ValueError( + "Some option to move the selection is required. Arrow keys or j/k keys." + ) + merged_style = merge_styles( [ DEFAULT_STYLE, @@ -220,20 +233,24 @@ def all(_event): perform_validation(get_selected_values()) - @bindings.add(Keys.Down, eager=True) - @bindings.add("j", eager=True) - def move_cursor_down(_event): + 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): + def move_cursor_up(event): ic.select_previous() while not ic.is_selection_valid(): ic.select_previous() + if use_arrow_keys: + bindings.add(Keys.Down, eager=True)(move_cursor_down) + bindings.add(Keys.Up, eager=True)(move_cursor_up) + + if use_jk_keys: + 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): diff --git a/questionary/prompts/common.py b/questionary/prompts/common.py index df0ebf41..00c44b7f 100644 --- a/questionary/prompts/common.py +++ b/questionary/prompts/common.py @@ -69,7 +69,7 @@ def __init__( value: Optional[Any] = None, disabled: Optional[str] = None, checked: Optional[bool] = False, - shortcut_key: Optional[str] = None, + shortcut_key: Optional[Union[str, bool]] = True, ) -> None: self.disabled = disabled @@ -84,9 +84,15 @@ def __init__( self.value = title if shortcut_key is not None: - self.shortcut_key = str(shortcut_key) + if isinstance(shortcut_key, bool): + self.auto_shortcut = shortcut_key + self.shortcut_key = None + else: + self.shortcut_key = str(shortcut_key) + self.auto_shortcut = False else: self.shortcut_key = None + self.auto_shortcut = True @staticmethod def build(c: Union[str, "Choice", Dict[str, Any]]) -> "Choice": @@ -114,6 +120,12 @@ def build(c: Union[str, "Choice", Dict[str, Any]]) -> "Choice": c.get("key"), ) + def get_shortcut_title(self): + if self.shortcut_key is None: + return "-) " + else: + return "{}) ".format(self.shortcut_key) + class Separator(Choice): """Used to space/separate choices group.""" @@ -192,6 +204,7 @@ def __init__( pointer: Optional[str] = DEFAULT_SELECTED_POINTER, use_indicator: bool = True, use_shortcuts: bool = False, + show_selected: bool = False, use_arrow_keys: bool = True, initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None, **kwargs: Any, @@ -199,6 +212,7 @@ def __init__( self.use_indicator = use_indicator self.use_shortcuts = use_shortcuts + self.show_selected = show_selected self.use_arrow_keys = use_arrow_keys self.default = default self.pointer = pointer @@ -263,7 +277,7 @@ def _assign_shortcut_keys(self): shortcut_idx = 0 for c in self.choices: - if c.shortcut_key is None and not c.disabled: + if c.auto_shortcut and not c.disabled: c.shortcut_key = available_shortcuts[shortcut_idx] shortcut_idx += 1 @@ -342,10 +356,7 @@ def append(index: int, choice: Choice): ) ) else: - if self.use_shortcuts and choice.shortcut_key is not None: - shortcut = "{}) ".format(choice.shortcut_key) - else: - shortcut = "" + shortcut = choice.get_shortcut_title() if self.use_shortcuts else "" if selected: if self.use_indicator: @@ -381,13 +392,16 @@ def append(index: int, choice: Choice): for i, c in enumerate(self.choices): append(i, c) - if self.use_shortcuts: - tokens.append( - ( - "class:text", - " Answer: {}" "".format(self.get_pointed_at().shortcut_key), - ) + if self.show_selected: + current = self.get_pointed_at() + + answer = current.get_shortcut_title() if self.use_shortcuts else "" + + answer += ( + current.title if isinstance(current.title, str) else current.title[0][1] ) + + tokens.append(("class:text", " Answer: {}".format(answer))) else: tokens.pop() # Remove last newline. return tokens diff --git a/questionary/prompts/select.py b/questionary/prompts/select.py index b91d676a..6c108fa1 100644 --- a/questionary/prompts/select.py +++ b/questionary/prompts/select.py @@ -28,6 +28,8 @@ def select( use_shortcuts: bool = False, use_arrow_keys: bool = True, use_indicator: bool = False, + use_jk_keys: bool = True, + show_selected: bool = False, instruction: Optional[str] = None, **kwargs: Any, ) -> Question: @@ -86,13 +88,35 @@ def select( use_shortcuts: Allow the user to select items from the list using shortcuts. The shortcuts will be displayed in front of - the list items. + the list items. Arrow keys, j/k keys and shortcuts are + not mutually exclusive. - use_arrow_keys: Allow usage of arrow keys to select item. + use_arrow_keys: Allow the user to select items from the list using + arrow keys. Arrow keys, j/k keys and shortcuts are not + mutually exclusive. + + use_jk_keys: Allow the user to select items from the list using + `j` (down) and `k` (up) keys. Arrow keys, j/k keys and + shortcuts are not mutually exclusive. + + show_selected: Display current selection choice at the bottom of list. Returns: :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). """ + if not (use_arrow_keys or use_shortcuts or use_jk_keys): + raise ValueError( + "Some option to move the selection is required. Arrow keys, j/k keys or shortcuts." + ) + + if use_shortcuts and use_jk_keys: + if any(getattr(c, "shortcut_key", "") in ["j", "k"] for c in choices): + raise ValueError( + "A choice is trying to register j/k as a " + "shortcut key when they are in use as arrow keys " + "disable one or the other." + ) + if choices is None or len(choices) == 0: raise ValueError("A list of choices needs to be provided.") @@ -113,6 +137,7 @@ def select( pointer=pointer, use_indicator=use_indicator, use_shortcuts=use_shortcuts, + show_selected=show_selected, use_arrow_keys=use_arrow_keys, initial_choice=default, ) @@ -157,7 +182,13 @@ def _(event): if use_shortcuts: # add key bindings for choices for i, c in enumerate(ic.choices): - if isinstance(c, Separator): + if c.shortcut_key is None and not c.disabled and not use_arrow_keys: + raise RuntimeError( + "{} does not have a shortcut and arrow keys " + "for movement are disabled. " + "This choice is not reachable.".format(c.title) + ) + if isinstance(c, Separator) or c.shortcut_key is None: continue # noinspection PyShadowingNames @@ -170,21 +201,23 @@ def select_choice(event): _reg_binding(i, c.shortcut_key) - if use_arrow_keys or use_shortcuts is False: - - @bindings.add(Keys.Down, eager=True) - @bindings.add("j", eager=True) - def move_cursor_down(event): + def move_cursor_down(event): + ic.select_next() + while not ic.is_selection_valid(): 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): + def move_cursor_up(event): + ic.select_previous() + while not ic.is_selection_valid(): ic.select_previous() - while not ic.is_selection_valid(): - ic.select_previous() + + if use_arrow_keys: + bindings.add(Keys.Down, eager=True)(move_cursor_down) + bindings.add(Keys.Up, eager=True)(move_cursor_up) + + if use_jk_keys: + 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): diff --git a/tests/prompts/test_checkbox.py b/tests/prompts/test_checkbox.py index 491ba55a..fb5db28a 100644 --- a/tests/prompts/test_checkbox.py +++ b/tests/prompts/test_checkbox.py @@ -264,3 +264,17 @@ def test_proper_type_returned(): result, cli = feed_cli_with_input("checkbox", message, text, **kwargs) assert result == [1, "foo", [3, "bar"]] + + +def test_fail_on_no_method_to_move_selection(): + message = "Foo message" + kwargs = { + "choices": ["foo", Choice("bar", disabled="bad"), "bazz"], + "use_shortcuts": False, + "use_arrow_keys": False, + "use_jk_keys": False, + } + text = KeyInputs.ENTER + "\r" + + with pytest.raises(ValueError): + feed_cli_with_input("checkbox", message, text, **kwargs) diff --git a/tests/prompts/test_common.py b/tests/prompts/test_common.py index 57341c69..d9beb6f8 100644 --- a/tests/prompts/test_common.py +++ b/tests/prompts/test_common.py @@ -7,6 +7,7 @@ from prompt_toolkit.output import ColorDepth, DummyOutput from prompt_toolkit.validation import ValidationError, Validator from questionary.prompts import common +from questionary import Choice from questionary.prompts.common import ( InquirerControl, @@ -128,6 +129,53 @@ def test_prompt_highlight_coexist(): assert ic._get_choice_tokens() == expected_tokens +def test_prompt_show_answer_with_shortcuts(): + ic = InquirerControl( + ["a", Choice("b", shortcut_key=False), "c"], + show_selected=True, + use_shortcuts=True, + ) + + expected_tokens = [ + ("class:pointer", " » "), + ("[SetCursorPosition]", ""), + ("class:text", "○ "), + ("class:highlighted", "1) a"), + ("", "\n"), + ("class:text", " "), + ("class:text", "○ "), + ("class:text", "-) b"), + ("", "\n"), + ("class:text", " "), + ("class:text", "○ "), + ("class:text", "2) c"), + ("", "\n"), + ("class:text", " Answer: 1) a"), + ] + assert ic.pointed_at == 0 + assert ic._get_choice_tokens() == expected_tokens + + ic.select_next() + expected_tokens = [ + ("class:text", " "), + ("class:text", "○ "), + ("class:text", "1) a"), + ("", "\n"), + ("class:pointer", " » "), + ("[SetCursorPosition]", ""), + ("class:text", "○ "), + ("class:highlighted", "-) b"), + ("", "\n"), + ("class:text", " "), + ("class:text", "○ "), + ("class:text", "2) c"), + ("", "\n"), + ("class:text", " Answer: -) b"), + ] + assert ic.pointed_at == 1 + assert ic._get_choice_tokens() == expected_tokens + + def test_print(monkeypatch): mock = Mock(return_value=None) monkeypatch.setattr(DummyOutput, "write", mock) diff --git a/tests/prompts/test_select.py b/tests/prompts/test_select.py index c67d58f6..e8db4d36 100644 --- a/tests/prompts/test_select.py +++ b/tests/prompts/test_select.py @@ -47,20 +47,22 @@ def test_select_second_choice(): assert result == "bar" -def test_select_second_choice_using_j(): +def test_select_third_choice(): message = "Foo message" kwargs = {"choices": ["foo", "bar", "bazz"]} - text = "j" + KeyInputs.ENTER + "\r" + text = KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER + "\r" result, cli = feed_cli_with_input("select", message, text, **kwargs) - assert result == "bar" + assert result == "bazz" -def test_select_third_choice(): +def test_select_third_choice_using_shortcuts_and_arrows(): message = "Foo message" - kwargs = {"choices": ["foo", "bar", "bazz"]} - text = KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER + "\r" - + kwargs = { + "choices": ["foo", "bar", "bazz"], + "use_shortcuts": True, + } + text = KeyInputs.TWO + KeyInputs.DOWN + KeyInputs.ENTER + "\r" result, cli = feed_cli_with_input("select", message, text, **kwargs) assert result == "bazz" @@ -146,6 +148,30 @@ def test_select_empty_choices(): feed_cli_with_input("select", message, text, **kwargs) +def test_disallow_shortcut_key(): + message = "Foo message" + kwargs = { + "choices": ["foo", "bar", Choice("bazz", shortcut_key=False)], + "use_shortcuts": True, + } + text = KeyInputs.THREE + "\r" + + result, cli = feed_cli_with_input("select", message, text, **kwargs) + assert result == "foo" + + +def test_allow_shortcut_key_with_True(): + message = "Foo message" + kwargs = { + "choices": ["foo", "bar", Choice("bazz", shortcut_key=True)], + "use_shortcuts": True, + } + text = KeyInputs.THREE + "\r" + + result, cli = feed_cli_with_input("select", message, text, **kwargs) + assert result == "bazz" + + def test_select_initial_choice(): message = "Foo message" kwargs = {"choices": ["foo", "bazz"], "default": "bazz"} @@ -204,6 +230,60 @@ def test_select_arrow_keys(): assert result == "bazz" +def test_fail_for_unreachable_choice(): + message = "Foo message" + kwargs = { + "choices": ["foo", "bar", Choice("bazz", shortcut_key=False)], + "use_shortcuts": True, + "use_arrow_keys": False, + } + text = KeyInputs.THREE + "\r" + + with pytest.raises(RuntimeError): + feed_cli_with_input("select", message, text, **kwargs) + + +def test_fail_on_no_method_to_move_selection(): + message = "Foo message" + kwargs = { + "choices": ["foo", Choice("bar", disabled="bad"), "bazz"], + "use_shortcuts": False, + "use_arrow_keys": False, + "use_jk_keys": False, + } + text = KeyInputs.ENTER + "\r" + + with pytest.raises(ValueError): + feed_cli_with_input("select", message, text, **kwargs) + + +def test_jk_and_shortcut_conflict_fails(): + message = "Foo message" + kwargs = { + "choices": ["foo", Choice("bar", shortcut_key="j"), "bazz"], + "use_shortcuts": True, + "use_arrow_keys": True, + "use_jk_keys": True, + } + text = KeyInputs.ENTER + "\r" + + with pytest.raises(ValueError): + feed_cli_with_input("select", message, text, **kwargs) + + +def test_jk_and_shortcut_conflict_avoided_by_disabling_ij_keys(): + message = "Foo message" + kwargs = { + "choices": ["foo", Choice("bar", shortcut_key="j"), "bazz"], + "use_shortcuts": True, + "use_arrow_keys": True, + "use_jk_keys": False, + } + text = KeyInputs.ENTER + "\r" + + feed_cli_with_input("select", message, text, **kwargs) + + def test_select_shortcuts(): message = "Foo message" kwargs = {"choices": ["foo", "bazz"], "use_shortcuts": True} diff --git a/tests/utils.py b/tests/utils.py index cb7d2972..de31db36 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -20,6 +20,9 @@ class KeyInputs: BACK = "\x7f" SPACE = " " TAB = "\x09" + ONE = "1" + TWO = "2" + THREE = "3" def feed_cli_with_input(_type, message, texts, sleep_time=1, **kwargs):