mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
V2 of input suggestions API.
This commit is contained in:
84
src/textual/suggester.py
Normal file
84
src/textual/suggester.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user