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 ..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
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user