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

View File

@@ -8,3 +8,4 @@ exclude_lines =
if __name__ == "__main__": if __name__ == "__main__":
@overload @overload
__rich_repr__ __rich_repr__
@abstractmethod

View File

@@ -50,6 +50,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Message.control` is now a property instead of a class variable. https://github.com/Textualize/textual/issues/2528 - `Message.control` is now a property instead of a class variable. https://github.com/Textualize/textual/issues/2528
- `Tree` and `DirectoryTree` Messages no longer accept a `tree` parameter, using `self.node.tree` instead. https://github.com/Textualize/textual/issues/2529 - `Tree` and `DirectoryTree` Messages no longer accept a `tree` parameter, using `self.node.tree` instead. https://github.com/Textualize/textual/issues/2529
## Unreleased
### Added
- `Suggester` API to compose with widgets for automatic suggestions https://github.com/Textualize/textual/issues/2330
- `SuggestFromList` class to let widgets get completions from a fixed set of options https://github.com/Textualize/textual/pull/2604
- `Input` has a new component class `input--suggestion` https://github.com/Textualize/textual/pull/2604
### Changed
- Keybinding <kbd>right</kbd> in `Input` is also used to accept a suggestion if the cursor is at the end of the input https://github.com/Textualize/textual/pull/2604
- `Input.__init__` now accepts a `suggester` attribute for completion suggestions https://github.com/Textualize/textual/pull/2604
## [0.25.0] - 2023-05-17 ## [0.25.0] - 2023-05-17
### Changed ### Changed

138
src/textual/suggester.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Iterable
from ._cache import LRUCache
from .dom import DOMNode
from .message import Message
@dataclass
class SuggestionReady(Message):
"""Sent when a completion suggestion is ready."""
value: str
"""The value to which the suggestion is for."""
suggestion: str
"""The string suggestion."""
class Suggester(ABC):
"""Defines how widgets generate completion suggestions.
To define a custom suggester, subclass `Suggester` and implement the async method
`get_suggestion`.
See [`SuggestFromList`][textual.suggester.SuggestFromList] for an example.
"""
cache: LRUCache[str, str | None] | None
"""Suggestion cache, if used."""
def __init__(self, *, use_cache: bool = True, case_sensitive: bool = False) -> None:
"""Create a suggester object.
Args:
use_cache: Whether to cache suggestion results.
case_sensitive: Whether suggestions are case sensitive or not.
If they are not, incoming values are casefolded before generating
the suggestion.
"""
self.cache = LRUCache(1024) if use_cache else None
self.case_sensitive = case_sensitive
async def _get_suggestion(self, requester: DOMNode, value: str) -> None:
"""Used by widgets to get completion suggestions.
Note:
When implementing custom suggesters, this method does not need to be
overridden.
Args:
requester: The message target that requested a suggestion.
value: The current value to complete.
"""
normalized_value = value if self.case_sensitive else value.casefold()
if self.cache is None or normalized_value not in self.cache:
suggestion = await self.get_suggestion(normalized_value)
if self.cache is not None:
self.cache[normalized_value] = suggestion
else:
suggestion = self.cache[normalized_value]
if suggestion is None:
return
requester.post_message(SuggestionReady(value, suggestion))
@abstractmethod
async def get_suggestion(self, value: str) -> str | None:
"""Try to get a completion suggestion for the given input value.
Custom suggesters should implement this method.
Note:
The value argument will be casefolded if `self.case_sensitive` is `False`.
Note:
If your implementation is not deterministic, you may need to disable caching.
Args:
value: The current value of the requester widget.
Returns:
A valid suggestion or `None`.
"""
pass
class SuggestFromList(Suggester):
"""Give completion suggestions based on a fixed list of options.
Example:
```py
countries = ["England", "Scotland", "Portugal", "Spain", "France"]
class MyApp(App[None]):
def compose(self) -> ComposeResult:
yield Input(suggester=SuggestFromList(countries, case_sensitive=False))
```
If the user types ++p++ inside the input widget, a completion suggestion
for `"Portugal"` appears.
"""
def __init__(
self, suggestions: Iterable[str], *, case_sensitive: bool = True
) -> None:
"""Creates a suggester based off of a given iterable of possibilities.
Args:
suggestions: Valid suggestions sorted by decreasing priority.
case_sensitive: Whether suggestions are computed in a case sensitive manner
or not. The values provided in the argument `suggestions` represent the
canonical representation of the completions and they will be suggested
with that same casing.
"""
super().__init__(case_sensitive=case_sensitive)
self._suggestions = list(suggestions)
self._for_comparison = (
self._suggestions
if self.case_sensitive
else [suggestion.casefold() for suggestion in self._suggestions]
)
async def get_suggestion(self, value: str) -> str | None:
"""Gets a completion from the given possibilities.
Args:
value: The current value.
Returns:
A valid completion suggestion or `None`.
"""
for idx, suggestion in enumerate(self._for_comparison):
if suggestion.startswith(value):
return self._suggestions[idx]
return None

View File

@@ -17,7 +17,8 @@ from ..events import Blur, Focus, Mount
from ..geometry import Size from ..geometry import Size
from ..message import Message from ..message import Message
from ..reactive import reactive from ..reactive import reactive
from ..validation import Failure, ValidationResult, Validator from ..suggester import Suggester, SuggestionReady
from ..validation import ValidationResult, Validator
from ..widget import Widget from ..widget import Widget
@@ -33,13 +34,26 @@ class _InputRenderable:
) -> "RenderResult": ) -> "RenderResult":
input = self.input input = self.input
result = input._value result = input._value
if input._cursor_at_end: width = input.content_size.width
# Add the completion with a faded style.
value = input.value
value_length = len(value)
suggestion = input._suggestion
show_suggestion = len(suggestion) > value_length
if show_suggestion:
result += Text(
suggestion[value_length:],
input.get_component_rich_style("input--suggestion"),
)
if self.cursor_visible and input.has_focus:
if not show_suggestion and input._cursor_at_end:
result.pad_right(1) result.pad_right(1)
cursor_style = input.get_component_rich_style("input--cursor") cursor_style = input.get_component_rich_style("input--cursor")
if self.cursor_visible and input.has_focus:
cursor = input.cursor_position cursor = input.cursor_position
result.stylize(cursor_style, cursor, cursor + 1) result.stylize(cursor_style, cursor, cursor + 1)
width = input.content_size.width
segments = list(result.render(console)) segments = list(result.render(console))
line_length = Segment.get_line_length(segments) line_length = Segment.get_line_length(segments)
if line_length < width: if line_length < width:
@@ -82,7 +96,7 @@ class Input(Widget, can_focus=True):
| :- | :- | | :- | :- |
| left | Move the cursor left. | | left | Move the cursor left. |
| ctrl+left | Move the cursor one word to the left. | | ctrl+left | Move the cursor one word to the left. |
| right | Move the cursor right. | | right | Move the cursor right or accept the completion suggestion. |
| ctrl+right | Move the cursor one word to the right. | | ctrl+right | Move the cursor one word to the right. |
| backspace | Delete the character to the left of the cursor. | | backspace | Delete the character to the left of the cursor. |
| home,ctrl+a | Go to the beginning of the input. | | home,ctrl+a | Go to the beginning of the input. |
@@ -95,12 +109,17 @@ class Input(Widget, can_focus=True):
| ctrl+k | Delete everything to the right of the cursor. | | ctrl+k | Delete everything to the right of the cursor. |
""" """
COMPONENT_CLASSES: ClassVar[set[str]] = {"input--cursor", "input--placeholder"} COMPONENT_CLASSES: ClassVar[set[str]] = {
"input--cursor",
"input--placeholder",
"input--suggestion",
}
""" """
| Class | Description | | Class | Description |
| :- | :- | | :- | :- |
| `input--cursor` | Target the cursor. | | `input--cursor` | Target the cursor. |
| `input--placeholder` | Target the placeholder text (when it exists). | | `input--placeholder` | Target the placeholder text (when it exists). |
| `input--suggestion` | Target the auto-completion suggestion (when it exists). |
""" """
DEFAULT_CSS = """ DEFAULT_CSS = """
@@ -121,7 +140,7 @@ class Input(Widget, can_focus=True):
color: $text; color: $text;
text-style: reverse; text-style: reverse;
} }
Input>.input--placeholder { Input>.input--placeholder, Input>.input--suggestion {
color: $text-disabled; color: $text-disabled;
} }
Input.-invalid { Input.-invalid {
@@ -143,6 +162,10 @@ class Input(Widget, can_focus=True):
_cursor_visible = reactive(True) _cursor_visible = reactive(True)
password = reactive(False) password = reactive(False)
max_size: reactive[int | None] = reactive(None) max_size: reactive[int | None] = reactive(None)
suggester: Suggester | None
"""The suggester used to provide completions as the user types."""
_suggestion = reactive("")
"""A completion suggestion for the current value in the input."""
@dataclass @dataclass
class Changed(Message): class Changed(Message):
@@ -195,6 +218,8 @@ class Input(Widget, can_focus=True):
placeholder: str = "", placeholder: str = "",
highlighter: Highlighter | None = None, highlighter: Highlighter | None = None,
password: bool = False, password: bool = False,
*,
suggester: Suggester | None = None,
validators: Validator | Iterable[Validator] | None = None, validators: Validator | Iterable[Validator] | None = None,
name: str | None = None, name: str | None = None,
id: str | None = None, id: str | None = None,
@@ -207,7 +232,9 @@ class Input(Widget, can_focus=True):
value: An optional default value for the input. value: An optional default value for the input.
placeholder: Optional placeholder text for the input. placeholder: Optional placeholder text for the input.
highlighter: An optional highlighter for the input. highlighter: An optional highlighter for the input.
password: Flag to say if the field should obfuscate its content. Default is `False`. password: Flag to say if the field should obfuscate its content.
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
input instance.
validators: An iterable of validators that the Input value will be checked against. validators: An iterable of validators that the Input value will be checked against.
name: Optional name for the input widget. name: Optional name for the input widget.
id: Optional ID for the widget. id: Optional ID for the widget.
@@ -220,6 +247,7 @@ class Input(Widget, can_focus=True):
self.placeholder = placeholder self.placeholder = placeholder
self.highlighter = highlighter self.highlighter = highlighter
self.password = password self.password = password
self.suggester = suggester
# Ensure we always end up with an Iterable of validators # Ensure we always end up with an Iterable of validators
if isinstance(validators, Validator): if isinstance(validators, Validator):
self.validators: list[Validator] = [validators] self.validators: list[Validator] = [validators]
@@ -272,6 +300,9 @@ class Input(Widget, can_focus=True):
self.view_position = self.view_position self.view_position = self.view_position
async def watch_value(self, value: str) -> None: async def watch_value(self, value: str) -> None:
self._suggestion = ""
if self.suggester and value:
self.run_worker(self.suggester._get_suggestion(self, value))
if self.styles.auto_dimensions: if self.styles.auto_dimensions:
self.refresh(layout=True) self.refresh(layout=True)
@@ -402,13 +433,18 @@ class Input(Widget, can_focus=True):
else: else:
self.cursor_position = len(self.value) self.cursor_position = len(self.value)
async def _on_suggestion_ready(self, event: SuggestionReady) -> None:
"""Handle suggestion messages and set the suggestion when relevant."""
if event.value == self.value:
self._suggestion = event.suggestion
def insert_text_at_cursor(self, text: str) -> None: def insert_text_at_cursor(self, text: str) -> None:
"""Insert new text at the cursor, move the cursor to the end of the new text. """Insert new text at the cursor, move the cursor to the end of the new text.
Args: Args:
text: New text to insert. text: New text to insert.
""" """
if self.cursor_position > len(self.value): if self.cursor_position >= len(self.value):
self.value += text self.value += text
self.cursor_position = len(self.value) self.cursor_position = len(self.value)
else: else:
@@ -423,7 +459,11 @@ class Input(Widget, can_focus=True):
self.cursor_position -= 1 self.cursor_position -= 1
def action_cursor_right(self) -> None: def action_cursor_right(self) -> None:
"""Move the cursor one position to the right.""" """Accept an auto-completion or move the cursor one position to the right."""
if self._cursor_at_end and self._suggestion:
self.value = self._suggestion
self.cursor_position = len(self.value)
else:
self.cursor_position += 1 self.cursor_position += 1
def action_home(self) -> None: def action_home(self) -> None:

File diff suppressed because one or more lines are too long

View File

@@ -40,7 +40,7 @@ def snap_compare(
def compare( def compare(
app_path: str | PurePath, app_path: str | PurePath,
press: Iterable[str] = ("_",), press: Iterable[str] = (),
terminal_size: tuple[int, int] = (80, 24), terminal_size: tuple[int, int] = (80, 24),
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
) -> bool: ) -> 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) 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): def test_buttons_render(snap_compare):
# Testing button rendering. We press tab to focus the first button too. # Testing button rendering. We press tab to focus the first button too.
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"]) 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): def test_select(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
def test_selection_list_selected(snap_compare): def test_selection_list_selected(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py")
def test_selection_list_selections(snap_compare): def test_selection_list_selections(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py")
def test_selection_list_tuples(snap_compare): def test_selection_list_tuples(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py")
def test_select_expanded(snap_compare): def test_select_expanded(snap_compare):
assert snap_compare( assert snap_compare(
WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"] 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): def test_dock_scroll_off_by_one(snap_compare):
# https://github.com/Textualize/textual/issues/2525 # https://github.com/Textualize/textual/issues/2525
assert snap_compare( 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): def test_scroll_to(snap_compare):
# https://github.com/Textualize/textual/issues/2525 # 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): 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