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 ..geometry import Size
from ..message import Message from ..message import Message
from ..reactive import reactive from ..reactive import reactive
from ..suggester import Suggester, SuggestionReady
from ..widget import Widget from ..widget import Widget
@@ -155,12 +156,8 @@ 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)
suggestions = reactive[Optional[List[str]]](None) suggester: Suggester | None
"""List of completion suggestions that are shown while the user types. """The suggester used to provide completions as 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..
"""
_suggestion = reactive("") _suggestion = reactive("")
"""A completion suggestion for the current value in the input.""" """A completion suggestion for the current value in the input."""
@@ -213,7 +210,7 @@ class Input(Widget, can_focus=True):
highlighter: Highlighter | None = None, highlighter: Highlighter | None = None,
password: bool = False, password: bool = False,
*, *,
suggestions: Iterable[str] | None = None, suggester: Suggester | None = None,
name: str | None = None, name: str | None = None,
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
@@ -226,7 +223,8 @@ class Input(Widget, can_focus=True):
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. 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. name: Optional name for the input widget.
id: Optional ID for the widget. id: Optional ID for the widget.
classes: Optional initial classes for the widget. classes: Optional initial classes for the widget.
@@ -238,7 +236,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.suggestions = suggestions self.suggester = suggester
def _position_to_cell(self, position: int) -> int: def _position_to_cell(self, position: int) -> int:
"""Convert an index within the value to cell position.""" """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: async def watch_value(self, value: str) -> None:
self._suggestion = "" self._suggestion = ""
if self.suggestions and value: if self.suggester and value:
self._get_suggestion() self.call_next(self.suggester.get, self, value)
if self.styles.auto_dimensions: if self.styles.auto_dimensions:
self.refresh(layout=True) self.refresh(layout=True)
self.post_message(self.Changed(self, value)) self.post_message(self.Changed(self, value))
@@ -388,6 +386,11 @@ 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.input_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.
@@ -531,23 +534,3 @@ class Input(Widget, can_focus=True):
async def action_submit(self) -> None: async def action_submit(self) -> None:
"""Handle a submit action (normally the user hitting Enter in the input).""" """Handle a submit action (normally the user hitting Enter in the input)."""
self.post_message(self.Submitted(self, self.value)) 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 import pytest
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.suggester import SuggestFromList
from textual.widgets import Input from textual.widgets import Input
class SuggestionsApp(App[ComposeResult]): class SuggestionsApp(App[ComposeResult]):
def __init__(self, suggestions=None): def __init__(self, suggestions):
self.suggestions = suggestions self.suggestions = suggestions
self.input = Input(suggestions=self.suggestions) self.input = Input(suggester=SuggestFromList(self.suggestions))
super().__init__() super().__init__()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@@ -17,7 +18,7 @@ class SuggestionsApp(App[ComposeResult]):
async def test_no_suggestions(): async def test_no_suggestions():
app = SuggestionsApp() app = SuggestionsApp([])
async with app.run_test() as pilot: async with app.run_test() as pilot:
assert app.input._suggestion == "" assert app.input._suggestion == ""
await pilot.press("a") await pilot.press("a")

View File

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