Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added checkbox locked choices feature #327

Merged
merged 8 commits into from
Mar 6, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ questions = [
answers = inquirer.prompt(questions)
```

Checkbox questions can take one extra argument `carousel=False`. If set to true, the answers will rotate (back to first when pressing down on last choice, and down to last choice when pressing up on first choice)
Checkbox questions can take extra argument `carousel=False`. If set to true, the answers will rotate (back to first when pressing down on last choice, and down to last choice when pressing up on first choice)

Another argument that can be used is `locked=<List>`. The given choices in the locked argument cannot be removed. This is useful if you want to make clear that a specific option out of the choices must be chosen.

### Path

Expand Down
21 changes: 21 additions & 0 deletions examples/checkbox_locked.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import os
import sys
from pprint import pprint


sys.path.append(os.path.realpath("."))
import inquirer # noqa


questions = [
inquirer.Checkbox(
"courses",
message="Which courses would you like to take?",
choices=["Programming fundamentals", "Fullstack development", "Data science", "DevOps"],
locked=["Programming fundamentals"],
),
]

answers = inquirer.prompt(questions)

pprint(answers)
2 changes: 2 additions & 0 deletions src/inquirer/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def __init__(
name,
message="",
choices=None,
locked=None,
default=None,
ignore=False,
validate=True,
Expand All @@ -173,6 +174,7 @@ def __init__(
):

super().__init__(name, message, choices, default, ignore, validate, other=other)
self.locked = locked
self.carousel = carousel
self.autocomplete = autocomplete

Expand Down
20 changes: 14 additions & 6 deletions src/inquirer/render/console/_checkbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
class Checkbox(BaseConsoleRender):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.selection = [k for (k, v) in enumerate(self.question.choices) if v in (self.question.default or [])]
self.locked = self.question.locked or []
self.selection = [k for (k, v) in enumerate(self.question.choices) if v in self.set_default_choices()]
self.current = 0

def set_default_choices(self):
Copy link
Collaborator

@Cube707 Cube707 Jan 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function is named strangely. I assume you wanted to make its return type clear? (which changed in the meantime)

However like this its named as a Setter-Methode, which it is clearly not.

Suggested change
def set_default_choices(self):
def default_choices(self):

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed

default = self.question.default or []
return default + self.locked

@property
def is_long(self):
choices = self.question.choices or []
Expand All @@ -39,14 +44,12 @@ def get_options(self):
is_in_beginning = self.current <= half_options
is_in_middle = half_options < self.current < ending_milestone
is_in_end = self.current >= ending_milestone

for index, choice in enumerate(cchoices):
if (
(is_in_middle and self.current - half_options + index in self.selection)
or (is_in_beginning and index in self.selection)
or (is_in_end and index + max(len(choices) - MAX_OPTIONS_DISPLAYED_AT_ONCE, 0) in self.selection)
): # noqa

symbol = self.theme.Checkbox.selected_icon
color = self.theme.Checkbox.selected_color
else:
Expand All @@ -60,17 +63,20 @@ def get_options(self):
or (is_in_beginning and index == self.current)
or (is_in_end and end_index == self.current)
):

selector = self.theme.Checkbox.selection_icon
color = self.theme.Checkbox.selection_color

if choice in self.locked:
color = self.theme.Checkbox.locked_option_color

if choice == GLOBAL_OTHER_CHOICE:
symbol = "+"

yield choice, selector + " " + symbol, color

def process_input(self, pressed):
question = self.question
is_current_choice_locked = question.choices[self.current] in self.locked
if pressed == key.UP:
if question.carousel and self.current == 0:
self.current = len(question.choices) - 1
Expand All @@ -87,12 +93,14 @@ def process_input(self, pressed):
if self.question.choices[self.current] == GLOBAL_OTHER_CHOICE:
self.other_input()
elif self.current in self.selection:
self.selection.remove(self.current)
if not is_current_choice_locked:
self.selection.remove(self.current)
else:
self.selection.append(self.current)
elif pressed == key.LEFT:
if self.current in self.selection:
self.selection.remove(self.current)
if not is_current_choice_locked:
self.selection.remove(self.current)
elif pressed == key.RIGHT:
if self.current not in self.selection:
self.selection.append(self.current)
Expand Down
1 change: 1 addition & 0 deletions src/inquirer/themes.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def __init__(self):
self.Checkbox.selected_color = term.yellow + term.bold
self.Checkbox.unselected_color = term.normal
self.Checkbox.unselected_icon = "[ ]"
self.Checkbox.locked_option_color = term.gray50
NivEz marked this conversation as resolved.
Show resolved Hide resolved
self.List.selection_color = term.blue
self.List.selection_cursor = ">"
self.List.unselected_color = term.normal
Expand Down
24 changes: 24 additions & 0 deletions tests/acceptance/test_checkbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,27 @@ def setUp(self):
def test_default_selection(self):
self.sut.send(key.ENTER)
self.sut.expect("{'interests': ", timeout=1)


@unittest.skipUnless(sys.platform.startswith("lin"), "Linux only")
class CheckLockedTest(unittest.TestCase):
def setUp(self):
self.sut = pexpect.spawn("python examples/checkbox_locked.py")
self.sut.expect("DevOps.*", timeout=1)

def test_default_selection(self):
self.sut.send(key.ENTER)
self.sut.expect(r"{'courses': \['Programming fundamentals'\]}", timeout=1)

def test_locked_option(self):
self.sut.send(key.SPACE)
self.sut.send(key.LEFT)
Copy link
Collaborator

@Cube707 Cube707 Jan 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its a good idea to test for both keys to select a option.

But in this case the test would actually succseed even if the locking code didn't work, as it deselects and than imediatly selects againt. So please seperate this into two seperate tests

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seprated into two tests

self.sut.send(key.ENTER)
self.sut.expect(r"{'courses': \['Programming fundamentals'\]}", timeout=1)

def test_locked_with_another_option(self):
self.sut.send(key.DOWN)
self.sut.send(key.DOWN)
self.sut.send(key.SPACE)
self.sut.send(key.ENTER)
self.sut.expect(r"{'courses': \['Programming fundamentals', 'Data science'\]}", timeout=1)
56 changes: 56 additions & 0 deletions tests/integration/console_render/test_checkbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,59 @@ def test_double_invert_all(self):
result = sut.render(question)

assert result == []

def test_unselect_locked(self):
stdin_array = [key.SPACE, key.LEFT, key.ENTER]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seprated into two tests as well

stdin = helper.event_factory(*stdin_array)
message = "Foo message"
variable = "Bar variable"
choices = ["foo", "bar", "bazz"]

question = questions.Checkbox(variable, message, choices=choices, locked=["foo"])

sut = ConsoleRender(event_generator=stdin)
result = sut.render(question)

assert result == ["foo"]

def test_two_locked_options(self):
stdin_array = [key.ENTER]
stdin = helper.event_factory(*stdin_array)
message = "Foo message"
variable = "Bar variable"
choices = ["foo", "bar", "bazz"]

question = questions.Checkbox(variable, message, choices=choices, locked=["foo", "bazz"])

sut = ConsoleRender(event_generator=stdin)
result = sut.render(question)

assert result == ["foo", "bazz"]

def test_locked_with_typo(self):
stdin_array = [key.ENTER]
stdin = helper.event_factory(*stdin_array)
message = "Foo message"
variable = "Bar variable"
choices = ["foo", "bar", "bazz"]

question = questions.Checkbox(variable, message, choices=choices, locked=["fooo"])

sut = ConsoleRender(event_generator=stdin)
result = sut.render(question)

assert result == []

def test_locked_with_default(self):
stdin_array = [key.DOWN, key.SPACE, key.ENTER]
stdin = helper.event_factory(*stdin_array)
message = "Foo message"
variable = "Bar variable"
choices = ["foo", "bar", "bazz"]

question = questions.Checkbox(variable, message, choices=choices, locked=["bar"], default=["bar"])

sut = ConsoleRender(event_generator=stdin)
result = sut.render(question)

assert result == ["bar"]