mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #2604 from Textualize/input-auto-completion
Input completion suggestions
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
23
tests/snapshot_tests/snapshot_apps/input_suggestions.py
Normal file
23
tests/snapshot_tests/snapshot_apps/input_suggestions.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
101
tests/suggester/test_input_suggestions.py
Normal file
101
tests/suggester/test_input_suggestions.py
Normal 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"
|
||||
55
tests/suggester/test_suggest_from_list.py
Normal file
55
tests/suggester/test_suggest_from_list.py
Normal 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)]
|
||||
111
tests/suggester/test_suggester.py
Normal file
111
tests/suggester/test_suggester.py
Normal 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
|
||||
Reference in New Issue
Block a user