radubulimac's picture
fix import issue
2d876d1
"""Module contains the class to create a fuzzy prompt."""
import asyncio
import math
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
cast,
)
from pfzy import fuzzy_match
from pfzy.score import fzy_scorer, substr_scorer
from pfzy.types import HAYSTACKS
from prompt_toolkit.application.application import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.filters.cli import IsDone
from prompt_toolkit.layout.containers import (
ConditionalContainer,
FloatContainer,
HSplit,
Window,
)
from prompt_toolkit.layout.controls import BufferControl, DummyControl
from prompt_toolkit.layout.dimension import Dimension, LayoutDimension
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.layout.processors import AfterInput, BeforeInput
from prompt_toolkit.lexers.base import SimpleLexer
from prompt_toolkit.validation import ValidationError
from prompt_toolkit.widgets.base import Frame
from InquirerPy.base import FakeDocument, InquirerPyUIListControl
from InquirerPy.base.list import BaseListPrompt
from InquirerPy.containers.instruction import InstructionWindow
from InquirerPy.containers.message import MessageWindow
from InquirerPy.containers.validation import ValidationFloat
from InquirerPy.enum import INQUIRERPY_POINTER_SEQUENCE
from InquirerPy.exceptions import InvalidArgument
from InquirerPy.separator import Separator
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyListChoices,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
calculate_height,
)
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
__all__ = ["FuzzyPrompt"]
class InquirerPyFuzzyControl(InquirerPyUIListControl):
"""An :class:`~prompt_toolkit.layout.UIControl` class that displays a list of choices.
This only displays the chocies. The actual input buffer will be handled by a separate
:class:`~prompt_toolkit.layout.BufferControl`.
Reference the parameter definition in :class:`.FuzzyPrompt`.
"""
def __init__(
self,
choices: InquirerPyListChoices,
pointer: str,
marker: str,
current_text: Callable[[], str],
max_lines: int,
session_result: Optional[InquirerPySessionResult],
multiselect: bool,
marker_pl: str,
match_exact: bool,
) -> None:
self._pointer = pointer
self._marker = marker
self._marker_pl = marker_pl
self._current_text = current_text
self._max_lines = max_lines if max_lines > 0 else 1
self._scorer = fzy_scorer if not match_exact else substr_scorer
super().__init__(
choices=choices,
default=None,
session_result=session_result,
multiselect=multiselect,
)
def _format_choices(self) -> None:
for index, choice in enumerate(self.choices):
if isinstance(choice["value"], Separator):
raise InvalidArgument(
"fuzzy prompt argument choices should not contain Separator"
)
choice["index"] = index
choice["indices"] = []
self._filtered_choices = self.choices
self._first_line = 0
self._last_line = min(self._max_lines, self.choice_count)
self._height = self._last_line - self._first_line
def _get_hover_text(self, choice) -> List[Tuple[str, str]]:
"""Get the current highlighted line of text.
If in the middle of filtering, loop through the char and color
indices matched char into style class `class:fuzzy_match`.
Returns:
FormattedText in list of tuple format.
"""
display_choices = []
display_choices.append(("class:pointer", self._pointer))
display_choices.append(
(
"class:marker",
self._marker
if self.choices[choice["index"]]["enabled"]
else self._marker_pl,
)
)
display_choices.append(("[SetCursorPosition]", ""))
if not choice["indices"]:
display_choices.append(("class:pointer", choice["name"]))
else:
indices = set(choice["indices"])
for index, char in enumerate(choice["name"]):
if index in indices:
display_choices.append(("class:fuzzy_match", char))
else:
display_choices.append(("class:pointer", char))
return display_choices
def _get_normal_text(self, choice) -> List[Tuple[str, str]]:
"""Get the line of text in `FormattedText`.
If in the middle of filtering, loop through the char and color
indices matched char into `class:fuzzy_match`.
Calculate spaces of pointer to make the choice equally align.
Returns:
FormattedText in list of tuple format.
"""
display_choices = []
display_choices.append(("class:pointer", len(self._pointer) * " "))
display_choices.append(
(
"class:marker",
self._marker
if self.choices[choice["index"]]["enabled"]
else self._marker_pl,
)
)
if not choice["indices"]:
display_choices.append(("", choice["name"]))
else:
indices = set(choice["indices"])
for index, char in enumerate(choice["name"]):
if index in indices:
display_choices.append(("class:fuzzy_match", char))
else:
display_choices.append(("", char))
return display_choices
def _get_formatted_choices(self) -> List[Tuple[str, str]]:
"""Get all available choices in formatted text format.
Overriding this method because `self.choice` will be the
full choice list. Using `self.filtered_choice` to get
a list of choice based on current_text.
Returns:
FormattedText in list of tuple format.
"""
display_choices = []
if self.choice_count == 0:
self._selected_choice_index = 0
return display_choices
if self._selected_choice_index < 0:
self._selected_choice_index = 0
elif self._selected_choice_index >= self.choice_count:
self._selected_choice_index = self.choice_count - 1
if (self._last_line - self._first_line) < min(self.choice_count, self._height):
self._last_line = min(self.choice_count, self._height)
self._first_line = self._last_line - min(self.choice_count, self._height)
if self._selected_choice_index <= self._first_line:
self._first_line = self._selected_choice_index
self._last_line = self._first_line + min(self._height, self.choice_count)
elif self._selected_choice_index >= self._last_line:
self._last_line = self._selected_choice_index + 1
self._first_line = self._last_line - min(self._height, self.choice_count)
if self._last_line > self.choice_count:
self._last_line = self.choice_count
self._first_line = self._last_line - min(self._height, self.choice_count)
if self._first_line < 0:
self._first_line = 0
self._last_line = self._first_line + min(self._height, self.choice_count)
for index in range(self._first_line, self._last_line):
if index == self.selected_choice_index:
display_choices += self._get_hover_text(self._filtered_choices[index])
else:
display_choices += self._get_normal_text(self._filtered_choices[index])
display_choices.append(("", "\n"))
if display_choices:
display_choices.pop()
return display_choices
async def _filter_choices(self, wait_time: float) -> List[Dict[str, Any]]:
"""Call to filter choices using fzy fuzzy match.
Args:
wait_time: Additional time to wait before filtering the choice.
Returns:
Filtered choices.
"""
if not self._current_text():
for choice in self.choices:
choice["indices"] = []
choices = self.choices
else:
await asyncio.sleep(wait_time)
choices = await fuzzy_match(
self._current_text(),
cast(HAYSTACKS, self.choices),
key="name",
scorer=self._scorer,
)
return choices
@property
def selection(self) -> Dict[str, Any]:
"""Override this value since `self.choice` does not indicate the choice displayed.
`self.filtered_choice` is the up to date choice displayed.
Returns:
A dictionary of name and value for the current pointed choice.
"""
return self._filtered_choices[self.selected_choice_index]
@property
def choice_count(self) -> int:
"""int: Filtered choice count."""
return len(self._filtered_choices)
class FuzzyPrompt(BaseListPrompt):
"""Create a prompt that lists choices while also allowing fuzzy search like fzf.
A wrapper class around :class:`~prompt_toolkit.application.Application`.
Fuzzy search using :func:`pfzy.match.fuzzy_match` function.
Override the default keybindings for up/down as j/k cannot be bind even if `editing_mode` is vim
due to the input buffer.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
choices: List of choices to display and select.
Refer to :ref:`pages/dynamic:choices` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default value in the search buffer.
Different than other list type prompts, the `default` parameter tries to replicate what fzf does and
add the value in `default` to search buffer so it starts searching immediatelly.
Refer to :ref:`pages/dynamic:default` documentation for more details.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
pointer: Pointer symbol. Customer symbol that will be used to indicate the current choice selection.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
The main use case for this prompt would be when `multiselect` is True, you can enforce a min/max selection.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
height: Preferred height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
max_height: Max height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
multiselect: Enable multi-selection on choices.
You can use `validate` parameter to control min/max selections.
Setting to True will also change the result from a single value to a list of values.
prompt: Input prompt symbol. Custom symbol to display infront of the input buffer to indicate for input.
border: Create border around the choice window.
info: Display choice information similar to fzf --info=inline next to the prompt.
match_exact: Use exact sub-string match instead of using fzy fuzzy match algorithm.
exact_symbol: Custom symbol to display in the info section when `info=True`.
marker: Marker Symbol. Custom symbol to indicate if a choice is selected.
This will take effects when `multiselect` is True.
marker_pl: Marker place holder when the choice is not selected.
This is empty space by default.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
cycle: Return to top item if hit bottom during navigation or vice versa.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.fuzzy(message="Select one:", choices=[1, 2, 3]).execute()
>>> print(result)
1
"""
def __init__(
self,
message: InquirerPyMessage,
choices: InquirerPyListChoices,
default: InquirerPyDefault = "",
pointer: str = INQUIRERPY_POINTER_SEQUENCE,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
instruction: str = "",
long_instruction: str = "",
multiselect: bool = False,
prompt: str = INQUIRERPY_POINTER_SEQUENCE,
marker: str = INQUIRERPY_POINTER_SEQUENCE,
marker_pl: str = " ",
border: bool = False,
info: bool = True,
match_exact: bool = False,
exact_symbol: str = " E",
height: Optional[Union[str, int]] = None,
max_height: Optional[Union[str, int]] = None,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
keybindings: Optional[InquirerPyKeybindings] = None,
cycle: bool = True,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
if not keybindings:
keybindings = {}
self._prompt = prompt
self._info = info
self._task = None
self._rendered = False
self._exact_symbol = exact_symbol
keybindings = {
"up": [{"key": "up"}, {"key": "c-p"}],
"down": [{"key": "down"}, {"key": "c-n"}],
"toggle": [],
"toggle-exact": [],
**keybindings,
}
super().__init__(
message=message,
style=style,
border=border,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
transformer=transformer,
filter=filter,
validate=validate,
invalid_message=invalid_message,
multiselect=multiselect,
instruction=instruction,
long_instruction=long_instruction,
keybindings=keybindings,
cycle=cycle,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
self.kb_func_lookup = {"toggle-exact": [{"func": self._toggle_exact}]}
self._default = (
default
if not isinstance(default, Callable)
else cast(Callable, default)(self._result)
)
self._height_offset += 1 # search input
self._dimmension_height, self._dimmension_max_height = calculate_height(
height, max_height, height_offset=self.height_offset
)
self._content_control: InquirerPyFuzzyControl = InquirerPyFuzzyControl(
choices=choices,
pointer=pointer,
marker=marker,
current_text=self._get_current_text,
max_lines=self._dimmension_max_height,
session_result=session_result,
multiselect=multiselect,
marker_pl=marker_pl,
match_exact=match_exact,
)
self._buffer = Buffer(on_text_changed=self._on_text_changed)
input_window = Window(
height=LayoutDimension.exact(1),
content=BufferControl(
self._buffer,
[
AfterInput(self._generate_after_input),
BeforeInput(self._generate_before_input),
],
lexer=SimpleLexer("class:input"),
),
)
choice_height_dimmension = lambda: Dimension(
max=self._dimmension_max_height,
preferred=self._dimmension_height,
min=self.content_control._height if self.content_control._height > 0 else 1,
)
self.choice_window = Window(
content=self.content_control,
height=choice_height_dimmension,
dont_extend_height=True,
)
main_content_window = HSplit([input_window, self.choice_window])
if self._border:
main_content_window = Frame(main_content_window)
self._layout = Layout(
FloatContainer(
content=HSplit(
[
MessageWindow(
message=self._get_prompt_message,
filter=True,
wrap_lines=self._wrap_lines,
show_cursor=True,
),
ConditionalContainer(
main_content_window,
filter=~IsDone(),
),
ConditionalContainer(
Window(content=DummyControl()),
filter=~IsDone() & self._is_displaying_long_instruction,
),
InstructionWindow(
message=self._long_instruction,
filter=~IsDone() & self._is_displaying_long_instruction,
wrap_lines=self._wrap_lines,
),
],
),
floats=[
ValidationFloat(
invalid_message=self._get_error_message,
filter=self._is_invalid & ~IsDone(),
wrap_lines=self._wrap_lines,
left=0,
bottom=self._validation_window_bottom_offset,
),
],
)
)
self._layout.focus(input_window)
self._application = Application(
layout=self._layout,
style=self._style,
key_bindings=self._kb,
editing_mode=self._editing_mode,
after_render=self._after_render,
)
def _toggle_exact(self, _, value: Optional[bool] = None) -> None:
"""Toggle matching algorithm.
Switch between fzy fuzzy match or sub-string exact match.
Args:
value: Specify the value to toggle.
"""
if value is not None:
self.content_control._scorer = fzy_scorer if not value else substr_scorer
else:
self.content_control._scorer = (
fzy_scorer
if self.content_control._scorer == substr_scorer
else substr_scorer
)
def _on_rendered(self, _) -> None:
"""Render callable choices and set the buffer default text.
Setting buffer default text has to be after application is rendered and choice are loaded,
because `self._filter_choices` will use the event loop from `Application`.
"""
if self._default:
default_text = str(self._default)
self._buffer.text = default_text
self._buffer.cursor_position = len(default_text)
def _handle_toggle_all(self, _, value: Optional[bool] = None) -> None:
"""Toggle all choice `enabled` status.
Args:
value: Specify the value to toggle.
"""
if not self._multiselect:
return
for choice in self.content_control._filtered_choices:
raw_choice = self.content_control.choices[choice["index"]]
if isinstance(raw_choice["value"], Separator):
continue
raw_choice["enabled"] = value if value else not raw_choice["enabled"]
def _generate_after_input(self) -> List[Tuple[str, str]]:
"""Virtual text displayed after the user input."""
display_message = []
if self._info:
display_message.append(("", " "))
display_message.append(
(
"class:fuzzy_info",
f"{self.content_control.choice_count}/{len(self.content_control.choices)}",
)
)
if self._multiselect:
display_message.append(
("class:fuzzy_info", f" ({len(self.selected_choices)})")
)
if self.content_control._scorer == substr_scorer:
display_message.append(("class:fuzzy_info", self._exact_symbol))
return display_message
def _generate_before_input(self) -> List[Tuple[str, str]]:
"""Display prompt symbol as virtual text before user input."""
display_message = []
display_message.append(("class:fuzzy_prompt", "%s " % self._prompt))
return display_message
def _filter_callback(self, task):
"""Redraw `self._application` when the filter task is finished."""
if task.cancelled():
return
self.content_control._filtered_choices = task.result()
self._application.invalidate()
def _calculate_wait_time(self) -> float:
"""Calculate wait time to smoother the application on big data set.
Using digit of the choices lengeth to get wait time.
For digit greater than 6, using formula 2^(digit - 5) * 0.3 to increase the wait_time.
Returns:
Desired wait time before running the filter.
"""
wait_table = {
2: 0.05,
3: 0.1,
4: 0.2,
5: 0.3,
}
digit = 1
if len(self.content_control.choices) > 0:
digit = int(math.log10(len(self.content_control.choices))) + 1
if digit < 2:
return 0.0
if digit in wait_table:
return wait_table[digit]
return wait_table[5] * (2 ** (digit - 5))
def _on_text_changed(self, _) -> None:
"""Handle buffer text change event.
1. Check if there is current task running.
2. Cancel if already has task, increase wait_time
3. Create a filtered_choice task in asyncio event loop
4. Add callback
1. Run a new filter on all choices.
2. Re-calculate current selected_choice_index
if it exceeds the total filtered_choice.
3. Avoid selected_choice_index less than zero,
this fix the issue of cursor lose when:
choice -> empty choice -> choice
Don't need to create or check asyncio event loop, `prompt_toolkit`
application already has a event loop running.
"""
if self._invalid:
self._invalid = False
wait_time = self._calculate_wait_time()
if self._task and not self._task.done():
self._task.cancel()
self._task = asyncio.create_task(
self.content_control._filter_choices(wait_time)
)
self._task.add_done_callback(self._filter_callback)
def _handle_toggle_choice(self, _) -> None:
"""Handle tab event, alter the `selected` state of the choice."""
if not self._multiselect:
return
current_selected_index = self.content_control.selection["index"]
self.content_control.choices[current_selected_index][
"enabled"
] = not self.content_control.choices[current_selected_index]["enabled"]
def _handle_enter(self, event: "KeyPressEvent") -> None:
"""Handle enter event.
Validate the result first.
In multiselect scenario, if no TAB is entered, then capture the current
highlighted choice and return the value in a list.
Otherwise, return all TAB choices as a list.
In normal scenario, reutrn the current highlighted choice.
If current UI contains no choice due to filter, return None.
"""
try:
fake_document = FakeDocument(self.result_value)
self._validator.validate(fake_document) # type: ignore
if self._multiselect:
self.status["answered"] = True
if not self.selected_choices:
self.status["result"] = [self.content_control.selection["name"]]
event.app.exit(result=[self.content_control.selection["value"]])
else:
self.status["result"] = self.result_name
event.app.exit(result=self.result_value)
else:
self.status["answered"] = True
self.status["result"] = self.content_control.selection["name"]
event.app.exit(result=self.content_control.selection["value"])
except ValidationError as e:
self._set_error(str(e))
except IndexError:
self.status["answered"] = True
self.status["result"] = None if not self._multiselect else []
event.app.exit(result=None if not self._multiselect else [])
@property
def content_control(self) -> InquirerPyFuzzyControl:
"""InquirerPyFuzzyControl: Override for type-hinting."""
return cast(InquirerPyFuzzyControl, super().content_control)
@content_control.setter
def content_control(self, value: InquirerPyFuzzyControl) -> None:
self._content_control = value
def _get_current_text(self) -> str:
"""Get current input buffer text."""
return self._buffer.text