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 8 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 `Input` for automatic input suggestions while typing https://github.com/Textualize/textual/issues/2330
- `SuggestFromList` class to let `Input` widgets get completions from a fixed set of options https://github.com/Textualize/textual/pull/2604
- `Input` has 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 for completion suggestions https://github.com/Textualize/textual/pull/2604


## [0.25.0] - 2023-05-17

### Changed
Expand Down
84 changes: 84 additions & 0 deletions src/textual/suggester.py
Original file line number Diff line number Diff line change
@@ -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:
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""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()
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved


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
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.call_next(self.suggester.get, 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.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.

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
101 changes: 101 additions & 0 deletions tests/input/test_suggestions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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):
self.suggestions = suggestions
self.input = Input(suggester=SuggestFromList(self.suggestions))
super().__init__()

def compose(self) -> ComposeResult:
yield self.input


async def test_no_suggestions():
app = SuggestionsApp([])
async with app.run_test() as pilot:
assert app.input._suggestion == ""
await pilot.press("a")
assert app.input._suggestion == ""


async def test_suggestion():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
for char in "hello":
await pilot.press(char)
assert app.input._suggestion == "hello"


async def test_accept_suggestion():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press("h")
await pilot.press("right")
assert app.input.value == "hello"


async def test_no_suggestion_on_empty_value():
app = SuggestionsApp(["hello"])
async with app.run_test():
assert app.input._suggestion == ""


async def test_no_suggestion_on_empty_value_after_deleting():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press("h", "e", "backspace", "backspace")
assert app.input.value == "" # Sanity check.
assert app.input._suggestion == ""


async def test_suggestion_shows_up_after_deleting_extra_chars():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press(*"help")
assert app.input._suggestion == ""
await pilot.press("backspace")
assert app.input._suggestion == "hello"


async def test_suggestion_shows_up_after_deleting_extra_chars_in_middle_of_word():
app = SuggestionsApp(["hello"])
async with app.run_test() as pilot:
await pilot.press(*"hefl")
assert app.input._suggestion == ""
await pilot.press("left", "backspace")
assert app.input._suggestion == "hello"


@pytest.mark.parametrize(
("suggestion", "truncate_at"),
[
(".......", 3),
("hey there", 3),
("Olá, tudo bem?", 3),
("áàóãõñç", 2),
(string.punctuation, 3),
(string.punctuation[::-1], 5),
(string.punctuation[::3], 5),
],
)
async def test_suggestion_with_special_characters(suggestion: str, truncate_at: int):
app = SuggestionsApp([suggestion])
async with app.run_test() as pilot:
await pilot.press(*suggestion[:truncate_at])
assert app.input._suggestion == suggestion


async def test_suggestion_priority():
app = SuggestionsApp(["dog", "dad"])
async with app.run_test() as pilot:
await pilot.press("d")
assert app.input._suggestion == "dog"
await pilot.press("a")
assert app.input._suggestion == "dad"
Loading