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

feat: add search functionality to select and checkbox prompt, based on #42 #374

Merged
merged 6 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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, "
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are we happy to say that space can't be used in the search?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We have to exclude it, since space is used to select an entry from the list. Otherwise we would have to come up with a rather complex solution to allow (de-)selection of an entry when the search is enabled.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Makes sense

"<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 @@ -204,6 +206,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 @@ -275,6 +278,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 @@ -343,9 +347,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()
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm ok with this search mechanism, but it is not a prefix search like #42. Does this matter? Are there any downsides to searching for any occurrence of the substring? I suppose in the future, we can make the filter customisable, so it doesn't matter too much...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After using the prefix search for a few days it felt unnatural and not intuitive. I personally don't know any application that prefers a prefix search above a substring search.

A fuzzy search would probably be the best, but add unnecessary complexity and/or dependencies to this library.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for explaining

]
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 @@ -425,7 +439,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 @@ -467,7 +481,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 @@ -477,6 +491,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 @@ -531,6 +578,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 @@ -540,6 +591,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
Loading