From f464241fd6af1c8794ff7830adc24513e001a319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 13:35:08 +0100 Subject: [PATCH 01/17] Implement auto-completion. --- CHANGELOG.md | 7 +++ src/textual/widgets/_input.py | 84 +++++++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bf44a47d..1bc65e312d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Added + +- `Input` widget now supports auto-completion https://github.com/Textualize/textual/issues/2330 + + ## [0.25.0] - 2023-05-17 ### Changed diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index e14dcdf109..9af02b4800 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -1,7 +1,7 @@ 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 @@ -9,7 +9,7 @@ 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 @@ -31,13 +31,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) + completion = input._completion + show_completion = completion.startswith(value) and ( + len(completion) > value_length + ) + if show_completion: + result += Text( + completion[value_length:], + input.get_component_rich_style("input--suggestion"), + ) + if self.cursor_visible and input.has_focus: + if not show_completion 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: @@ -80,7 +95,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 auto-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. | @@ -93,12 +108,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 = """ @@ -119,7 +139,7 @@ class Input(Widget, can_focus=True): color: $text; text-style: reverse; } - Input>.input--placeholder { + Input>.input--placeholder, Input>.input--suggestion { color: $text-disabled; } """ @@ -135,6 +155,13 @@ class Input(Widget, can_focus=True): _cursor_visible = reactive(True) password = reactive(False) max_size: reactive[int | None] = reactive(None) + completions = reactive[Optional[List[str]]](None) + """List of auto-completions that are suggested while the user types. + + The precedence of the suggestions is inferred from the order of the list. + Set this to `None` or to an empty list to disable auto-suggestions.""" + _completion = reactive("") + """A completion suggestion for the current value in the input.""" class Changed(Message, bubble=True): """Posted when the value changes. @@ -184,6 +211,8 @@ def __init__( placeholder: str = "", highlighter: Highlighter | None = None, password: bool = False, + *, + completions: Iterable[str] | None = None, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -195,7 +224,8 @@ 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. + completions: Possible auto-completions for the input field. name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. @@ -207,6 +237,7 @@ def __init__( self.placeholder = placeholder self.highlighter = highlighter self.password = password + self.completions = completions def _position_to_cell(self, position: int) -> int: """Convert an index within the value to cell position.""" @@ -252,6 +283,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._completion = "" + if self.completions and value: + self._get_completion() if self.styles.auto_dimensions: self.refresh(layout=True) self.post_message(self.Changed(self, value)) @@ -359,7 +393,7 @@ def insert_text_at_cursor(self, text: str) -> None: 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: @@ -374,8 +408,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._completion: + self.value = self._completion + 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.""" @@ -492,3 +530,23 @@ def action_delete_left_all(self) -> None: async def action_submit(self) -> None: """Handle a submit action (normally the user hitting Enter in the input).""" self.post_message(self.Submitted(self, self.value)) + + def validate_completions( + self, completions: Iterable[str] | None + ) -> list[str] | None: + """Convert completions iterable to a list.""" + if completions is None: + return None + return list(completions) + + @work(exclusive=True) + def _get_completion(self) -> None: + """Try to get a completion to suggest to the user.""" + if not self.completions: + return + + value = self.value + for completion in self.completions: + if completion.startswith(value): + self._completion = completion + break From 0c520f13ce0d24eac41dbc39aa9a4a2e7af4cf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 13:58:59 +0100 Subject: [PATCH 02/17] Change naming. --- CHANGELOG.md | 9 +++++- src/textual/widgets/_input.py | 61 ++++++++++++++++++----------------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc65e312d..d702b2e246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- `Input` widget now supports auto-completion https://github.com/Textualize/textual/issues/2330 +- `Input` widget now supports showing automatic input suggestions https://github.com/Textualize/textual/issues/2330 +- `Input` accepts a parameter `suggestions` with a list of suggestions that show up while the user types https://github.com/Textualize/textual/pull/2604 +- `Input.suggestions` reactive can be used to change the possible completions for a given input https://github.com/Textualize/textual/pull/2604 +- `Input` has 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 ## [0.25.0] - 2023-05-17 diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 9af02b4800..d0432871a9 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -36,18 +36,18 @@ def __rich_console__( # Add the completion with a faded style. value = input.value value_length = len(value) - completion = input._completion - show_completion = completion.startswith(value) and ( - len(completion) > value_length + suggestion = input._suggestion + show_suggestion = suggestion.startswith(value) and ( + len(suggestion) > value_length ) - if show_completion: + if show_suggestion: result += Text( - completion[value_length:], + suggestion[value_length:], input.get_component_rich_style("input--suggestion"), ) if self.cursor_visible and input.has_focus: - if not show_completion and input._cursor_at_end: + 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 @@ -95,7 +95,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 or accept the auto-completion suggestion. | + | 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. | @@ -155,12 +155,13 @@ class Input(Widget, can_focus=True): _cursor_visible = reactive(True) password = reactive(False) max_size: reactive[int | None] = reactive(None) - completions = reactive[Optional[List[str]]](None) - """List of auto-completions that are suggested while the user types. + suggestions = reactive[Optional[List[str]]](None) + """List of completion suggestions that are shown while the user types. The precedence of the suggestions is inferred from the order of the list. - Set this to `None` or to an empty list to disable auto-suggestions.""" - _completion = reactive("") + Set this to `None` or to an empty list to disable this feature.. + """ + _suggestion = reactive("") """A completion suggestion for the current value in the input.""" class Changed(Message, bubble=True): @@ -212,7 +213,7 @@ def __init__( highlighter: Highlighter | None = None, password: bool = False, *, - completions: Iterable[str] | None = None, + suggestions: Iterable[str] | None = None, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -225,7 +226,7 @@ def __init__( 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. - completions: Possible auto-completions for the input field. + suggestions: Possible auto-completions for the input field. name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. @@ -237,7 +238,7 @@ def __init__( self.placeholder = placeholder self.highlighter = highlighter self.password = password - self.completions = completions + self.suggestions = suggestions def _position_to_cell(self, position: int) -> int: """Convert an index within the value to cell position.""" @@ -283,9 +284,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._completion = "" - if self.completions and value: - self._get_completion() + self._suggestion = "" + if self.suggestions and value: + self._get_suggestion() if self.styles.auto_dimensions: self.refresh(layout=True) self.post_message(self.Changed(self, value)) @@ -409,8 +410,8 @@ def action_cursor_left(self) -> None: def action_cursor_right(self) -> None: """Accept an auto-completion or move the cursor one position to the right.""" - if self._cursor_at_end and self._completion: - self.value = self._completion + if self._cursor_at_end and self._suggestion: + self.value = self._suggestion self.cursor_position = len(self.value) else: self.cursor_position += 1 @@ -531,22 +532,22 @@ async def action_submit(self) -> None: """Handle a submit action (normally the user hitting Enter in the input).""" self.post_message(self.Submitted(self, self.value)) - def validate_completions( - self, completions: Iterable[str] | None + def validate_suggestions( + self, suggestions: Iterable[str] | None ) -> list[str] | None: - """Convert completions iterable to a list.""" - if completions is None: + """Convert suggestions iterable into a list.""" + if suggestions is None: return None - return list(completions) + return list(suggestions) @work(exclusive=True) - def _get_completion(self) -> None: - """Try to get a completion to suggest to the user.""" - if not self.completions: + def _get_suggestion(self) -> None: + """Try to get a suggestion for the user.""" + if not self.suggestions: return value = self.value - for completion in self.completions: - if completion.startswith(value): - self._completion = completion + for suggestion in self.suggestions: + if suggestion.startswith(value): + self._suggestion = suggestion break From 03a43521ddcd9d6fce81ea985fcece7b3a2968f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 14:02:51 +0100 Subject: [PATCH 03/17] Fix test. --- .../__snapshots__/test_snapshots.ambr | 118 +++++++++--------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index c0e2e96b98..9e342171ac 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -16496,135 +16496,135 @@ 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + From bfdaf02f667ff2fddf50cf1272ce56bcaf336a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 14:26:32 +0100 Subject: [PATCH 04/17] Add snapshot test. --- tests/input/test_suggestions.py | 0 .../__snapshots__/test_snapshots.ambr | 160 ++++++++++++++++++ tests/snapshot_tests/conftest.py | 2 +- .../snapshot_apps/input_suggestions.py | 22 +++ tests/snapshot_tests/test_snapshots.py | 8 +- 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 tests/input/test_suggestions.py create mode 100644 tests/snapshot_tests/snapshot_apps/input_suggestions.py diff --git a/tests/input/test_suggestions.py b/tests/input/test_suggestions.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 9e342171ac..260107313e 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -16632,6 +16632,166 @@ ''' # --- +# name: test_input_suggestions + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FruitsApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + strawberry + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_key_display ''' 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..1ddc4ba42b --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/input_suggestions.py @@ -0,0 +1,22 @@ +from textual.app import App, ComposeResult +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", suggestions=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 bdedbced39..0c8fb5abba 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -84,6 +84,10 @@ def test_input_and_focus(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "input.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"]) @@ -463,7 +467,9 @@ 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=["_"], ) From 97f30a21fbbe3280ae1ef289beb7903cca070159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 14:50:28 +0100 Subject: [PATCH 05/17] Add tests. --- tests/input/test_suggestions.py | 100 ++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/input/test_suggestions.py b/tests/input/test_suggestions.py index e69de29bb2..ca310d29fb 100644 --- a/tests/input/test_suggestions.py +++ b/tests/input/test_suggestions.py @@ -0,0 +1,100 @@ +import string + +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import Input + + +class SuggestionsApp(App[ComposeResult]): + def __init__(self, suggestions=None): + self.suggestions = suggestions + self.input = Input(suggestions=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" From 75606c8dfdec251e396f39ba70fca9b440892702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 16:11:02 +0100 Subject: [PATCH 06/17] Add explicit sleep. --- tests/snapshot_tests/test_snapshots.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 0c8fb5abba..6f0a97faf3 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -475,7 +475,9 @@ def test_dock_scroll_off_by_one(snap_compare): 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): From 297549c7d80e34f73c98c55c928abfc2cded14f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 May 2023 10:47:22 +0100 Subject: [PATCH 07/17] V2 of input suggestions API. --- src/textual/suggester.py | 84 +++++++++++++++++++ src/textual/widgets/_input.py | 45 ++++------ tests/input/test_suggestions.py | 7 +- .../snapshot_apps/input_suggestions.py | 3 +- 4 files changed, 104 insertions(+), 35 deletions(-) create mode 100644 src/textual/suggester.py diff --git a/src/textual/suggester.py b/src/textual/suggester.py new file mode 100644 index 0000000000..888ce7cee0 --- /dev/null +++ b/src/textual/suggester.py @@ -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: + """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() + + +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 diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index d0432871a9..104b7c9701 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -16,6 +16,7 @@ from ..geometry import Size from ..message import Message from ..reactive import reactive +from ..suggester import Suggester, SuggestionReady from ..widget import Widget @@ -155,12 +156,8 @@ class Input(Widget, can_focus=True): _cursor_visible = reactive(True) password = reactive(False) max_size: reactive[int | None] = reactive(None) - suggestions = reactive[Optional[List[str]]](None) - """List of completion suggestions that are shown while the user types. - - The precedence of the suggestions is inferred from the order of the list. - Set this to `None` or to an empty list to disable this feature.. - """ + 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.""" @@ -213,7 +210,7 @@ def __init__( highlighter: Highlighter | None = None, password: bool = False, *, - suggestions: Iterable[str] | None = None, + suggester: Suggester | None = None, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -226,7 +223,8 @@ def __init__( 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. - suggestions: Possible auto-completions for the input field. + 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. @@ -238,7 +236,7 @@ def __init__( self.placeholder = placeholder self.highlighter = highlighter self.password = password - self.suggestions = suggestions + self.suggester = suggester def _position_to_cell(self, position: int) -> int: """Convert an index within the value to cell position.""" @@ -285,8 +283,8 @@ def watch_cursor_position(self, cursor_position: int) -> None: async def watch_value(self, value: str) -> None: self._suggestion = "" - if self.suggestions and value: - self._get_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)) @@ -388,6 +386,11 @@ 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. @@ -531,23 +534,3 @@ def action_delete_left_all(self) -> None: async def action_submit(self) -> None: """Handle a submit action (normally the user hitting Enter in the input).""" self.post_message(self.Submitted(self, self.value)) - - def validate_suggestions( - self, suggestions: Iterable[str] | None - ) -> list[str] | None: - """Convert suggestions iterable into a list.""" - if suggestions is None: - return None - return list(suggestions) - - @work(exclusive=True) - def _get_suggestion(self) -> None: - """Try to get a suggestion for the user.""" - if not self.suggestions: - return - - value = self.value - for suggestion in self.suggestions: - if suggestion.startswith(value): - self._suggestion = suggestion - break diff --git a/tests/input/test_suggestions.py b/tests/input/test_suggestions.py index ca310d29fb..e70e415932 100644 --- a/tests/input/test_suggestions.py +++ b/tests/input/test_suggestions.py @@ -3,13 +3,14 @@ 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=None): + def __init__(self, suggestions): self.suggestions = suggestions - self.input = Input(suggestions=self.suggestions) + self.input = Input(suggester=SuggestFromList(self.suggestions)) super().__init__() def compose(self) -> ComposeResult: @@ -17,7 +18,7 @@ def compose(self) -> ComposeResult: async def test_no_suggestions(): - app = SuggestionsApp() + app = SuggestionsApp([]) async with app.run_test() as pilot: assert app.input._suggestion == "" await pilot.press("a") diff --git a/tests/snapshot_tests/snapshot_apps/input_suggestions.py b/tests/snapshot_tests/snapshot_apps/input_suggestions.py index 1ddc4ba42b..d932066364 100644 --- a/tests/snapshot_tests/snapshot_apps/input_suggestions.py +++ b/tests/snapshot_tests/snapshot_apps/input_suggestions.py @@ -1,4 +1,5 @@ from textual.app import App, ComposeResult +from textual.suggester import SuggestFromList from textual.widgets import Input @@ -14,7 +15,7 @@ class FruitsApp(App[None]): """ def compose(self) -> ComposeResult: - yield Input("straw", suggestions=fruits) + yield Input("straw", suggester=SuggestFromList(fruits)) if __name__ == "__main__": From 124e62e41b82459542b3bb810e9b760c24dcd223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 May 2023 10:50:00 +0100 Subject: [PATCH 08/17] Changelog. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d702b2e246..bef138a5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- `Input` widget now supports showing automatic input suggestions https://github.com/Textualize/textual/issues/2330 -- `Input` accepts a parameter `suggestions` with a list of suggestions that show up while the user types https://github.com/Textualize/textual/pull/2604 -- `Input.suggestions` reactive can be used to change the possible completions for a given input https://github.com/Textualize/textual/pull/2604 +- `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 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 for completion suggestions https://github.com/Textualize/textual/pull/2604 ## [0.25.0] - 2023-05-17 From e63ec577cdeb5f4a8b030f0960630f12da7041d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 May 2023 11:46:05 +0100 Subject: [PATCH 09/17] Address review feedback. --- CHANGELOG.md | 8 ++++---- src/textual/suggester.py | 27 +++++++++++++++------------ src/textual/widgets/_input.py | 4 ++-- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bef138a5d4..ba5bec3d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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 +- `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 for completion suggestions 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 diff --git a/src/textual/suggester.py b/src/textual/suggester.py index 888ce7cee0..8fe0da029d 100644 --- a/src/textual/suggester.py +++ b/src/textual/suggester.py @@ -1,11 +1,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Iterable, Optional +from typing import Generic, Iterable, Optional, TypeVar +from ._types import MessageTarget from .message import Message -if TYPE_CHECKING: - from .widgets import Input +_SuggestionRequester = TypeVar("_SuggestionRequester", bound=MessageTarget) +"""Type variable for the message target that will request suggestions.""" @dataclass @@ -18,7 +19,7 @@ class SuggestionReady(Message): """The string suggestion.""" -class Suggester(ABC): +class Suggester(ABC, Generic[_SuggestionRequester]): """Defines how [inputs][textual.widgets.Input] generate completion suggestions. To define a custom suggester, subclass `Suggester` and implement the async method @@ -26,22 +27,24 @@ class Suggester(ABC): See [`SuggestFromList`][textual.suggester.SuggestFromList] for an example. """ - async def get(self, input: "Input", value: str) -> None: - """Used by [`Input`][textual.widgets.Input] to get completion suggestions. + async def _get_suggestion( + self, requester: _SuggestionRequester, value: str + ) -> None: + """Used by widgets 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. + requester: The message target 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)) + requester.post_message(SuggestionReady(value, suggestion)) @abstractmethod async def get_suggestion(self, value: str) -> Optional[str]: @@ -55,10 +58,10 @@ async def get_suggestion(self, value: str) -> Optional[str]: Returns: A valid suggestion or `None`. """ - raise NotImplementedError() + pass -class SuggestFromList(Suggester): +class SuggestFromList(Suggester[_SuggestionRequester]): """Give completion suggestions based on a fixed list of options.""" def __init__(self, suggestions: Iterable[str]) -> None: @@ -73,10 +76,10 @@ 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. + value: The current value. Returns: - A valid suggestion or `None`. + A valid completion suggestion or `None`. """ for suggestion in self.suggestions: if suggestion.startswith(value): diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 104b7c9701..d87bb47dae 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -156,7 +156,7 @@ class Input(Widget, can_focus=True): _cursor_visible = reactive(True) password = reactive(False) max_size: reactive[int | None] = reactive(None) - suggester: Suggester | None + suggester: Suggester[Input] | None """The suggester used to provide completions as the user types.""" _suggestion = reactive("") """A completion suggestion for the current value in the input.""" @@ -284,7 +284,7 @@ def watch_cursor_position(self, cursor_position: int) -> None: async def watch_value(self, value: str) -> None: self._suggestion = "" if self.suggester and value: - self.call_next(self.suggester.get, self, value) + self.call_next(self.suggester._get_suggestion, self, value) if self.styles.auto_dimensions: self.refresh(layout=True) self.post_message(self.Changed(self, value)) From 239e5eebc6a1a41db9daa462ae7ac5d2d13e86e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 May 2023 15:16:24 +0100 Subject: [PATCH 10/17] Use workers to get suggestions. --- src/textual/suggester.py | 70 ++++++++++++++++++++++++----------- src/textual/widgets/_input.py | 6 +-- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/src/textual/suggester.py b/src/textual/suggester.py index 8fe0da029d..b290b18043 100644 --- a/src/textual/suggester.py +++ b/src/textual/suggester.py @@ -1,35 +1,43 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Generic, Iterable, Optional, TypeVar +from typing import Iterable -from ._types import MessageTarget +from .dom import DOMNode from .message import Message -_SuggestionRequester = TypeVar("_SuggestionRequester", bound=MessageTarget) -"""Type variable for the message target that will request suggestions.""" - @dataclass class SuggestionReady(Message): """Sent when a completion suggestion is ready.""" - input_value: str - """The input value that the suggestion was for.""" + initial_value: str + """The value to which the suggestion is for.""" suggestion: str """The string suggestion.""" -class Suggester(ABC, Generic[_SuggestionRequester]): - """Defines how [inputs][textual.widgets.Input] generate completion suggestions. +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. """ - async def _get_suggestion( - self, requester: _SuggestionRequester, value: str - ) -> None: + cache: dict[str, str | None] | None + """Suggestion cache, if used.""" + + def __init__(self, use_cache: bool = True): + """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: @@ -40,20 +48,29 @@ async def _get_suggestion( requester: The message target that requested a suggestion. value: The current input value to complete. """ - suggestion = await self.get_suggestion(value) + + 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) -> Optional[str]: + 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 input widget. + value: The current value of the requester widget. Returns: A valid suggestion or `None`. @@ -61,8 +78,18 @@ async def get_suggestion(self, value: str) -> Optional[str]: pass -class SuggestFromList(Suggester[_SuggestionRequester]): - """Give completion suggestions based on a fixed list of options.""" +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. @@ -70,9 +97,10 @@ def __init__(self, suggestions: Iterable[str]) -> None: Args: suggestions: Valid suggestions sorted by decreasing priority. """ - self.suggestions = list(suggestions) + super().__init__() + self._suggestions = list(suggestions) - async def get_suggestion(self, value: str) -> Optional[str]: + async def get_suggestion(self, value: str) -> str | None: """Gets a completion from the given possibilities. Args: @@ -81,7 +109,7 @@ async def get_suggestion(self, value: str) -> Optional[str]: Returns: A valid completion suggestion or `None`. """ - for suggestion in self.suggestions: + for suggestion in self._suggestions: if suggestion.startswith(value): return suggestion return None diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index d87bb47dae..c5be91da6f 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -156,7 +156,7 @@ class Input(Widget, can_focus=True): _cursor_visible = reactive(True) password = reactive(False) max_size: reactive[int | None] = reactive(None) - suggester: Suggester[Input] | 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.""" @@ -284,7 +284,7 @@ def watch_cursor_position(self, cursor_position: int) -> None: async def watch_value(self, value: str) -> None: self._suggestion = "" if self.suggester and value: - self.call_next(self.suggester._get_suggestion, self, 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)) @@ -388,7 +388,7 @@ async def _on_click(self, event: events.Click) -> None: async def _on_suggestion_ready(self, event: SuggestionReady) -> None: """Handle suggestion messages and set the suggestion when relevant.""" - if event.input_value == self.value: + if event.initial_value == self.value: self._suggestion = event.suggestion def insert_text_at_cursor(self, text: str) -> None: From 3308cdde1cb9ecebda3fca28f20af6db4bb41016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 May 2023 15:32:49 +0100 Subject: [PATCH 11/17] Use FIFOCache. Related comments: https://github.com/Textualize/textual/pull/2604#discussion_r1202431626 --- src/textual/suggester.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/suggester.py b/src/textual/suggester.py index b290b18043..619dbb470a 100644 --- a/src/textual/suggester.py +++ b/src/textual/suggester.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Iterable +from ._cache import FIFOCache from .dom import DOMNode from .message import Message @@ -26,7 +27,7 @@ class Suggester(ABC): See [`SuggestFromList`][textual.suggester.SuggestFromList] for an example. """ - cache: dict[str, str | None] | None + cache: FIFOCache[str, str | None] | None """Suggestion cache, if used.""" def __init__(self, use_cache: bool = True): @@ -35,7 +36,7 @@ def __init__(self, use_cache: bool = True): Args: use_cache: Whether to cache suggestion results. """ - self.cache = {} if use_cache else None + self.cache = FIFOCache(1024) if use_cache else None async def _get_suggestion(self, requester: DOMNode, value: str) -> None: """Used by widgets to get completion suggestions. From 2c03956049690a293ab042a9c4be834f2d3d6afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 May 2023 17:01:20 +0100 Subject: [PATCH 12/17] Fix type hint. --- src/textual/suggester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/suggester.py b/src/textual/suggester.py index 619dbb470a..d0be59507a 100644 --- a/src/textual/suggester.py +++ b/src/textual/suggester.py @@ -30,7 +30,7 @@ class Suggester(ABC): cache: FIFOCache[str, str | None] | None """Suggestion cache, if used.""" - def __init__(self, use_cache: bool = True): + def __init__(self, use_cache: bool = True) -> None: """Create a suggester object. Args: From 0488b5ed9ca5f738b67b71bba3c16390b15d45ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 May 2023 17:22:21 +0100 Subject: [PATCH 13/17] Ignore abstractmethods. --- .coveragerc | 1 + 1 file changed, 1 insertion(+) 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 From fc86682dfa2f7f2e152db6acc31f717349a22182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 May 2023 17:32:26 +0100 Subject: [PATCH 14/17] Add tests for suggester. --- .../test_input_suggestions.py} | 0 tests/suggester/test_suggest_from_list.py | 55 +++++++++ tests/suggester/test_suggester.py | 111 ++++++++++++++++++ 3 files changed, 166 insertions(+) rename tests/{input/test_suggestions.py => suggester/test_input_suggestions.py} (100%) create mode 100644 tests/suggester/test_suggest_from_list.py create mode 100644 tests/suggester/test_suggester.py diff --git a/tests/input/test_suggestions.py b/tests/suggester/test_input_suggestions.py similarity index 100% rename from tests/input/test_suggestions.py rename to tests/suggester/test_input_suggestions.py 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 From ae266551a19522fafcb715c79a129fe565b2a58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 May 2023 17:32:40 +0100 Subject: [PATCH 15/17] Update suggester implementation. --- src/textual/suggester.py | 52 +++++++++++++++++++++++++---------- src/textual/widgets/_input.py | 6 ++-- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/textual/suggester.py b/src/textual/suggester.py index d0be59507a..adf61ea60a 100644 --- a/src/textual/suggester.py +++ b/src/textual/suggester.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Iterable -from ._cache import FIFOCache +from ._cache import LRUCache from .dom import DOMNode from .message import Message @@ -13,7 +13,7 @@ class SuggestionReady(Message): """Sent when a completion suggestion is ready.""" - initial_value: str + value: str """The value to which the suggestion is for.""" suggestion: str """The string suggestion.""" @@ -27,16 +27,20 @@ class Suggester(ABC): See [`SuggestFromList`][textual.suggester.SuggestFromList] for an example. """ - cache: FIFOCache[str, str | None] | None + cache: LRUCache[str, str | None] | None """Suggestion cache, if used.""" - def __init__(self, use_cache: bool = True) -> None: + def __init__(self, *, use_cache: bool = True, case_sensitive: bool = True) -> 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 = FIFOCache(1024) if use_cache else None + 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. @@ -47,15 +51,16 @@ async def _get_suggestion(self, requester: DOMNode, value: str) -> None: Args: requester: The message target that requested a suggestion. - value: The current input value to complete. + value: The current value to complete. """ - if self.cache is None or value not in self.cache: - suggestion = await self.get_suggestion(value) + 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[value] = suggestion + self.cache[normalized_value] = suggestion else: - suggestion = self.cache[value] + suggestion = self.cache[normalized_value] if suggestion is None: return @@ -67,6 +72,9 @@ async def get_suggestion(self, value: str) -> str | None: 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. @@ -88,18 +96,32 @@ class SuggestFromList(Suggester): class MyApp(App[None]): def compose(self) -> ComposeResult: - yield Input(suggester=SuggestFromList(countries)) + 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]) -> None: + 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__() + 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. @@ -110,7 +132,7 @@ async def get_suggestion(self, value: str) -> str | None: Returns: A valid completion suggestion or `None`. """ - for suggestion in self._suggestions: + for idx, suggestion in enumerate(self._for_comparison): if suggestion.startswith(value): - return suggestion + return self._suggestions[idx] return None diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index c5be91da6f..3581ccda81 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -38,9 +38,7 @@ def __rich_console__( value = input.value value_length = len(value) suggestion = input._suggestion - show_suggestion = suggestion.startswith(value) and ( - len(suggestion) > value_length - ) + show_suggestion = len(suggestion) > value_length if show_suggestion: result += Text( suggestion[value_length:], @@ -388,7 +386,7 @@ async def _on_click(self, event: events.Click) -> None: async def _on_suggestion_ready(self, event: SuggestionReady) -> None: """Handle suggestion messages and set the suggestion when relevant.""" - if event.initial_value == self.value: + if event.value == self.value: self._suggestion = event.suggestion def insert_text_at_cursor(self, text: str) -> None: From fd57309ad078c0b7cc98d568f61b09708c35e0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 May 2023 17:57:33 +0100 Subject: [PATCH 16/17] Make validators kwd-only. --- src/textual/widgets/_input.py | 8 +- .../__snapshots__/test_snapshots.ambr | 240 +++++++++--------- 2 files changed, 124 insertions(+), 124 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 3d89768382..1c1ca124ae 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -2,7 +2,7 @@ import re from dataclasses import dataclass -from typing import ClassVar, Iterable, List, Optional +from typing import ClassVar, Iterable from rich.cells import cell_len, get_character_cell_size from rich.console import Console, ConsoleOptions, RenderableType, RenderResult @@ -10,7 +10,7 @@ from rich.segment import Segment from rich.text import Text -from .. import events, work +from .. import events from .._segment_tools import line_crop from ..binding import Binding, BindingType from ..events import Blur, Focus, Mount @@ -18,7 +18,7 @@ from ..message import Message from ..reactive import reactive from ..suggester import Suggester, SuggestionReady -from ..validation import Failure, ValidationResult, Validator +from ..validation import ValidationResult, Validator from ..widget import Widget @@ -218,9 +218,9 @@ def __init__( placeholder: str = "", highlighter: Highlighter | None = None, password: bool = False, - validators: Validator | Iterable[Validator] | None = None, *, suggester: Suggester | None = None, + validators: Validator | Iterable[Validator] | None = None, name: str | None = None, id: str | None = None, classes: str | None = None, diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 10b402636f..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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + @@ -16973,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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + From baa1f712d8b335ed00ba2a22c79bb3803b268645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 29 May 2023 14:24:49 +0100 Subject: [PATCH 17/17] Change default case sensitivity. --- src/textual/suggester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/suggester.py b/src/textual/suggester.py index adf61ea60a..362fe89f6d 100644 --- a/src/textual/suggester.py +++ b/src/textual/suggester.py @@ -30,7 +30,7 @@ class Suggester(ABC): cache: LRUCache[str, str | None] | None """Suggestion cache, if used.""" - def __init__(self, *, use_cache: bool = True, case_sensitive: bool = True) -> None: + def __init__(self, *, use_cache: bool = True, case_sensitive: bool = False) -> None: """Create a suggester object. Args: