mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #2604 from Textualize/input-auto-completion
Input completion suggestions
This commit is contained in:
@@ -8,3 +8,4 @@ exclude_lines =
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@overload
|
@overload
|
||||||
__rich_repr__
|
__rich_repr__
|
||||||
|
@abstractmethod
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -50,6 +50,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
|
- `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
|
- `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
|
## [0.25.0] - 2023-05-17
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
138
src/textual/suggester.py
Normal file
138
src/textual/suggester.py
Normal file
@@ -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
|
||||||
@@ -17,7 +17,8 @@ from ..events import Blur, Focus, Mount
|
|||||||
from ..geometry import Size
|
from ..geometry import Size
|
||||||
from ..message import Message
|
from ..message import Message
|
||||||
from ..reactive import reactive
|
from ..reactive import reactive
|
||||||
from ..validation import Failure, ValidationResult, Validator
|
from ..suggester import Suggester, SuggestionReady
|
||||||
|
from ..validation import ValidationResult, Validator
|
||||||
from ..widget import Widget
|
from ..widget import Widget
|
||||||
|
|
||||||
|
|
||||||
@@ -33,13 +34,26 @@ class _InputRenderable:
|
|||||||
) -> "RenderResult":
|
) -> "RenderResult":
|
||||||
input = self.input
|
input = self.input
|
||||||
result = input._value
|
result = input._value
|
||||||
if input._cursor_at_end:
|
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)
|
result.pad_right(1)
|
||||||
cursor_style = input.get_component_rich_style("input--cursor")
|
cursor_style = input.get_component_rich_style("input--cursor")
|
||||||
if self.cursor_visible and input.has_focus:
|
|
||||||
cursor = input.cursor_position
|
cursor = input.cursor_position
|
||||||
result.stylize(cursor_style, cursor, cursor + 1)
|
result.stylize(cursor_style, cursor, cursor + 1)
|
||||||
width = input.content_size.width
|
|
||||||
segments = list(result.render(console))
|
segments = list(result.render(console))
|
||||||
line_length = Segment.get_line_length(segments)
|
line_length = Segment.get_line_length(segments)
|
||||||
if line_length < width:
|
if line_length < width:
|
||||||
@@ -82,7 +96,7 @@ class Input(Widget, can_focus=True):
|
|||||||
| :- | :- |
|
| :- | :- |
|
||||||
| left | Move the cursor left. |
|
| left | Move the cursor left. |
|
||||||
| ctrl+left | Move the cursor one word to the 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. |
|
| ctrl+right | Move the cursor one word to the right. |
|
||||||
| backspace | Delete the character to the left of the cursor. |
|
| backspace | Delete the character to the left of the cursor. |
|
||||||
| home,ctrl+a | Go to the beginning of the input. |
|
| 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. |
|
| 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 |
|
| Class | Description |
|
||||||
| :- | :- |
|
| :- | :- |
|
||||||
| `input--cursor` | Target the cursor. |
|
| `input--cursor` | Target the cursor. |
|
||||||
| `input--placeholder` | Target the placeholder text (when it exists). |
|
| `input--placeholder` | Target the placeholder text (when it exists). |
|
||||||
|
| `input--suggestion` | Target the auto-completion suggestion (when it exists). |
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
@@ -121,7 +140,7 @@ class Input(Widget, can_focus=True):
|
|||||||
color: $text;
|
color: $text;
|
||||||
text-style: reverse;
|
text-style: reverse;
|
||||||
}
|
}
|
||||||
Input>.input--placeholder {
|
Input>.input--placeholder, Input>.input--suggestion {
|
||||||
color: $text-disabled;
|
color: $text-disabled;
|
||||||
}
|
}
|
||||||
Input.-invalid {
|
Input.-invalid {
|
||||||
@@ -143,6 +162,10 @@ class Input(Widget, can_focus=True):
|
|||||||
_cursor_visible = reactive(True)
|
_cursor_visible = reactive(True)
|
||||||
password = reactive(False)
|
password = reactive(False)
|
||||||
max_size: reactive[int | None] = reactive(None)
|
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
|
@dataclass
|
||||||
class Changed(Message):
|
class Changed(Message):
|
||||||
@@ -195,6 +218,8 @@ class Input(Widget, can_focus=True):
|
|||||||
placeholder: str = "",
|
placeholder: str = "",
|
||||||
highlighter: Highlighter | None = None,
|
highlighter: Highlighter | None = None,
|
||||||
password: bool = False,
|
password: bool = False,
|
||||||
|
*,
|
||||||
|
suggester: Suggester | None = None,
|
||||||
validators: Validator | Iterable[Validator] | None = None,
|
validators: Validator | Iterable[Validator] | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
@@ -207,7 +232,9 @@ class Input(Widget, can_focus=True):
|
|||||||
value: An optional default value for the input.
|
value: An optional default value for the input.
|
||||||
placeholder: Optional placeholder text for the input.
|
placeholder: Optional placeholder text for the input.
|
||||||
highlighter: An optional highlighter 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.
|
validators: An iterable of validators that the Input value will be checked against.
|
||||||
name: Optional name for the input widget.
|
name: Optional name for the input widget.
|
||||||
id: Optional ID for the widget.
|
id: Optional ID for the widget.
|
||||||
@@ -220,6 +247,7 @@ class Input(Widget, can_focus=True):
|
|||||||
self.placeholder = placeholder
|
self.placeholder = placeholder
|
||||||
self.highlighter = highlighter
|
self.highlighter = highlighter
|
||||||
self.password = password
|
self.password = password
|
||||||
|
self.suggester = suggester
|
||||||
# Ensure we always end up with an Iterable of validators
|
# Ensure we always end up with an Iterable of validators
|
||||||
if isinstance(validators, Validator):
|
if isinstance(validators, Validator):
|
||||||
self.validators: list[Validator] = [validators]
|
self.validators: list[Validator] = [validators]
|
||||||
@@ -272,6 +300,9 @@ class Input(Widget, can_focus=True):
|
|||||||
self.view_position = self.view_position
|
self.view_position = self.view_position
|
||||||
|
|
||||||
async def watch_value(self, value: str) -> None:
|
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:
|
if self.styles.auto_dimensions:
|
||||||
self.refresh(layout=True)
|
self.refresh(layout=True)
|
||||||
|
|
||||||
@@ -402,13 +433,18 @@ class Input(Widget, can_focus=True):
|
|||||||
else:
|
else:
|
||||||
self.cursor_position = len(self.value)
|
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:
|
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.
|
"""Insert new text at the cursor, move the cursor to the end of the new text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: New text to insert.
|
text: New text to insert.
|
||||||
"""
|
"""
|
||||||
if self.cursor_position > len(self.value):
|
if self.cursor_position >= len(self.value):
|
||||||
self.value += text
|
self.value += text
|
||||||
self.cursor_position = len(self.value)
|
self.cursor_position = len(self.value)
|
||||||
else:
|
else:
|
||||||
@@ -423,7 +459,11 @@ class Input(Widget, can_focus=True):
|
|||||||
self.cursor_position -= 1
|
self.cursor_position -= 1
|
||||||
|
|
||||||
def action_cursor_right(self) -> None:
|
def action_cursor_right(self) -> None:
|
||||||
"""Move the cursor one position to the right."""
|
"""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
|
self.cursor_position += 1
|
||||||
|
|
||||||
def action_home(self) -> None:
|
def action_home(self) -> None:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -40,7 +40,7 @@ def snap_compare(
|
|||||||
|
|
||||||
def compare(
|
def compare(
|
||||||
app_path: str | PurePath,
|
app_path: str | PurePath,
|
||||||
press: Iterable[str] = ("_",),
|
press: Iterable[str] = (),
|
||||||
terminal_size: tuple[int, int] = (80, 24),
|
terminal_size: tuple[int, int] = (80, 24),
|
||||||
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
|
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
23
tests/snapshot_tests/snapshot_apps/input_suggestions.py
Normal file
23
tests/snapshot_tests/snapshot_apps/input_suggestions.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.suggester import SuggestFromList
|
||||||
|
from textual.widgets import Input
|
||||||
|
|
||||||
|
|
||||||
|
fruits = ["apple", "pear", "mango", "peach", "strawberry", "blueberry", "banana"]
|
||||||
|
|
||||||
|
|
||||||
|
class FruitsApp(App[None]):
|
||||||
|
CSS = """
|
||||||
|
Input > .input--suggestion {
|
||||||
|
color: red;
|
||||||
|
text-style: italic;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Input("straw", suggester=SuggestFromList(fruits))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FruitsApp()
|
||||||
|
app.run()
|
||||||
@@ -97,6 +97,10 @@ def test_input_validation(snap_compare):
|
|||||||
assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press)
|
assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press)
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_suggestions(snap_compare):
|
||||||
|
assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[])
|
||||||
|
|
||||||
|
|
||||||
def test_buttons_render(snap_compare):
|
def test_buttons_render(snap_compare):
|
||||||
# Testing button rendering. We press tab to focus the first button too.
|
# Testing button rendering. We press tab to focus the first button too.
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
||||||
@@ -245,15 +249,19 @@ def test_progress_bar_completed_styled(snap_compare):
|
|||||||
def test_select(snap_compare):
|
def test_select(snap_compare):
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
|
||||||
|
|
||||||
|
|
||||||
def test_selection_list_selected(snap_compare):
|
def test_selection_list_selected(snap_compare):
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py")
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py")
|
||||||
|
|
||||||
|
|
||||||
def test_selection_list_selections(snap_compare):
|
def test_selection_list_selections(snap_compare):
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py")
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py")
|
||||||
|
|
||||||
|
|
||||||
def test_selection_list_tuples(snap_compare):
|
def test_selection_list_tuples(snap_compare):
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py")
|
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py")
|
||||||
|
|
||||||
|
|
||||||
def test_select_expanded(snap_compare):
|
def test_select_expanded(snap_compare):
|
||||||
assert snap_compare(
|
assert snap_compare(
|
||||||
WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"]
|
WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"]
|
||||||
@@ -482,13 +490,17 @@ def test_dock_scroll2(snap_compare):
|
|||||||
def test_dock_scroll_off_by_one(snap_compare):
|
def test_dock_scroll_off_by_one(snap_compare):
|
||||||
# https://github.com/Textualize/textual/issues/2525
|
# https://github.com/Textualize/textual/issues/2525
|
||||||
assert snap_compare(
|
assert snap_compare(
|
||||||
SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py", terminal_size=(80, 25)
|
SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py",
|
||||||
|
terminal_size=(80, 25),
|
||||||
|
press=["_"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_scroll_to(snap_compare):
|
def test_scroll_to(snap_compare):
|
||||||
# https://github.com/Textualize/textual/issues/2525
|
# https://github.com/Textualize/textual/issues/2525
|
||||||
assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25))
|
assert snap_compare(
|
||||||
|
SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25), press=["_"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_auto_fr(snap_compare):
|
def test_auto_fr(snap_compare):
|
||||||
|
|||||||
101
tests/suggester/test_input_suggestions.py
Normal file
101
tests/suggester/test_input_suggestions.py
Normal file
@@ -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"
|
||||||
55
tests/suggester/test_suggest_from_list.py
Normal file
55
tests/suggester/test_suggest_from_list.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from textual.dom import DOMNode
|
||||||
|
from textual.suggester import SuggestFromList, SuggestionReady
|
||||||
|
|
||||||
|
countries = ["England", "Portugal", "Scotland", "portugal", "PORTUGAL"]
|
||||||
|
|
||||||
|
|
||||||
|
class LogListNode(DOMNode):
|
||||||
|
def __init__(self, log_list: list[tuple[str, str]]) -> None:
|
||||||
|
self.log_list = log_list
|
||||||
|
|
||||||
|
def post_message(self, message: SuggestionReady):
|
||||||
|
# We hijack post_message so we can intercept messages without creating a full app.
|
||||||
|
self.log_list.append((message.suggestion, message.value))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_first_suggestion_has_priority():
|
||||||
|
suggester = SuggestFromList(countries)
|
||||||
|
|
||||||
|
assert "Portugal" == await suggester.get_suggestion("P")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["s", "S", "sc", "sC", "Sc", "SC"])
|
||||||
|
async def test_case_insensitive_suggestions(value):
|
||||||
|
suggester = SuggestFromList(countries, case_sensitive=False)
|
||||||
|
log = []
|
||||||
|
|
||||||
|
await suggester._get_suggestion(LogListNode(log), value)
|
||||||
|
assert log == [("Scotland", value)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"P",
|
||||||
|
"po",
|
||||||
|
"Po",
|
||||||
|
"pO",
|
||||||
|
"PO",
|
||||||
|
"port",
|
||||||
|
"Port",
|
||||||
|
"pORT",
|
||||||
|
"PORT",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_first_suggestion_has_priority_case_insensitive(value):
|
||||||
|
suggester = SuggestFromList(countries, case_sensitive=False)
|
||||||
|
log = []
|
||||||
|
|
||||||
|
await suggester._get_suggestion(LogListNode(log), value)
|
||||||
|
assert log == [("Portugal", value)]
|
||||||
111
tests/suggester/test_suggester.py
Normal file
111
tests/suggester/test_suggester.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from textual.dom import DOMNode
|
||||||
|
from textual.suggester import Suggester, SuggestionReady
|
||||||
|
|
||||||
|
|
||||||
|
class FillSuggester(Suggester):
|
||||||
|
async def get_suggestion(self, value: str):
|
||||||
|
if len(value) <= 10:
|
||||||
|
return f"{value:x<10}"
|
||||||
|
|
||||||
|
|
||||||
|
class LogListNode(DOMNode):
|
||||||
|
def __init__(self, log_list: list[tuple[str, str]]) -> None:
|
||||||
|
self.log_list = log_list
|
||||||
|
|
||||||
|
def post_message(self, message: SuggestionReady):
|
||||||
|
# We hijack post_message so we can intercept messages without creating a full app.
|
||||||
|
self.log_list.append((message.suggestion, message.value))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cache_on():
|
||||||
|
log = []
|
||||||
|
|
||||||
|
class MySuggester(Suggester):
|
||||||
|
async def get_suggestion(self, value: str):
|
||||||
|
log.append(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
suggester = MySuggester(use_cache=True)
|
||||||
|
await suggester._get_suggestion(DOMNode(), "hello")
|
||||||
|
assert log == ["hello"]
|
||||||
|
await suggester._get_suggestion(DOMNode(), "hello")
|
||||||
|
assert log == ["hello"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cache_off():
|
||||||
|
log = []
|
||||||
|
|
||||||
|
class MySuggester(Suggester):
|
||||||
|
async def get_suggestion(self, value: str):
|
||||||
|
log.append(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
suggester = MySuggester(use_cache=False)
|
||||||
|
await suggester._get_suggestion(DOMNode(), "hello")
|
||||||
|
assert log == ["hello"]
|
||||||
|
await suggester._get_suggestion(DOMNode(), "hello")
|
||||||
|
assert log == ["hello", "hello"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggestion_ready_message():
|
||||||
|
log = []
|
||||||
|
suggester = FillSuggester()
|
||||||
|
await suggester._get_suggestion(LogListNode(log), "hello")
|
||||||
|
assert log == [("helloxxxxx", "hello")]
|
||||||
|
await suggester._get_suggestion(LogListNode(log), "world")
|
||||||
|
assert log == [("helloxxxxx", "hello"), ("worldxxxxx", "world")]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_message_if_no_suggestion():
|
||||||
|
log = []
|
||||||
|
suggester = FillSuggester()
|
||||||
|
await suggester._get_suggestion(LogListNode(log), "this is a longer string")
|
||||||
|
assert log == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggestion_ready_message_on_cache_hit():
|
||||||
|
log = []
|
||||||
|
suggester = FillSuggester(use_cache=True)
|
||||||
|
await suggester._get_suggestion(LogListNode(log), "hello")
|
||||||
|
assert log == [("helloxxxxx", "hello")]
|
||||||
|
await suggester._get_suggestion(LogListNode(log), "hello")
|
||||||
|
assert log == [("helloxxxxx", "hello"), ("helloxxxxx", "hello")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
[
|
||||||
|
"hello",
|
||||||
|
"HELLO",
|
||||||
|
"HeLlO",
|
||||||
|
"Hello",
|
||||||
|
"hELLO",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_case_insensitive_suggestions(value):
|
||||||
|
class MySuggester(Suggester):
|
||||||
|
async def get_suggestion(self, value: str):
|
||||||
|
assert "hello" == value
|
||||||
|
|
||||||
|
suggester = MySuggester(use_cache=False, case_sensitive=False)
|
||||||
|
await suggester._get_suggestion(DOMNode(), value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_case_insensitive_cache_hits():
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
class MySuggester(Suggester):
|
||||||
|
async def get_suggestion(self, value: str):
|
||||||
|
nonlocal count
|
||||||
|
count += 1
|
||||||
|
return value + "abc"
|
||||||
|
|
||||||
|
suggester = MySuggester(use_cache=True, case_sensitive=False)
|
||||||
|
hellos = ["hello", "HELLO", "HeLlO", "Hello", "hELLO"]
|
||||||
|
for hello in hellos:
|
||||||
|
await suggester._get_suggestion(DOMNode(), hello)
|
||||||
|
assert count == 1
|
||||||
Reference in New Issue
Block a user