Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Input completion suggestions #2604

Merged
merged 18 commits into from
May 29, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ exclude_lines =
if __name__ == "__main__":
@overload
__rich_repr__
@abstractmethod
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Message.control` is now a property instead of a class variable. https://github.com/Textualize/textual/issues/2528
- `Tree` and `DirectoryTree` Messages no longer accept a `tree` parameter, using `self.node.tree` instead. https://github.com/Textualize/textual/issues/2529

## Unreleased

### Added

- `Suggester` API to compose with widgets for automatic suggestions https://github.com/Textualize/textual/issues/2330
- `SuggestFromList` class to let widgets get completions from a fixed set of options https://github.com/Textualize/textual/pull/2604
- `Input` has a new component class `input--suggestion` https://github.com/Textualize/textual/pull/2604

### Changed

- Keybinding <kbd>right</kbd> in `Input` is also used to accept a suggestion if the cursor is at the end of the input https://github.com/Textualize/textual/pull/2604
- `Input.__init__` now accepts a `suggester` attribute for completion suggestions https://github.com/Textualize/textual/pull/2604


## [0.25.0] - 2023-05-17

### Changed
Expand Down
138 changes: 138 additions & 0 deletions src/textual/suggester.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Iterable

from ._cache import LRUCache
from .dom import DOMNode
from .message import Message


@dataclass
class SuggestionReady(Message):
"""Sent when a completion suggestion is ready."""

value: str
"""The value to which the suggestion is for."""
suggestion: str
"""The string suggestion."""


class Suggester(ABC):
"""Defines how widgets 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.
"""

cache: LRUCache[str, str | None] | None
"""Suggestion cache, if used."""

def __init__(self, *, use_cache: bool = True, case_sensitive: bool = True) -> None:
"""Create a suggester object.

Args:
use_cache: Whether to cache suggestion results.
case_sensitive: Whether suggestions are case sensitive or not.
If they are not, incoming values are casefolded before generating
the suggestion.
"""
self.cache = LRUCache(1024) if use_cache else None
self.case_sensitive = case_sensitive

async def _get_suggestion(self, requester: DOMNode, value: str) -> None:
"""Used by widgets to get completion suggestions.

Note:
When implementing custom suggesters, this method does not need to be
overridden.

Args:
requester: The message target that requested a suggestion.
value: The current value to complete.
"""

normalized_value = value if self.case_sensitive else value.casefold()
if self.cache is None or normalized_value not in self.cache:
suggestion = await self.get_suggestion(normalized_value)
if self.cache is not None:
self.cache[normalized_value] = suggestion
else:
suggestion = self.cache[normalized_value]

if suggestion is None:
return
requester.post_message(SuggestionReady(value, suggestion))

@abstractmethod
async def get_suggestion(self, value: str) -> str | None:
"""Try to get a completion suggestion for the given input value.

Custom suggesters should implement this method.

Note:
The value argument will be casefolded if `self.case_sensitive` is `False`.

Note:
If your implementation is not deterministic, you may need to disable caching.

Args:
value: The current value of the requester widget.

Returns:
A valid suggestion or `None`.
"""
pass


class SuggestFromList(Suggester):
"""Give completion suggestions based on a fixed list of options.

Example:
```py
countries = ["England", "Scotland", "Portugal", "Spain", "France"]

class MyApp(App[None]):
def compose(self) -> ComposeResult:
yield Input(suggester=SuggestFromList(countries, case_sensitive=False))
```

If the user types ++p++ inside the input widget, a completion suggestion
for `"Portugal"` appears.
"""

def __init__(
self, suggestions: Iterable[str], *, case_sensitive: bool = True
) -> None:
"""Creates a suggester based off of a given iterable of possibilities.

Args:
suggestions: Valid suggestions sorted by decreasing priority.
case_sensitive: Whether suggestions are computed in a case sensitive manner
or not. The values provided in the argument `suggestions` represent the
canonical representation of the completions and they will be suggested
with that same casing.
"""
super().__init__(case_sensitive=case_sensitive)
self._suggestions = list(suggestions)
self._for_comparison = (
self._suggestions
if self.case_sensitive
else [suggestion.casefold() for suggestion in self._suggestions]
)

async def get_suggestion(self, value: str) -> str | None:
"""Gets a completion from the given possibilities.

Args:
value: The current value.

Returns:
A valid completion suggestion or `None`.
"""
for idx, suggestion in enumerate(self._for_comparison):
if suggestion.startswith(value):
return self._suggestions[idx]
return None
64 changes: 52 additions & 12 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
from ..geometry import Size
from ..message import Message
from ..reactive import reactive
from ..validation import Failure, ValidationResult, Validator
from ..suggester import Suggester, SuggestionReady
from ..validation import ValidationResult, Validator
from ..widget import Widget


Expand All @@ -33,13 +34,26 @@ def __rich_console__(
) -> "RenderResult":
input = self.input
result = input._value
if input._cursor_at_end:
result.pad_right(1)
cursor_style = input.get_component_rich_style("input--cursor")
width = input.content_size.width

# Add the completion with a faded style.
value = input.value
value_length = len(value)
suggestion = input._suggestion
show_suggestion = len(suggestion) > value_length
if show_suggestion:
result += Text(
suggestion[value_length:],
input.get_component_rich_style("input--suggestion"),
)

if self.cursor_visible and input.has_focus:
if not show_suggestion and input._cursor_at_end:
result.pad_right(1)
cursor_style = input.get_component_rich_style("input--cursor")
cursor = input.cursor_position
result.stylize(cursor_style, cursor, cursor + 1)
width = input.content_size.width

segments = list(result.render(console))
line_length = Segment.get_line_length(segments)
if line_length < width:
Expand Down Expand Up @@ -82,7 +96,7 @@ class Input(Widget, can_focus=True):
| :- | :- |
| left | Move the cursor left. |
| ctrl+left | Move the cursor one word to the left. |
| right | Move the cursor right. |
| right | Move the cursor right or accept the completion suggestion. |
| ctrl+right | Move the cursor one word to the right. |
| backspace | Delete the character to the left of the cursor. |
| home,ctrl+a | Go to the beginning of the input. |
Expand All @@ -95,12 +109,17 @@ class Input(Widget, can_focus=True):
| ctrl+k | Delete everything to the right of the cursor. |
"""

COMPONENT_CLASSES: ClassVar[set[str]] = {"input--cursor", "input--placeholder"}
COMPONENT_CLASSES: ClassVar[set[str]] = {
"input--cursor",
"input--placeholder",
"input--suggestion",
}
"""
| Class | Description |
| :- | :- |
| `input--cursor` | Target the cursor. |
| `input--placeholder` | Target the placeholder text (when it exists). |
| `input--suggestion` | Target the auto-completion suggestion (when it exists). |
"""

DEFAULT_CSS = """
Expand All @@ -121,7 +140,7 @@ class Input(Widget, can_focus=True):
color: $text;
text-style: reverse;
}
Input>.input--placeholder {
Input>.input--placeholder, Input>.input--suggestion {
color: $text-disabled;
}
Input.-invalid {
Expand All @@ -143,6 +162,10 @@ class Input(Widget, can_focus=True):
_cursor_visible = reactive(True)
password = reactive(False)
max_size: reactive[int | None] = reactive(None)
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."""

@dataclass
class Changed(Message):
Expand Down Expand Up @@ -195,6 +218,8 @@ def __init__(
placeholder: str = "",
highlighter: Highlighter | None = None,
password: bool = False,
*,
suggester: Suggester | None = None,
validators: Validator | Iterable[Validator] | None = None,
name: str | None = None,
id: str | None = None,
Expand All @@ -207,7 +232,9 @@ def __init__(
value: An optional default value for the input.
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. Default is `False`.
password: Flag to say if the field should obfuscate its content.
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
input instance.
validators: An iterable of validators that the Input value will be checked against.
name: Optional name for the input widget.
id: Optional ID for the widget.
Expand All @@ -220,6 +247,7 @@ def __init__(
self.placeholder = placeholder
self.highlighter = highlighter
self.password = password
self.suggester = suggester
# Ensure we always end up with an Iterable of validators
if isinstance(validators, Validator):
self.validators: list[Validator] = [validators]
Expand Down Expand Up @@ -272,6 +300,9 @@ def watch_cursor_position(self, cursor_position: int) -> None:
self.view_position = self.view_position

async def watch_value(self, value: str) -> None:
self._suggestion = ""
if self.suggester and value:
self.run_worker(self.suggester._get_suggestion(self, value))
if self.styles.auto_dimensions:
self.refresh(layout=True)

Expand Down Expand Up @@ -402,13 +433,18 @@ async def _on_click(self, event: events.Click) -> None:
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.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.

Args:
text: New text to insert.
"""
if self.cursor_position > len(self.value):
if self.cursor_position >= len(self.value):
self.value += text
self.cursor_position = len(self.value)
else:
Expand All @@ -423,8 +459,12 @@ def action_cursor_left(self) -> None:
self.cursor_position -= 1

def action_cursor_right(self) -> None:
"""Move the cursor one position to the right."""
self.cursor_position += 1
"""Accept an auto-completion or move the cursor one position to the right."""
if self._cursor_at_end and self._suggestion:
self.value = self._suggestion
self.cursor_position = len(self.value)
else:
self.cursor_position += 1

def action_home(self) -> None:
"""Move the cursor to the start of the input."""
Expand Down
Loading