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
+ '''
+
+
+ '''
+# ---
# name: test_key_display
'''