Source code for InquirerPy.prompts.fuzzy

"""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)


[docs]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