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

Prefix filter search #42

Closed
wants to merge 5 commits into from
Closed
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
1 change: 1 addition & 0 deletions questionary/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
("qmark", "fg:#5f819d"), # token in front of the question
("question", "bold"), # question text
("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question
("search", "noinherit fg:#FF6600 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
65 changes: 60 additions & 5 deletions questionary/prompts/common.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# -*- coding: utf-8 -*-
import inspect
from prompt_toolkit import PromptSession
from prompt_toolkit.filters import IsDone, Always
from prompt_toolkit.filters import Always, Condition, IsDone
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import (
FormattedTextControl,
Layout,
HSplit,
ConditionalContainer,
Window,
)
from prompt_toolkit.layout.dimension import LayoutDimension as D
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it would be better not to import as D and instead use the full LayoutDimension identifier.

from prompt_toolkit.validation import Validator, ValidationError
from typing import Optional, Any, List, Text, Dict, Union, Callable, Tuple

Expand Down Expand Up @@ -155,6 +157,7 @@ def __init__(
self.is_answered = False
self.choices = []
self.selected_options = []
self.prefix_search_filter = None

self._init_choices(choices)
self._assign_shortcut_keys()
Expand Down Expand Up @@ -208,9 +211,18 @@ def _init_choices(self, choices):

self.choices.append(choice)

@property
def filtered_choices(self):
if not self.prefix_search_filter:
return self.choices
else:
return [
c for c in self.choices if c.title.startswith(self.prefix_search_filter)
]

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

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

if self.use_shortcuts:
Expand Down Expand Up @@ -323,7 +335,7 @@ def select_next(self):
self.pointed_at = (self.pointed_at + 1) % self.choice_count

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

def get_selected_values(self):
# get values not labels
Expand All @@ -333,6 +345,35 @@ def get_selected_values(self):
if (not isinstance(c, Separator) and c.value in self.selected_options)
]

def add_search_character(self, char: Keys) -> None:
if char == Keys.Backspace:
self.remove_search_character()
else:
if self.prefix_search_filter is None:
self.prefix_search_filter = str(char)
else:
self.prefix_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.prefix_search_filter and len(self.prefix_search_filter) > 1:
self.prefix_search_filter = self.prefix_search_filter[:-1]
else:
self.prefix_search_filter = None

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

return [
('', '\n'),
('class:question-mark', '/ '),
('class:search', self.prefix_search_filter),
('class:question-mark', '...'),
]


def build_validator(validate: Any) -> Optional[Validator]:
if validate:
Expand Down Expand Up @@ -385,8 +426,22 @@ def create_inquirer_layout(

_fix_unecessary_blank_lines(ps)

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

return Layout(
HSplit(
[ps.layout.container, ConditionalContainer(Window(ic), filter=~IsDone())]
[
ps.layout.container,
ConditionalContainer(Window(ic), filter=~IsDone()),
ConditionalContainer(
Window(
height=D.exact(2),
content=FormattedTextControl(ic.get_search_string_tokens)
),
filter=has_search_string & ~IsDone() # noqa # pylint:disable=invalid-unary-operand-type
),
]
)
)
24 changes: 21 additions & 3 deletions questionary/prompts/select.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-

import string
from typing import Any, Dict, List, Optional, Text, Union

from prompt_toolkit.application import Application
Expand All @@ -22,6 +22,7 @@ def select(
use_shortcuts: bool = False,
use_indicator: bool = False,
use_pointer: bool = True,
use_prefix_filter_search: bool = False,
instruction: Text = None,
**kwargs: Any
) -> Question:
Expand Down Expand Up @@ -59,6 +60,13 @@ def select(

use_pointer: Flag to enable the pointer in front of the currently
highlighted element.

use_prefix_filter_search: Flag to enable prefix filter. Typing some prefix will
filter the choices to keep only the one that match
the prefix.
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
Returns:
Question: Question instance, ready to be prompted (using `.ask()`).
"""
Expand Down Expand Up @@ -138,19 +146,29 @@ def select_choice(event):
else:

@bindings.add(Keys.Down, eager=True)
@bindings.add("j", eager=True)
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):
ic.select_previous()
while not ic.is_selection_valid():
ic.select_previous()

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

for character in string.printable:
bindings.add(character, eager=True)(search_filter)
bindings.add(Keys.Backspace, eager=True)(search_filter)
else:
# Enable vi-like navigation
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):
ic.is_answered = True
Expand Down
57 changes: 57 additions & 0 deletions tests/prompts/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,60 @@ def test_select_empty_choices():

with pytest.raises(ValueError):
feed_cli_with_input("select", message, text, **kwargs)


def test_filter_prefix_one_letter():
message = "Foo message"
kwargs = {"choices": ["abc", "def", "ghi", "jkl"]}
text = "g" + KeyInputs.ENTER + "\r"

result, cli = feed_cli_with_input(
"select", message, text, use_prefix_filter_search=True, **kwargs
)
assert result == "ghi"


def test_filter_prefix_multiple_letters():
message = "Foo message"
kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
text = "j" + "j" + KeyInputs.ENTER + "\r"

result, cli = feed_cli_with_input(
"select", message, text, use_prefix_filter_search=True, **kwargs
)
assert result == "jja"


def test_select_filter_handle_backspace():
message = "Foo message"
kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
text = "j" + "j" + KeyInputs.BACK + KeyInputs.ENTER + "\r"

result, cli = feed_cli_with_input(
"select", message, text, use_prefix_filter_search=True, **kwargs
)
assert result == "jkl"

message = "Foo message"
kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
text = (
"j" + "j" +
KeyInputs.BACK + KeyInputs.BACK + KeyInputs.BACK + KeyInputs.BACK +
KeyInputs.ENTER + "\r"
)

result, cli = feed_cli_with_input(
"select", message, text, use_prefix_filter_search=True, **kwargs
)
assert result == "abc"


def test_select_goes_back_to_top_after_filtering():
message = "Foo message"
kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
text = KeyInputs.DOWN + KeyInputs.DOWN + "j" + KeyInputs.ENTER + "\r"

result, cli = feed_cli_with_input(
"select", message, text, use_prefix_filter_search=True, **kwargs
)
assert result == "jkl"
2 changes: 1 addition & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def feed_cli_with_input(_type, message, texts, sleep_time=1, **kwargs):
Create a Prompt, feed it with the given user input and return the CLI
object.

You an provide multiple texts, the feeder will async sleep for `sleep_time`
You can provide multiple texts, the feeder will async sleep for `sleep_time`

This returns a (result, Application) tuple.
"""
Expand Down