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 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FruitsApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + strawberry + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + @@ -16813,138 +16973,138 @@ font-weight: 700; } - .terminal-3846064726-matrix { + .terminal-922438230-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3846064726-title { + .terminal-922438230-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3846064726-r1 { fill: #e1e1e1 } - .terminal-3846064726-r2 { fill: #c5c8c6 } - .terminal-3846064726-r3 { fill: #1e1e1e } - .terminal-3846064726-r4 { fill: #7b3042 } - .terminal-3846064726-r5 { fill: #e2e2e2 } - .terminal-3846064726-r6 { fill: #3a7e4f } - .terminal-3846064726-r7 { fill: #b93c5b } - .terminal-3846064726-r8 { fill: #121212 } - .terminal-3846064726-r9 { fill: #787878 } + .terminal-922438230-r1 { fill: #e1e1e1 } + .terminal-922438230-r2 { fill: #c5c8c6 } + .terminal-922438230-r3 { fill: #1e1e1e } + .terminal-922438230-r4 { fill: #7b3042 } + .terminal-922438230-r5 { fill: #e2e2e2 } + .terminal-922438230-r6 { fill: #3a7e4f } + .terminal-922438230-r7 { fill: #b93c5b } + .terminal-922438230-r8 { fill: #121212 } + .terminal-922438230-r9 { fill: #787878 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputApp + InputApp - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -2  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -2 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Enter a number between 1 and 5 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + -2 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 3 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + -2 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Enter a number between 1 and 5 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py index d45cd87ac3..236e527b2c 100644 --- a/tests/snapshot_tests/conftest.py +++ b/tests/snapshot_tests/conftest.py @@ -40,7 +40,7 @@ def snap_compare( def compare( app_path: str | PurePath, - press: Iterable[str] = ("_",), + press: Iterable[str] = (), terminal_size: tuple[int, int] = (80, 24), run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, ) -> bool: diff --git a/tests/snapshot_tests/snapshot_apps/input_suggestions.py b/tests/snapshot_tests/snapshot_apps/input_suggestions.py new file mode 100644 index 0000000000..d932066364 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/input_suggestions.py @@ -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() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f960e18558..3e14c4ecd3 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -97,6 +97,10 @@ def test_input_validation(snap_compare): 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): # Testing button rendering. We press tab to focus the first button too. 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): assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py") + def test_selection_list_selected(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py") + def test_selection_list_selections(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py") + def test_selection_list_tuples(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py") + def test_select_expanded(snap_compare): assert snap_compare( 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): # https://github.com/Textualize/textual/issues/2525 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): # 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): diff --git a/tests/suggester/test_input_suggestions.py b/tests/suggester/test_input_suggestions.py new file mode 100644 index 0000000000..e70e415932 --- /dev/null +++ b/tests/suggester/test_input_suggestions.py @@ -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" diff --git a/tests/suggester/test_suggest_from_list.py b/tests/suggester/test_suggest_from_list.py new file mode 100644 index 0000000000..833c5e4b20 --- /dev/null +++ b/tests/suggester/test_suggest_from_list.py @@ -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)] diff --git a/tests/suggester/test_suggester.py b/tests/suggester/test_suggester.py new file mode 100644 index 0000000000..91c80f7e82 --- /dev/null +++ b/tests/suggester/test_suggester.py @@ -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