Merge pull request #2604 from Textualize/input-auto-completion

Input completion suggestions
This commit is contained in:
Rodrigo Girão Serrão
2023-05-29 14:48:40 +01:00
committed by GitHub
11 changed files with 849 additions and 194 deletions

File diff suppressed because one or more lines are too long

View File

@@ -40,7 +40,7 @@ def snap_compare(
def compare(
app_path: str | PurePath,
press: Iterable[str] = ("_",),
press: Iterable[str] = (),
terminal_size: tuple[int, int] = (80, 24),
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
) -> bool:

View File

@@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual.suggester import SuggestFromList
from textual.widgets import Input
fruits = ["apple", "pear", "mango", "peach", "strawberry", "blueberry", "banana"]
class FruitsApp(App[None]):
CSS = """
Input > .input--suggestion {
color: red;
text-style: italic;
}
"""
def compose(self) -> ComposeResult:
yield Input("straw", suggester=SuggestFromList(fruits))
if __name__ == "__main__":
app = FruitsApp()
app.run()

View File

@@ -97,6 +97,10 @@ def test_input_validation(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press)
def test_input_suggestions(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[])
def test_buttons_render(snap_compare):
# Testing button rendering. We press tab to focus the first button too.
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
@@ -245,15 +249,19 @@ def test_progress_bar_completed_styled(snap_compare):
def test_select(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
def test_selection_list_selected(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py")
def test_selection_list_selections(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py")
def test_selection_list_tuples(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py")
def test_select_expanded(snap_compare):
assert snap_compare(
WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"]
@@ -482,13 +490,17 @@ def test_dock_scroll2(snap_compare):
def test_dock_scroll_off_by_one(snap_compare):
# https://github.com/Textualize/textual/issues/2525
assert snap_compare(
SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py", terminal_size=(80, 25)
SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py",
terminal_size=(80, 25),
press=["_"],
)
def test_scroll_to(snap_compare):
# https://github.com/Textualize/textual/issues/2525
assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25))
assert snap_compare(
SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25), press=["_"]
)
def test_auto_fr(snap_compare):

View File

@@ -0,0 +1,101 @@
import string
import pytest
from textual.app import App, ComposeResult
from textual.suggester import SuggestFromList
from textual.widgets import Input
class SuggestionsApp(App[ComposeResult]):
def __init__(self, suggestions):
self.suggestions = suggestions
self.input = Input(suggester=SuggestFromList(self.suggestions))
super().__init__()
def compose(self) -> ComposeResult:
yield self.input
async def test_no_suggestions():
app = SuggestionsApp([])
async with app.run_test() as pilot:
assert app.input._suggestion == ""
await pilot.press("a")
assert app.input._suggestion == ""
async def test_suggestion():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
for char in "hello":
await pilot.press(char)
assert app.input._suggestion == "hello"
async def test_accept_suggestion():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press("h")
await pilot.press("right")
assert app.input.value == "hello"
async def test_no_suggestion_on_empty_value():
app = SuggestionsApp(["hello"])
async with app.run_test():
assert app.input._suggestion == ""
async def test_no_suggestion_on_empty_value_after_deleting():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press("h", "e", "backspace", "backspace")
assert app.input.value == "" # Sanity check.
assert app.input._suggestion == ""
async def test_suggestion_shows_up_after_deleting_extra_chars():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press(*"help")
assert app.input._suggestion == ""
await pilot.press("backspace")
assert app.input._suggestion == "hello"
async def test_suggestion_shows_up_after_deleting_extra_chars_in_middle_of_word():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press(*"hefl")
assert app.input._suggestion == ""
await pilot.press("left", "backspace")
assert app.input._suggestion == "hello"
@pytest.mark.parametrize(
("suggestion", "truncate_at"),
[
(".......", 3),
("hey there", 3),
("Olá, tudo bem?", 3),
("áàóãõñç", 2),
(string.punctuation, 3),
(string.punctuation[::-1], 5),
(string.punctuation[::3], 5),
],
)
async def test_suggestion_with_special_characters(suggestion: str, truncate_at: int):
app = SuggestionsApp([suggestion])
async with app.run_test() as pilot:
await pilot.press(*suggestion[:truncate_at])
assert app.input._suggestion == suggestion
async def test_suggestion_priority():
app = SuggestionsApp(["dog", "dad"])
async with app.run_test() as pilot:
await pilot.press("d")
assert app.input._suggestion == "dog"
await pilot.press("a")
assert app.input._suggestion == "dad"

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import pytest
from textual.dom import DOMNode
from textual.suggester import SuggestFromList, SuggestionReady
countries = ["England", "Portugal", "Scotland", "portugal", "PORTUGAL"]
class LogListNode(DOMNode):
def __init__(self, log_list: list[tuple[str, str]]) -> None:
self.log_list = log_list
def post_message(self, message: SuggestionReady):
# We hijack post_message so we can intercept messages without creating a full app.
self.log_list.append((message.suggestion, message.value))
async def test_first_suggestion_has_priority():
suggester = SuggestFromList(countries)
assert "Portugal" == await suggester.get_suggestion("P")
@pytest.mark.parametrize("value", ["s", "S", "sc", "sC", "Sc", "SC"])
async def test_case_insensitive_suggestions(value):
suggester = SuggestFromList(countries, case_sensitive=False)
log = []
await suggester._get_suggestion(LogListNode(log), value)
assert log == [("Scotland", value)]
@pytest.mark.parametrize(
"value",
[
"p",
"P",
"po",
"Po",
"pO",
"PO",
"port",
"Port",
"pORT",
"PORT",
],
)
async def test_first_suggestion_has_priority_case_insensitive(value):
suggester = SuggestFromList(countries, case_sensitive=False)
log = []
await suggester._get_suggestion(LogListNode(log), value)
assert log == [("Portugal", value)]

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
import pytest
from textual.dom import DOMNode
from textual.suggester import Suggester, SuggestionReady
class FillSuggester(Suggester):
async def get_suggestion(self, value: str):
if len(value) <= 10:
return f"{value:x<10}"
class LogListNode(DOMNode):
def __init__(self, log_list: list[tuple[str, str]]) -> None:
self.log_list = log_list
def post_message(self, message: SuggestionReady):
# We hijack post_message so we can intercept messages without creating a full app.
self.log_list.append((message.suggestion, message.value))
async def test_cache_on():
log = []
class MySuggester(Suggester):
async def get_suggestion(self, value: str):
log.append(value)
return value
suggester = MySuggester(use_cache=True)
await suggester._get_suggestion(DOMNode(), "hello")
assert log == ["hello"]
await suggester._get_suggestion(DOMNode(), "hello")
assert log == ["hello"]
async def test_cache_off():
log = []
class MySuggester(Suggester):
async def get_suggestion(self, value: str):
log.append(value)
return value
suggester = MySuggester(use_cache=False)
await suggester._get_suggestion(DOMNode(), "hello")
assert log == ["hello"]
await suggester._get_suggestion(DOMNode(), "hello")
assert log == ["hello", "hello"]
async def test_suggestion_ready_message():
log = []
suggester = FillSuggester()
await suggester._get_suggestion(LogListNode(log), "hello")
assert log == [("helloxxxxx", "hello")]
await suggester._get_suggestion(LogListNode(log), "world")
assert log == [("helloxxxxx", "hello"), ("worldxxxxx", "world")]
async def test_no_message_if_no_suggestion():
log = []
suggester = FillSuggester()
await suggester._get_suggestion(LogListNode(log), "this is a longer string")
assert log == []
async def test_suggestion_ready_message_on_cache_hit():
log = []
suggester = FillSuggester(use_cache=True)
await suggester._get_suggestion(LogListNode(log), "hello")
assert log == [("helloxxxxx", "hello")]
await suggester._get_suggestion(LogListNode(log), "hello")
assert log == [("helloxxxxx", "hello"), ("helloxxxxx", "hello")]
@pytest.mark.parametrize(
"value",
[
"hello",
"HELLO",
"HeLlO",
"Hello",
"hELLO",
],
)
async def test_case_insensitive_suggestions(value):
class MySuggester(Suggester):
async def get_suggestion(self, value: str):
assert "hello" == value
suggester = MySuggester(use_cache=False, case_sensitive=False)
await suggester._get_suggestion(DOMNode(), value)
async def test_case_insensitive_cache_hits():
count = 0
class MySuggester(Suggester):
async def get_suggestion(self, value: str):
nonlocal count
count += 1
return value + "abc"
suggester = MySuggester(use_cache=True, case_sensitive=False)
hellos = ["hello", "HELLO", "HeLlO", "Hello", "hELLO"]
for hello in hellos:
await suggester._get_suggestion(DOMNode(), hello)
assert count == 1