diff --git a/.coveragerc b/.coveragerc
index 087a1674f7..11b69dc731 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -8,3 +8,4 @@ exclude_lines =
if __name__ == "__main__":
@overload
__rich_repr__
+ @abstractmethod
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0676b51aa4..90525c9ca4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 right 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
diff --git a/src/textual/suggester.py b/src/textual/suggester.py
new file mode 100644
index 0000000000..362fe89f6d
--- /dev/null
+++ b/src/textual/suggester.py
@@ -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 = False) -> 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
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index 31f267a32d..1c1ca124ae 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -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
@@ -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:
@@ -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. |
@@ -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 = """
@@ -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 {
@@ -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):
@@ -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,
@@ -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.
@@ -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]
@@ -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)
@@ -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:
@@ -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."""
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 47b0e0c611..fa3b4a920a 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -732,134 +732,134 @@
font-weight: 700;
}
- .terminal-1256256702-matrix {
+ .terminal-1939862718-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1256256702-title {
+ .terminal-1939862718-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1256256702-r1 { fill: #1e1e1e }
- .terminal-1256256702-r2 { fill: #161616 }
- .terminal-1256256702-r3 { fill: #c5c8c6 }
- .terminal-1256256702-r4 { fill: #7c7c7c }
- .terminal-1256256702-r5 { fill: #e1e1e1 }
+ .terminal-1939862718-r1 { fill: #1e1e1e }
+ .terminal-1939862718-r2 { fill: #161616 }
+ .terminal-1939862718-r3 { fill: #c5c8c6 }
+ .terminal-1939862718-r4 { fill: #7c7c7c }
+ .terminal-1939862718-r5 { fill: #e1e1e1 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- BlurApp
+ BlurApp
-
-
-
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊foo ▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊foo▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -16654,135 +16654,295 @@
font-weight: 700;
}
- .terminal-4205022328-matrix {
+ .terminal-596216952-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-4205022328-title {
+ .terminal-596216952-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-4205022328-r1 { fill: #1e1e1e }
- .terminal-4205022328-r2 { fill: #121212 }
- .terminal-4205022328-r3 { fill: #c5c8c6 }
- .terminal-4205022328-r4 { fill: #e2e2e2 }
- .terminal-4205022328-r5 { fill: #0178d4 }
- .terminal-4205022328-r6 { fill: #e1e1e1 }
+ .terminal-596216952-r1 { fill: #1e1e1e }
+ .terminal-596216952-r2 { fill: #121212 }
+ .terminal-596216952-r3 { fill: #c5c8c6 }
+ .terminal-596216952-r4 { fill: #e2e2e2 }
+ .terminal-596216952-r5 { fill: #0178d4 }
+ .terminal-596216952-r6 { fill: #e1e1e1 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- InputApp
+ InputApp
-
-
-
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊Darren ▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊Burns▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊Darren▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊Burns▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_input_suggestions
+ '''
+