Skip to content

Commit f6db655

Browse files
committed
Skeleton code for subscription based autocompletion
0 parents  commit f6db655

File tree

7 files changed

+693
-0
lines changed

7 files changed

+693
-0
lines changed

README.md

Whitespace-only changes.

poetry.lock

Lines changed: 540 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[tool.poetry]
2+
name = "textual-autocomplete"
3+
version = "0.1.0"
4+
description = ""
5+
authors = ["Darren Burns <[email protected]>"]
6+
readme = "README.md"
7+
packages = [{include = "textual_autocomplete"}]
8+
9+
[tool.poetry.dependencies]
10+
python = "^3.7.8"
11+
textual = "^0.5.0"
12+
13+
14+
[tool.poetry.group.dev.dependencies]
15+
mypy = "^0.991"
16+
black = "^22.10.0"
17+
ward = "^0.67.0b0"
18+
19+
[build-system]
20+
requires = ["poetry-core"]
21+
build-backend = "poetry.core.masonry.api"

tests/__init__.py

Whitespace-only changes.

textual_autocomplete/__init__.py

Whitespace-only changes.

textual_autocomplete/_autocomplete.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Iterable, Callable
5+
6+
from rich import Console
7+
from rich.console import ConsoleOptions, RenderableType
8+
from rich.text import Text
9+
from textual import events
10+
from textual.app import ComposeResult
11+
from textual.css.styles import RenderStyles
12+
from textual.reactive import watch
13+
from textual.widget import Widget
14+
from textual.widgets import Input
15+
16+
17+
class AutocompleteError(Exception):
18+
pass
19+
20+
21+
class DropdownRender:
22+
def __init__(
23+
self,
24+
filter: str,
25+
matches: Iterable[CompletionCandidate],
26+
highlight_index: int,
27+
component_styles: dict[str, RenderStyles],
28+
) -> None:
29+
self.filter = filter
30+
self.matches = matches
31+
self.highlight_index = highlight_index
32+
self.component_styles = component_styles
33+
self._highlight_item_style = self.component_styles.get(
34+
"search-completion--selected-item"
35+
).rich_style
36+
37+
def __rich_console__(self, console: Console, options: ConsoleOptions):
38+
matches = []
39+
for index, match in enumerate(self.matches):
40+
if not match.secondary:
41+
secondary_text = self._find_secondary_text(match.original_object)
42+
else:
43+
secondary_text = match.secondary
44+
match = Text.from_markup(
45+
f"{match.primary:<{options.max_width - 3}}[dim]{secondary_text}"
46+
)
47+
matches.append(match)
48+
if self.highlight_index == index:
49+
match.stylize(self._highlight_item_style)
50+
match.highlight_regex(self.filter, style="black on #4EBF71")
51+
52+
return Text("\n").join(matches)
53+
54+
55+
@dataclass
56+
class AutocompleteOption:
57+
"""A single option appearing in the autocompletion dropdown. Each option has up to 3 columns.
58+
59+
Args:
60+
left: The left column will often contain an icon/symbol, the main (middle)
61+
column contains the text that represents this option.
62+
main: The main text representing this option - this will be highlighted by default.
63+
In an IDE, the `main` (middle) column might contain the name of a function or method.
64+
right: The text appearing in the right column of the dropdown.
65+
The right column often contains some metadata relating to this option.
66+
67+
"""
68+
left: str = ""
69+
main: str = ""
70+
right: str = ""
71+
72+
73+
class Autocomplete(Widget):
74+
"""An autocompletion dropdown widget. This widget gets linked to an Input widget, and is automatically
75+
updated based on the state of that Input."""
76+
77+
DEFAULT_CSS = """\
78+
Autocomplete {
79+
layer: textual-autocomplete;
80+
}
81+
"""
82+
83+
def __init__(
84+
self,
85+
linked_input: Input | str,
86+
get_results: Callable[[str, int], list[AutocompleteOption]],
87+
id: str | None = None,
88+
classes: str | None = None,
89+
):
90+
"""Construct an Autocomplete. Autocomplete only works if your Screen has a dedicated layer
91+
called `textual-autocomplete`.
92+
93+
Args:
94+
linked_input: A reference to the Input Widget to add autocomplete to, or a selector/query string
95+
identifying the Input Widget that should power this autocomplete.
96+
get_results: Function to call to retrieve the list of completion results for the current input value.
97+
Function takes the current input value and cursor position as arguments, and returns a list of
98+
`AutoCompleteOption` which will be displayed as a dropdown list.
99+
id: The ID of the widget, allowing you to directly refer to it using CSS and queries.
100+
classes: The classes of this widget, a space separated string.
101+
"""
102+
103+
if isinstance(linked_input, str):
104+
# If the user supplied a selector, find the Input to subscribe to
105+
self._input_widget = self.app.query_one(linked_input)
106+
else:
107+
self._input_widget = linked_input
108+
109+
super().__init__(
110+
linked_input,
111+
id=id,
112+
classes=classes,
113+
)
114+
self._get_results = get_results
115+
116+
# Configure the watch methods - we want to subscribe to a couple of the reactives inside the Input
117+
# so that we can react accordingly.
118+
# TODO: Error cases - Handle case where reference to input widget no longer exists for example
119+
watch(self._input_widget, attribute_name="cursor_position", callback=self._input_cursor_position_changed)
120+
watch(self._input_widget, attribute_name="value", callback=self._input_value_changed)
121+
122+
def on_mount(self, event: events.Mount) -> None:
123+
if "textual-autocomplete" not in self.screen.layers:
124+
raise AutocompleteError(
125+
"Screen must have a layer called `textual-autocomplete`."
126+
)
127+
128+
def _input_cursor_position_changed(self) -> None:
129+
return
130+
131+
def _input_value_changed(self) -> None:
132+
return

textual_autocomplete/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)