diff --git a/src/textual/suggester.py b/src/textual/suggester.py new file mode 100644 index 000000000..888ce7cee --- /dev/null +++ b/src/textual/suggester.py @@ -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 diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index d0432871a..104b7c970 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -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 diff --git a/tests/input/test_suggestions.py b/tests/input/test_suggestions.py index ca310d29f..e70e41593 100644 --- a/tests/input/test_suggestions.py +++ b/tests/input/test_suggestions.py @@ -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") diff --git a/tests/snapshot_tests/snapshot_apps/input_suggestions.py b/tests/snapshot_tests/snapshot_apps/input_suggestions.py index 1ddc4ba42..d93206636 100644 --- a/tests/snapshot_tests/snapshot_apps/input_suggestions.py +++ b/tests/snapshot_tests/snapshot_apps/input_suggestions.py @@ -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__":