diff --git a/README.md b/README.md index 76035788..5a196788 100644 --- a/README.md +++ b/README.md @@ -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=`. 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 diff --git a/examples/checkbox_locked.py b/examples/checkbox_locked.py new file mode 100644 index 00000000..c00e245c --- /dev/null +++ b/examples/checkbox_locked.py @@ -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) diff --git a/src/inquirer/questions.py b/src/inquirer/questions.py index 147dfeb2..e0253599 100644 --- a/src/inquirer/questions.py +++ b/src/inquirer/questions.py @@ -164,6 +164,7 @@ def __init__( name, message="", choices=None, + locked=None, default=None, ignore=False, validate=True, @@ -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 diff --git a/src/inquirer/render/console/_checkbox.py b/src/inquirer/render/console/_checkbox.py index fac2205b..93781122 100644 --- a/src/inquirer/render/console/_checkbox.py +++ b/src/inquirer/render/console/_checkbox.py @@ -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.default_choices()] self.current = 0 + def default_choices(self): + default = self.question.default or [] + return default + self.locked + @property def is_long(self): choices = self.question.choices or [] @@ -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: @@ -60,10 +63,12 @@ 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 = "+" @@ -71,6 +76,7 @@ def get_options(self): 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 @@ -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) diff --git a/src/inquirer/themes.py b/src/inquirer/themes.py index 43ca1cbc..7dd34953 100644 --- a/src/inquirer/themes.py +++ b/src/inquirer/themes.py @@ -75,7 +75,8 @@ def __init__(self): self.Editor = collections.namedtuple("editor", "opening_prompt") self.Checkbox = collections.namedtuple( "common", - "selection_color selection_icon selected_color unselected_color selected_icon unselected_icon", + "selection_color selection_icon selected_color unselected_color " + "selected_icon unselected_icon locked_option_color", ) self.List = collections.namedtuple("List", "selection_color selection_cursor unselected_color") @@ -93,6 +94,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 self.List.selection_color = term.blue self.List.selection_cursor = ">" self.List.unselected_color = term.normal diff --git a/tests/acceptance/test_checkbox.py b/tests/acceptance/test_checkbox.py index 47ad2502..e7b86855 100644 --- a/tests/acceptance/test_checkbox.py +++ b/tests/acceptance/test_checkbox.py @@ -147,3 +147,31 @@ 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_space(self): + self.sut.send(key.SPACE) + self.sut.send(key.ENTER) + self.sut.expect(r"{'courses': \['Programming fundamentals'\]}", timeout=1) + + def test_locked_option_left_key(self): + self.sut.send(key.LEFT) + 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) diff --git a/tests/integration/console_render/test_checkbox.py b/tests/integration/console_render/test_checkbox.py index 55a234d1..2414e140 100644 --- a/tests/integration/console_render/test_checkbox.py +++ b/tests/integration/console_render/test_checkbox.py @@ -324,3 +324,73 @@ def test_double_invert_all(self): result = sut.render(question) assert result == [] + + def test_unselect_locked_space(self): + stdin_array = [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=["foo"]) + + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == ["foo"] + + def test_unselect_locked_left(self): + stdin_array = [key.LEFT, 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"]) + + 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"]