Skip to content

Commit

Permalink
Add additional options to Choice, select and checkbox (#18)
Browse files Browse the repository at this point in the history
- Introduces an option `use_jk_keys` to enable/disable navigating a list using the `j` and `k` keys.
- Introduces an option `show_selected` to hide/show the selected answer.
- Allow a `Choice` to have no shortcut.
- Improve argument validation.

Co-authored-by: Kian Cross <kian@kiancross.co.uk>
  • Loading branch information
philastrophist and kiancross committed Jun 6, 2021
1 parent c5b6066 commit 0bb1a6d
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 41 deletions.
29 changes: 23 additions & 6 deletions questionary/prompts/checkbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):

Expand Down
40 changes: 27 additions & 13 deletions questionary/prompts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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":
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -192,13 +204,15 @@ 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,
):

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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
63 changes: 48 additions & 15 deletions questionary/prompts/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.")

Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
14 changes: 14 additions & 0 deletions tests/prompts/test_checkbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
48 changes: 48 additions & 0 deletions tests/prompts/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 0bb1a6d

Please sign in to comment.