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 10 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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).


## 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
115 changes: 115 additions & 0 deletions src/textual/suggester.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from __future__ import annotations

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

from .dom import DOMNode
from .message import Message


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

initial_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: dict[str, str | None] | None
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""Suggestion cache, if used."""

def __init__(self, use_cache: bool = True):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should have -> None

"""Create a suggester object.

Args:
use_cache: Whether to cache suggestion results.
"""
self.cache = {} if use_cache else None

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 input value to complete.
"""

if self.cache is None or value not in self.cache:
suggestion = await self.get_suggestion(value)
if self.cache is not None:
self.cache[value] = suggestion
else:
suggestion = self.cache[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:
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))
```
"""

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.
"""
super().__init__()
self._suggestions = list(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 suggestion in self._suggestions:
if suggestion.startswith(value):
return suggestion
return None
68 changes: 55 additions & 13 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
from __future__ import annotations

import re
from typing import ClassVar
from typing import ClassVar, Iterable, List, Optional

from rich.cells import cell_len, get_character_cell_size
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.highlighter import Highlighter
from rich.segment import Segment
from rich.text import Text

from .. import events
from .. import events, work
from .._segment_tools import line_crop
from ..binding import Binding, BindingType
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


Expand All @@ -31,13 +32,28 @@ 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 = suggestion.startswith(value) and (
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 @@ -80,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 @@ -93,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 @@ -119,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;
}
"""
Expand All @@ -135,6 +156,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."""

class Changed(Message, bubble=True):
"""Posted when the value changes.
Expand Down Expand Up @@ -184,6 +209,8 @@ def __init__(
placeholder: str = "",
highlighter: Highlighter | None = None,
password: bool = False,
*,
suggester: Suggester | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
Expand All @@ -195,7 +222,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.
name: Optional name for the input widget.
id: Optional ID for the widget.
classes: Optional initial classes for the widget.
Expand All @@ -207,6 +236,7 @@ def __init__(
self.placeholder = placeholder
self.highlighter = highlighter
self.password = password
self.suggester = suggester

def _position_to_cell(self, position: int) -> int:
"""Convert an index within the value to cell position."""
Expand Down Expand Up @@ -252,6 +282,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)
self.post_message(self.Changed(self, value))
Expand Down Expand Up @@ -353,13 +386,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.initial_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 @@ -374,8 +412,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