V2 of input suggestions API.

This commit is contained in:
Rodrigo Girão Serrão
2023-05-23 10:47:22 +01:00
parent 75606c8dfd
commit 297549c7d8
4 changed files with 104 additions and 35 deletions

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

@@ -0,0 +1,84 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable, Optional
from .message import Message
if TYPE_CHECKING:
from .widgets import Input
@dataclass
class SuggestionReady(Message):
"""Sent when a completion suggestion is ready."""
input_value: str
"""The input value that the suggestion was for."""
suggestion: str
"""The string suggestion."""
class Suggester(ABC):
"""Defines how [inputs][textual.widgets.Input] 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.
"""
async def get(self, input: "Input", value: str) -> None:
"""Used by [`Input`][textual.widgets.Input] to get completion suggestions.
Note:
When implementing custom suggesters, this method does not need to be
overridden.
Args:
input: The input widget that requested a suggestion.
value: The current input value to complete.
"""
suggestion = await self.get_suggestion(value)
if suggestion is None:
return
input.post_message(SuggestionReady(value, suggestion))
@abstractmethod
async def get_suggestion(self, value: str) -> Optional[str]:
"""Try to get a completion suggestion for the given input value.
Custom suggesters should implement this method.
Args:
value: The current value of the input widget.
Returns:
A valid suggestion or `None`.
"""
raise NotImplementedError()
class SuggestFromList(Suggester):
"""Give completion suggestions based on a fixed list of options."""
def __init__(self, suggestions: Iterable[str]) -> None:
"""Creates a suggester based off of a given iterable of possibilities.
Args:
suggestions: Valid suggestions sorted by decreasing priority.
"""
self.suggestions = list(suggestions)
async def get_suggestion(self, value: str) -> Optional[str]:
"""Gets a completion from the given possibilities.
Args:
value: The current value of the input widget.
Returns:
A valid suggestion or `None`.
"""
for suggestion in self.suggestions:
if suggestion.startswith(value):
return suggestion
return None

View File

@@ -16,6 +16,7 @@ from ..events import Blur, Focus, Mount
from ..geometry import Size
from ..message import Message
from ..reactive import reactive
from ..suggester import Suggester, SuggestionReady
from ..widget import Widget
@@ -155,12 +156,8 @@ class Input(Widget, can_focus=True):
_cursor_visible = reactive(True)
password = reactive(False)
max_size: reactive[int | None] = reactive(None)
suggestions = reactive[Optional[List[str]]](None)
"""List of completion suggestions that are shown while the user types.
The precedence of the suggestions is inferred from the order of the list.
Set this to `None` or to an empty list to disable this feature..
"""
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."""
@@ -213,7 +210,7 @@ class Input(Widget, can_focus=True):
highlighter: Highlighter | None = None,
password: bool = False,
*,
suggestions: Iterable[str] | None = None,
suggester: Suggester | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
@@ -226,7 +223,8 @@ class Input(Widget, can_focus=True):
placeholder: Optional placeholder text for the input.
highlighter: An optional highlighter for the input.
password: Flag to say if the field should obfuscate its content.
suggestions: Possible auto-completions for the input field.
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
input instance.
name: Optional name for the input widget.
id: Optional ID for the widget.
classes: Optional initial classes for the widget.
@@ -238,7 +236,7 @@ class Input(Widget, can_focus=True):
self.placeholder = placeholder
self.highlighter = highlighter
self.password = password
self.suggestions = suggestions
self.suggester = suggester
def _position_to_cell(self, position: int) -> int:
"""Convert an index within the value to cell position."""
@@ -285,8 +283,8 @@ class Input(Widget, can_focus=True):
async def watch_value(self, value: str) -> None:
self._suggestion = ""
if self.suggestions and value:
self._get_suggestion()
if self.suggester and value:
self.call_next(self.suggester.get, self, value)
if self.styles.auto_dimensions:
self.refresh(layout=True)
self.post_message(self.Changed(self, value))
@@ -388,6 +386,11 @@ class Input(Widget, can_focus=True):
else:
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.input_value == self.value:
self._suggestion = event.suggestion
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.
@@ -531,23 +534,3 @@ class Input(Widget, can_focus=True):
async def action_submit(self) -> None:
"""Handle a submit action (normally the user hitting Enter in the input)."""
self.post_message(self.Submitted(self, self.value))
def validate_suggestions(
self, suggestions: Iterable[str] | None
) -> list[str] | None:
"""Convert suggestions iterable into a list."""
if suggestions is None:
return None
return list(suggestions)
@work(exclusive=True)
def _get_suggestion(self) -> None:
"""Try to get a suggestion for the user."""
if not self.suggestions:
return
value = self.value
for suggestion in self.suggestions:
if suggestion.startswith(value):
self._suggestion = suggestion
break

View File

@@ -3,13 +3,14 @@ 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=None):
def __init__(self, suggestions):
self.suggestions = suggestions
self.input = Input(suggestions=self.suggestions)
self.input = Input(suggester=SuggestFromList(self.suggestions))
super().__init__()
def compose(self) -> ComposeResult:
@@ -17,7 +18,7 @@ class SuggestionsApp(App[ComposeResult]):
async def test_no_suggestions():
app = SuggestionsApp()
app = SuggestionsApp([])
async with app.run_test() as pilot:
assert app.input._suggestion == ""
await pilot.press("a")

View File

@@ -1,4 +1,5 @@
from textual.app import App, ComposeResult
from textual.suggester import SuggestFromList
from textual.widgets import Input
@@ -14,7 +15,7 @@ class FruitsApp(App[None]):
"""
def compose(self) -> ComposeResult:
yield Input("straw", suggestions=fruits)
yield Input("straw", suggester=SuggestFromList(fruits))
if __name__ == "__main__":