diff --git a/InquirerPy/prompts/fuzzy.py b/InquirerPy/prompts/fuzzy.py
index 2e2226e..4dbc9a1 100644
--- a/InquirerPy/prompts/fuzzy.py
+++ b/InquirerPy/prompts/fuzzy.py
@@ -4,6 +4,7 @@
from typing import 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
@@ -63,12 +64,14 @@ def __init__(
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,
@@ -219,6 +222,7 @@ async def _filter_choices(self, wait_time: float) -> List[Dict[str, Any]]:
self._current_text(),
cast(HAYSTACKS, self.choices),
key="name",
+ scorer=self._scorer,
)
return choices
@@ -287,7 +291,9 @@ class FuzzyPrompt(BaseListPrompt):
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 tot he prompt.
+ 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.
@@ -329,6 +335,8 @@ def __init__(
marker_pl: str = " ",
border: bool = False,
info: bool = True,
+ match_exact: bool = False,
+ exact_symbol: str = " E",
height: Union[str, int] = None,
max_height: Union[str, int] = None,
validate: InquirerPyValidate = None,
@@ -347,11 +355,13 @@ def __init__(
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__(
@@ -376,6 +386,7 @@ def __init__(
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)
@@ -395,6 +406,7 @@ def __init__(
session_result=session_result,
multiselect=multiselect,
marker_pl=marker_pl,
+ match_exact=match_exact,
)
self._buffer = Buffer(on_text_changed=self._on_text_changed)
@@ -470,6 +482,23 @@ def __init__(
after_render=self._after_render,
)
+ def _toggle_exact(self, _, value: 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.
@@ -503,17 +532,15 @@ def _generate_after_input(self) -> List[Tuple[str, str]]:
display_message.append(
(
"class:fuzzy_info",
- "%s/%s"
- % (
- self.content_control.choice_count,
- len(self.content_control.choices),
- ),
+ f"{self.content_control.choice_count}/{len(self.content_control.choices)}",
)
)
if self._multiselect:
display_message.append(
- ("class:fuzzy_info", " (%s)" % len(self.selected_choices))
+ ("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]]:
diff --git a/docs/pages/prompts/fuzzy.md b/docs/pages/prompts/fuzzy.md
index 2dfb242..2959bd9 100644
--- a/docs/pages/prompts/fuzzy.md
+++ b/docs/pages/prompts/fuzzy.md
@@ -88,6 +88,48 @@ choices = [
]
```
+## Exact Sub-String Match
+
+This prompt uses the [fzy](https://github.com/jhawthorn/fzy) fuzzy match algorithm by default. You can enable exact sub-string match
+by using the parameter `match_exact`.
+
+
+ Classic Syntax (PyInquirer)
+
+```{code-block} python
+from InquirerPy import prompt
+
+questions = [
+ {
+ "type": "fuzzy",
+ "message": "Select actions:",
+ "choices": ["hello", "weather", "what", "whoa", "hey", "yo"],
+ "match_exact": True,
+ "exact_symbol": " E", # indicator of exact match
+ },
+]
+
+result = prompt(questions=questions)
+```
+
+
+
+
+ Alternate Syntax
+
+```{code-block} python
+from InquirerPy import inquirer
+
+result = inquirer.fuzzy(
+ message="Select actions:",
+ choices=["hello", "weather", "what", "whoa", "hey", "yo"],
+ match_exact=True,
+ exact_symbol=" E", # indicator of exact match
+).execute()
+```
+
+
+
## Reference
```{eval-rst}
diff --git a/tests/prompts/test_fuzzy.py b/tests/prompts/test_fuzzy.py
index f4526b7..8a19e17 100644
--- a/tests/prompts/test_fuzzy.py
+++ b/tests/prompts/test_fuzzy.py
@@ -3,6 +3,7 @@
from typing import Callable, NamedTuple
from unittest.mock import ANY, MagicMock, call, patch
+from pfzy.score import fzy_scorer, substr_scorer
from prompt_toolkit.application.application import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout.layout import Layout
@@ -27,6 +28,7 @@ class TestFuzzy(unittest.TestCase):
session_result=None,
multiselect=False,
marker_pl=" ",
+ match_exact=False,
)
@patch("InquirerPy.utils.shutil.get_terminal_size")
@@ -179,6 +181,7 @@ def test_prompt_filter1(self):
session_result=None,
multiselect=False,
marker_pl=" ",
+ match_exact=False,
)
self.assertEqual(
content_control._filtered_choices,
@@ -292,6 +295,7 @@ def test_prompt_filter2(self):
session_result=None,
multiselect=False,
marker_pl=" ",
+ match_exact=False,
)
content_control.choices[0]["indices"] = [1, 2, 3]
asyncio.run(content_control._filter_choices(0.0))
@@ -381,6 +385,7 @@ def test_constructor(self, mocked_term, mocked_height, mocked_control):
session_result=None,
multiselect=False,
marker_pl=" ",
+ match_exact=False,
)
prompt = FuzzyPrompt(
@@ -411,6 +416,7 @@ def test_constructor(self, mocked_term, mocked_height, mocked_control):
session_result=None,
multiselect=False,
marker_pl=" ",
+ match_exact=False,
)
def test_prompt_after_input(self):
@@ -428,6 +434,15 @@ def test_prompt_after_input(self):
)
self.assertEqual(prompt._generate_after_input(), [])
+ def test_prompt_after_input2(self):
+ prompt = FuzzyPrompt(
+ message="", choices=["1", "2", "3"], match_exact=True, exact_symbol=" *"
+ )
+ self.assertEqual(
+ prompt._generate_after_input(),
+ [("", " "), ("class:fuzzy_info", "3/3"), ("class:fuzzy_info", " *")],
+ )
+
def test_prompt_before_input(self):
prompt = FuzzyPrompt(
message="Select one of them",
@@ -872,6 +887,7 @@ def test_control_line_render(self) -> None:
session_result=None,
multiselect=True,
marker_pl=" ",
+ match_exact=False,
)
# range 0-4
self.assertEqual(control._last_line, 4)
@@ -895,3 +911,80 @@ def test_control_line_render(self) -> None:
control._get_formatted_choices()
self.assertEqual(control._last_line, 6)
self.assertEqual(control._first_line, 2)
+
+ def test_control_exact_match(self) -> None:
+ content_control = InquirerPyFuzzyControl(
+ choices=["meat", "what", "whaaah", "weather", "haha"],
+ pointer=INQUIRERPY_POINTER_SEQUENCE,
+ marker=INQUIRERPY_POINTER_SEQUENCE,
+ current_text=lambda: "aa",
+ max_lines=80,
+ session_result=None,
+ multiselect=False,
+ marker_pl=" ",
+ match_exact=True,
+ )
+ self.assertEqual(
+ content_control._filtered_choices,
+ [
+ {
+ "enabled": False,
+ "index": 0,
+ "indices": [],
+ "name": "meat",
+ "value": "meat",
+ },
+ {
+ "enabled": False,
+ "index": 1,
+ "indices": [],
+ "name": "what",
+ "value": "what",
+ },
+ {
+ "enabled": False,
+ "index": 2,
+ "indices": [],
+ "name": "whaaah",
+ "value": "whaaah",
+ },
+ {
+ "enabled": False,
+ "index": 3,
+ "indices": [],
+ "name": "weather",
+ "value": "weather",
+ },
+ {
+ "enabled": False,
+ "index": 4,
+ "indices": [],
+ "name": "haha",
+ "value": "haha",
+ },
+ ],
+ )
+ result = asyncio.run(content_control._filter_choices(0.0))
+ self.assertEqual(
+ result,
+ [
+ {
+ "enabled": False,
+ "index": 2,
+ "indices": [2, 3],
+ "name": "whaaah",
+ "value": "whaaah",
+ }
+ ],
+ )
+
+ def test_toggle_exact(self):
+ self.assertEqual(self.prompt.content_control._scorer, fzy_scorer)
+ self.prompt._toggle_exact(None)
+ self.assertEqual(self.prompt.content_control._scorer, substr_scorer)
+ self.prompt._toggle_exact(None)
+ self.assertEqual(self.prompt.content_control._scorer, fzy_scorer)
+ self.prompt._toggle_exact(None, True)
+ self.assertEqual(self.prompt.content_control._scorer, substr_scorer)
+ self.prompt._toggle_exact(None, False)
+ self.assertEqual(self.prompt.content_control._scorer, fzy_scorer)