Skip to content

Commit

Permalink
feat: add search functionality to select and checkbox prompt, based on
Browse files Browse the repository at this point in the history
…#42 (#374)

* feat: add search functionality to select and checkbox prompt

This commit is heavily inspired by [gbataille's](https://github.com/gbataille) [PR](#42)

* chore: bump minor version, because of new feature

* chore: bump version in pyproject.toml

* feat: changed prefix filter to search filter, allow all/invert with ctrl

Instead of a prefix filter, the search filter is now searched within all entries. This seems to be more common than a prefix search.

Using the search functionality disabled the ability to select all options or invert the selection in the checkbox control. If the search filter is enabled, these two functionalities can now be used with the key modifier ctrl.

Updated the displayed instructions to match the changes made.

* fix: reverted version bump

---------

Co-authored-by: mario-dg <[email protected]>
  • Loading branch information
mario-dg and mario-dg authored Jul 24, 2024
1 parent 2aeed69 commit 07f5c1a
Show file tree
Hide file tree
Showing 8 changed files with 401 additions and 10 deletions.
75 changes: 75 additions & 0 deletions examples/checkbox_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import questionary
from examples import custom_style_dope

zoo_animals = [
"Lion",
"Tiger",
"Elephant",
"Giraffe",
"Zebra",
"Panda",
"Kangaroo",
"Gorilla",
"Chimpanzee",
"Orangutan",
"Hippopotamus",
"Rhinoceros",
"Leopard",
"Cheetah",
"Polar Bear",
"Grizzly Bear",
"Penguin",
"Flamingo",
"Peacock",
"Ostrich",
"Emu",
"Koala",
"Sloth",
"Armadillo",
"Meerkat",
"Lemur",
"Red Panda",
"Wolf",
"Fox",
"Otter",
"Sea Lion",
"Walrus",
"Seal",
"Crocodile",
"Alligator",
"Python",
"Boa Constrictor",
"Iguana",
"Komodo Dragon",
"Tortoise",
"Turtle",
"Parrot",
"Toucan",
"Macaw",
"Hyena",
"Jaguar",
"Anteater",
"Capybara",
"Bison",
"Moose",
]


if __name__ == "__main__":
toppings = (
questionary.checkbox(
"Select animals for your zoo",
choices=zoo_animals,
validate=lambda a: (
True if len(a) > 0 else "You must select at least one zoo animal"
),
style=custom_style_dope,
use_jk_keys=False,
use_search_filter=True,
).ask()
or []
)

print(
f"Alright let's create our zoo with following animals: {', '.join(toppings)}."
)
62 changes: 62 additions & 0 deletions examples/select_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Example for a select question type with search enabled.
Run example by typing `python -m examples.select_search` in your console."""
from pprint import pprint

import questionary
from examples import custom_style_dope
from questionary import Choice
from questionary import Separator
from questionary import prompt


def ask_pystyle(**kwargs):
# create the question object
question = questionary.select(
"What do you want to do?",
qmark="😃",
choices=[
"Order a pizza",
"Make a reservation",
"Cancel a reservation",
"Modify your order",
Separator(),
"Ask for opening hours",
Choice("Contact support", disabled="Unavailable at this time"),
"Talk to the receptionist",
],
style=custom_style_dope,
use_jk_keys=False,
use_search_filter=True,
**kwargs,
)

# prompt the user for an answer
return question.ask()


def ask_dictstyle(**kwargs):
questions = [
{
"type": "select",
"name": "theme",
"message": "What do you want to do?",
"choices": [
"Order a pizza",
"Make a reservation",
"Cancel a reservation",
"Modify your order",
Separator(),
"Ask for opening hours",
{"name": "Contact support", "disabled": "Unavailable at this time"},
"Talk to the receptionist",
],
}
]

return prompt(questions, style=custom_style_dope, **kwargs)


if __name__ == "__main__":
pprint(ask_pystyle())
8 changes: 8 additions & 0 deletions questionary/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
("qmark", "fg:#5f819d"), # token in front of the question
("question", "bold"), # question text
("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question
(
"search_success",
"noinherit fg:#00FF00 bold",
), # submitted answer text behind the question
(
"search_none",
"noinherit fg:#FF0000 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
Expand Down
35 changes: 31 additions & 4 deletions questionary/prompts/checkbox.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import string
from typing import Any
from typing import Callable
from typing import Dict
Expand Down Expand Up @@ -37,6 +38,7 @@ def checkbox(
use_arrow_keys: bool = True,
use_jk_keys: bool = True,
use_emacs_keys: bool = True,
use_search_filter: Union[str, bool, None] = False,
instruction: Optional[str] = None,
show_description: bool = True,
**kwargs: Any,
Expand Down Expand Up @@ -105,6 +107,14 @@ def checkbox(
use_emacs_keys: Allow the user to select items from the list using
`Ctrl+N` (down) and `Ctrl+P` (up) keys.
use_search_filter: Flag to enable search filtering. Typing some string will
filter the choices to keep only the ones that contain the
search string.
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
instruction: A message describing how to navigate the menu.
show_description: Display description of current selection if available.
Expand All @@ -119,6 +129,11 @@ def checkbox(
"Emacs keys."
)

if use_jk_keys and use_search_filter:
raise ValueError(
"Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix."
)

merged_style = merge_styles_default(
[
# Disable the default inverted colours bottom-toolbar behaviour (for
Expand Down Expand Up @@ -179,8 +194,9 @@ def get_prompt_tokens() -> List[Tuple[str, str]]:
"class:instruction",
"(Use arrow keys to move, "
"<space> to select, "
"<a> to toggle, "
"<i> to invert)",
f"<{'ctrl-a' if use_search_filter else 'a'}> to toggle, "
f"<{'ctrl-a' if use_search_filter else 'i'}> to invert"
f"{', type to filter' if use_search_filter else ''})",
)
)
return tokens
Expand Down Expand Up @@ -225,7 +241,7 @@ def toggle(_event):

perform_validation(get_selected_values())

@bindings.add("i", eager=True)
@bindings.add(Keys.ControlI if use_search_filter else "i", eager=True)
def invert(_event):
inverted_selection = [
c.value
Expand All @@ -238,7 +254,7 @@ def invert(_event):

perform_validation(get_selected_values())

@bindings.add("a", eager=True)
@bindings.add(Keys.ControlA if use_search_filter else "a", eager=True)
def all(_event):
all_selected = True # all choices have been selected
for c in ic.choices:
Expand All @@ -265,6 +281,17 @@ def move_cursor_up(event):
while not ic.is_selection_valid():
ic.select_previous()

if use_search_filter:

def search_filter(event):
ic.add_search_character(event.key_sequence[0].key)

for character in string.printable:
if character in string.whitespace:
continue
bindings.add(character, eager=True)(search_filter)
bindings.add(Keys.Backspace, eager=True)(search_filter)

if use_arrow_keys:
bindings.add(Keys.Down, eager=True)(move_cursor_down)
bindings.add(Keys.Up, eager=True)(move_cursor_up)
Expand Down
64 changes: 61 additions & 3 deletions questionary/prompts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
from prompt_toolkit.filters import Always
from prompt_toolkit.filters import Condition
from prompt_toolkit.filters import IsDone
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import ConditionalContainer
from prompt_toolkit.layout import FormattedTextControl
from prompt_toolkit.layout import HSplit
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout import Window
from prompt_toolkit.layout.dimension import LayoutDimension
from prompt_toolkit.styles import Style
from prompt_toolkit.validation import ValidationError
from prompt_toolkit.validation import Validator
Expand Down Expand Up @@ -236,6 +238,7 @@ class InquirerControl(FormattedTextControl):
choices: List[Choice]
default: Optional[Union[str, Choice, Dict[str, Any]]]
selected_options: List[Any]
search_filter: Union[str, None] = None
use_indicator: bool
use_shortcuts: bool
use_arrow_keys: bool
Expand Down Expand Up @@ -307,6 +310,7 @@ def __init__(
self.submission_attempted = False
self.error_message = None
self.selected_options = []
self.found_in_search = False

self._init_choices(choices, pointed_at)
self._assign_shortcut_keys()
Expand Down Expand Up @@ -375,9 +379,19 @@ def _init_choices(

self.choices.append(choice)

@property
def filtered_choices(self):
if not self.search_filter:
return self.choices
filtered = [
c for c in self.choices if self.search_filter.lower() in c.title.lower()
]
self.found_in_search = len(filtered) > 0
return filtered if self.found_in_search else self.choices

@property
def choice_count(self) -> int:
return len(self.choices)
return len(self.filtered_choices)

def _get_choice_tokens(self):
tokens = []
Expand Down Expand Up @@ -457,7 +471,7 @@ def append(index: int, choice: 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)

current = self.get_pointed_at()
Expand Down Expand Up @@ -499,7 +513,7 @@ def select_next(self) -> None:
self.pointed_at = (self.pointed_at + 1) % self.choice_count

def get_pointed_at(self) -> Choice:
return self.choices[self.pointed_at]
return self.filtered_choices[self.pointed_at]

def get_selected_values(self) -> List[Choice]:
# get values not labels
Expand All @@ -509,6 +523,39 @@ def get_selected_values(self) -> List[Choice]:
if (not isinstance(c, Separator) and c.value in self.selected_options)
]

def add_search_character(self, char: Keys) -> None:
"""Adds a character to the search filter"""
if char == Keys.Backspace:
self.remove_search_character()
else:
if self.search_filter is None:
self.search_filter = str(char)
else:
self.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.search_filter and len(self.search_filter) > 1:
self.search_filter = self.search_filter[:-1]
else:
self.search_filter = None

def get_search_string_tokens(self):
if self.search_filter is None:
return None

return [
("", "\n"),
("class:question-mark", "/ "),
(
"class:search_success" if self.found_in_search else "class:search_none",
self.search_filter,
),
("class:question-mark", "..."),
]


def build_validator(validate: Any) -> Optional[Validator]:
if validate:
Expand Down Expand Up @@ -563,6 +610,10 @@ def create_inquirer_layout(
)
_fix_unecessary_blank_lines(ps)

@Condition
def has_search_string():
return ic.get_search_string_tokens() is not None

validation_prompt: PromptSession = PromptSession(
bottom_toolbar=lambda: ic.error_message, **kwargs
)
Expand All @@ -572,6 +623,13 @@ def create_inquirer_layout(
[
ps.layout.container,
ConditionalContainer(Window(ic), filter=~IsDone()),
ConditionalContainer(
Window(
height=LayoutDimension.exact(2),
content=FormattedTextControl(ic.get_search_string_tokens),
),
filter=has_search_string & ~IsDone(),
),
ConditionalContainer(
validation_prompt.layout.container,
filter=Condition(lambda: ic.error_message is not None),
Expand Down
Loading

0 comments on commit 07f5c1a

Please sign in to comment.