├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── examples
├── _headers.py
├── basic_single_column.py
├── basic_two_column.py
├── basic_two_column_heavy_styles.py
├── dynamic_data.py
├── left_column_styling.py
├── partial_completions.py
└── path_completions_absolute.py
├── pyproject.toml
├── tests
└── snapshots
│ ├── __snapshots__
│ ├── test_cursor_tracking
│ │ ├── test_dropdown_tracks_input_cursor_and_cursor_prefix_as_search_string.svg
│ │ ├── test_dropdown_tracks_input_cursor_on_click_and_cursor_prefix_search_string.svg
│ │ └── test_dropdown_tracks_terminal_cursor_when_parent_scrolls.svg
│ ├── test_input
│ │ ├── test_candidate_can_be_selected_via_click.svg
│ │ ├── test_completion_still_works_if_chosen_while_input_widget_has_selection.svg
│ │ ├── test_hide_after_summoning_by_pressing_escape.svg
│ │ ├── test_hide_after_typing_by_pressing_escape.svg
│ │ ├── test_many_matching_candidates.svg
│ │ ├── test_multiple_autocomplete_dropdowns_on_a_single_input.svg
│ │ ├── test_multiple_autocomplete_dropdowns_on_same_screen.svg
│ │ ├── test_selecting_candidate_should_complete_input__enter_key.svg
│ │ ├── test_selecting_candidate_should_complete_input__tab_key.svg
│ │ ├── test_single_matching_candidate.svg
│ │ ├── test_summon_by_pressing_down.svg
│ │ ├── test_summon_by_pressing_down_after_performing_completion.svg
│ │ ├── test_summon_when_only_one_full_match_does_not_show_dropdown.svg
│ │ ├── test_tab_still_works_after_completion.svg
│ │ └── test_text_selection_works_while_autocomplete_is_open.svg
│ └── test_styling
│ │ ├── test_background_color_and_removed_style.svg
│ │ ├── test_cursor_color_change_and_dropdown_background_change.svg
│ │ ├── test_dropdown_styles_match_textual_theme.svg
│ │ ├── test_foreground_color_and_text_style.svg
│ │ └── test_max_height_and_scrolling.svg
│ ├── test_cursor_tracking.py
│ ├── test_function_candidates.py
│ ├── test_input.py
│ ├── test_prevent_default.py
│ └── test_styling.py
├── textual_autocomplete
├── __init__.py
├── _autocomplete.py
├── _path_autocomplete.py
├── fuzzy_search.py
└── py.typed
└── uv.lock
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - "main"
8 |
9 | env:
10 | PYTEST_ADDOPTS: "--color=yes"
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Install a specific version of uv
19 | uses: astral-sh/setup-uv@v5
20 | with:
21 | version: "0.6.3"
22 | enable-cache: true
23 |
24 | - name: Set up Python
25 | run: uv python install 3.9
26 |
27 | - name: Install Dependencies
28 | run: uv sync --all-extras --dev
29 |
30 | - name: Run Tests
31 | run: |
32 | uv run pytest tests/
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | .idea/
161 |
162 | snapshot_report.html
163 | sandbox/
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.testing.pytestArgs": [
3 | "tests"
4 | ],
5 | "python.testing.unittestEnabled": false,
6 | "python.testing.pytestEnabled": true
7 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Darren Burns
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # textual-autocomplete
2 |
3 | A simple autocomplete dropdown library for [Textual](https://github.com/textualize/textual) `Input` widgets.
4 |
5 | 
6 |
7 | Compatible with **Textual 2.0 and above**.
8 |
9 | ## Installation
10 |
11 | I recommend using [uv](https://docs.astral.sh/uv/) to manage your dependencies and install `textual-autocomplete`:
12 |
13 | ```bash
14 | uv add textual-autocomplete
15 | ```
16 |
17 | If you prefer `pip`, `poetry`, or something else, those will work too.
18 |
19 | ## Quick Start
20 |
21 | Here's the simplest possible way to add autocomplete to your Textual app:
22 |
23 | ```python
24 | from textual.app import App, ComposeResult
25 | from textual.widgets import Input
26 | from textual_autocomplete import AutoComplete, DropdownItem
27 |
28 | class ColorFinder(App):
29 | def compose(self) -> ComposeResult:
30 | # Create a standard Textual input
31 | text_input = Input(placeholder="Type a color...")
32 | yield text_input
33 |
34 | # Add an autocomplete to the same screen, and pass in the input widget.
35 | yield AutoComplete(
36 | text_input, # Target input widget
37 | candidates=["Red", "Green", "Blue", "Yellow", "Purple", "Orange"]
38 | )
39 |
40 | if __name__ == "__main__":
41 | app = ColorFinder()
42 | app.run()
43 | ```
44 |
45 | That's it! As you type in the input field, matching options will appear in a dropdown below.
46 |
47 | ## Core Features
48 |
49 | - 🔍 **Fuzzy matching** - Find matches even with typos
50 | - ⌨️ **Keyboard navigation** - Arrow keys, Tab, Enter, and Escape
51 | - 🎨 **Rich styling options** - Customizable highlighting and appearance
52 | - 📝 **Dynamic content** - Supply items as a list or from a callback function
53 | - 🔍 **Path completions** - Built-in support for filesystem path completions
54 |
55 | ## Examples
56 |
57 | ### With Left Metadata Column
58 |
59 | Add a metadata column (like icons) to provide additional context.
60 | These columns are display-only, and do not influence the search process.
61 |
62 | ```python
63 | from textual.app import App, ComposeResult
64 | from textual.widgets import Input
65 | from textual_autocomplete import AutoComplete, DropdownItem
66 |
67 | # Create dropdown items with a left metadata column.
68 | ITEMS = [
69 | DropdownItem(main="Python", prefix="🐍"),
70 | DropdownItem(main="JavaScript", prefix="📜"),
71 | DropdownItem(main="TypeScript", prefix="🔷"),
72 | DropdownItem(main="Java", prefix="☕"),
73 | ]
74 |
75 | class LanguageSearcher(App):
76 | def compose(self) -> ComposeResult:
77 | text_input = Input(placeholder="Programming language...")
78 | yield text_input
79 | yield AutoComplete(text_input, candidates=ITEMS)
80 |
81 | if __name__ == "__main__":
82 | app = LanguageSearcher()
83 | app.run()
84 | ```
85 |
86 | ### Styled Two-Column Layout
87 |
88 | Add rich styling to your metadata columns using [Textual markup](https://textual.textualize.io/guide/content/#markup).
89 |
90 | ```python
91 | from textual.app import App, ComposeResult
92 | from textual.content import Content
93 | from textual.widgets import Input, Label
94 | from textual_autocomplete import AutoComplete, DropdownItem
95 |
96 | # Languages with their popularity rank
97 | LANGUAGES_WITH_RANK = [
98 | (1, "Python"),
99 | (2, "JavaScript"),
100 | (3, "Java"),
101 | (4, "C++"),
102 | (5, "TypeScript"),
103 | (6, "Go"),
104 | (7, "Ruby"),
105 | (8, "Rust"),
106 | ]
107 |
108 | # Create dropdown items with styled rank in prefix
109 | CANDIDATES = [
110 | DropdownItem(
111 | language, # Main text to be completed
112 | prefix=Content.from_markup(
113 | f"[$text-primary on $primary-muted] {rank:>2} "
114 | ), # Prefix with styled rank
115 | )
116 | for rank, language in LANGUAGES_WITH_RANK
117 | ]
118 |
119 | class LanguageSearcher(App):
120 | def compose(self) -> ComposeResult:
121 | yield Label("Start typing a programming language:")
122 | text_input = Input(placeholder="Type here...")
123 | yield text_input
124 | yield AutoComplete(target=text_input, candidates=CANDIDATES)
125 |
126 | if __name__ == "__main__":
127 | app = LanguageSearcher()
128 | app.run()
129 | ```
130 |
131 | ## Keyboard Controls
132 |
133 | - **↑/↓** - Navigate through options
134 | - **↓** - Summon the dropdown
135 | - **Enter/Tab** - Complete the selected option
136 | - **Escape** - Hide dropdown
137 |
138 | ## Styling
139 |
140 | The dropdown can be styled using Textual CSS:
141 |
142 | ```css
143 | AutoComplete {
144 | /* Customize the dropdown */
145 | & AutoCompleteList {
146 | max-height: 6; /* The number of lines before scrollbars appear */
147 | color: $text-primary; /* The color of the text */
148 | background: $primary-muted; /* The background color of the dropdown */
149 | border-left: wide $success; /* The color of the left border */
150 | }
151 |
152 | /* Customize the matching substring highlighting */
153 | & .autocomplete--highlight-match {
154 | color: $text-accent;
155 | text-style: bold;
156 | }
157 |
158 | /* Customize the text the cursor is over */
159 | & .option-list--option-highlighted {
160 | color: $text-success;
161 | background: $error 50%; /* 50% opacity, blending into background */
162 | text-style: italic;
163 | }
164 | }
165 | ```
166 |
167 | Here's what that looks like when applied:
168 |
169 |
170 |
171 | By using Textual CSS like in the example above, you can ensure the shades of colors remain
172 | consistent across different themes. Here's the same dropdown with the Textual app theme switched to `gruvbox`:
173 |
174 |
175 |
176 | ### Styling the prefix
177 |
178 | You can style the prefix using Textual Content markup.
179 |
180 | ```python
181 | DropdownItem(
182 | main="Python",
183 | prefix=Content.from_markup(
184 | "[$text-success on $success-muted] 🐍"
185 | ),
186 | )
187 | ```
188 |
189 | ## Completing Paths
190 |
191 | `textual-autocomplete` includes a `PathAutoComplete` widget that can be used to autocomplete filesystem paths.
192 |
193 | ```python
194 | from textual.app import App, ComposeResult
195 | from textual.containers import Container
196 | from textual.widgets import Button, Input, Label
197 |
198 | from textual_autocomplete import PathAutoComplete
199 |
200 | class FileSystemPathCompletions(App[None]):
201 | def compose(self) -> ComposeResult:
202 | yield Label("Choose a file!", id="label")
203 | input_widget = Input(placeholder="Enter a path...")
204 | yield input_widget
205 | yield PathAutoComplete(target=input_widget, path="../textual")
206 |
207 |
208 | if __name__ == "__main__":
209 | app = FileSystemPathCompletions()
210 | app.run()
211 | ```
212 |
213 | Here's what that looks like in action:
214 |
215 | https://github.com/user-attachments/assets/25b80e34-0a35-4962-9024-f2dab7666689
216 |
217 | `PathAutoComplete` has a bunch of parameters that can be used to customize the behavior - check the docstring for more details. It'll also cache directory contents after reading them once - but you can clear the cache if you need to using the `clear_directory_cache` method.
218 |
219 | ## Dynamic Data with Callbacks
220 |
221 | Instead of supplying a static list of candidates, you can supply a callback function which returns a list of `DropdownItem` (candidates) that will be searched against.
222 |
223 | This callback function will be called anytime the text in the target input widget changes or the cursor position changes (and since the cursor position changes when the user inserts text, you can expect 2 calls to this function for most keystrokes - cache accordingly if this is a problem).
224 |
225 | The app below displays the length of the text in the input widget in the prefix of the dropdown items.
226 |
227 | ```python
228 | from textual.app import App, ComposeResult
229 | from textual.widgets import Input
230 |
231 | from textual_autocomplete import AutoComplete
232 | from textual_autocomplete._autocomplete import DropdownItem, TargetState
233 |
234 |
235 | class DynamicDataApp(App[None]):
236 | def compose(self) -> ComposeResult:
237 | input_widget = Input()
238 | yield input_widget
239 | yield AutoComplete(input_widget, candidates=self.candidates_callback)
240 |
241 | def candidates_callback(self, state: TargetState) -> list[DropdownItem]:
242 | left = len(state.text)
243 | return [
244 | DropdownItem(item, prefix=f"{left:>2} ")
245 | for item in [
246 | "Apple",
247 | "Banana",
248 | "Cherry",
249 | "Orange",
250 | "Pineapple",
251 | "Strawberry",
252 | "Watermelon",
253 | ]
254 | ]
255 |
256 |
257 | if __name__ == "__main__":
258 | app = DynamicDataApp()
259 | app.run()
260 | ```
261 |
262 | Notice the count displayed in the prefix increment and decrement based on the character count in the input.
263 |
264 | 
265 |
266 | ## Customizing Behavior
267 |
268 | If you need custom behavior, `AutoComplete` can be subclassed.
269 |
270 | A good example of how to subclass and customize behavior is the `PathAutoComplete` widget, which is a subclass of `AutoComplete`.
271 |
272 | Some methods you may want to be aware of which you can override:
273 |
274 | - `get_candidates`: Return a list of `DropdownItem` objects - called each time the input changes or the cursor position changes. Note that if you're overriding this in a subclass, you'll need to make sure that the `get_candidates` parameter passed into the `AutoComplete` constructor is set to `None` - this tells `textual-autocomplete` to use the subclassed method instead of the default.
275 | - `get_search_string`: The string that will be used to filter the candidates. You may wish to only use a portion of the input text to filter the candidates rather than the entire text.
276 | - `apply_completion`: Apply the completion to the target input widget. Receives the value the user selected from the dropdown and updates the `Input` directly using it's API.
277 | - `post_completion`: Called when a completion is selected. Called immediately after `apply_completion`. The default behaviour is just to hide the completion dropdown (after performing a completion, we want to immediately hide the dropdown in the default case).
278 |
279 | ## More Examples
280 |
281 | Check out the [examples directory](./examples) for more runnable examples.
282 |
283 | ## Contributing
284 |
285 | Contributions are welcome! Feel free to open issues or submit pull requests on GitHub.
286 |
--------------------------------------------------------------------------------
/examples/basic_single_column.py:
--------------------------------------------------------------------------------
1 | """Basic dropdown autocomplete from a list of options."""
2 |
3 | from textual.app import App, ComposeResult
4 | from textual.containers import Container
5 | from textual.widgets import Input
6 |
7 | from textual_autocomplete import AutoComplete
8 |
9 | LANGUAGES = [
10 | "Python",
11 | "JavaScript",
12 | "TypeScript",
13 | "Java",
14 | "C++",
15 | "Ruby",
16 | "Go",
17 | "Rust",
18 | ]
19 |
20 |
21 | class AutoCompleteExample(App[None]):
22 | def compose(self) -> ComposeResult:
23 | with Container(id="container"):
24 | text_input = Input(placeholder="Search for a programming language...")
25 | yield text_input
26 |
27 | yield AutoComplete(
28 | target=text_input, # The widget to attach autocomplete to
29 | candidates=LANGUAGES, # The list of completion candidates
30 | )
31 |
32 |
33 | if __name__ == "__main__":
34 | app = AutoCompleteExample()
35 | app.run()
36 |
--------------------------------------------------------------------------------
/examples/basic_two_column.py:
--------------------------------------------------------------------------------
1 | """Two column dropdown example with simple styling on the prefix."""
2 |
3 | from textual.app import App, ComposeResult
4 | from textual.content import Content
5 | from textual.widgets import Input, Label
6 |
7 | from textual_autocomplete import AutoComplete, DropdownItem
8 |
9 | # Languages with their popularity rank
10 | LANGUAGES_WITH_RANK = [
11 | (1, "Python"),
12 | (2, "JavaScript"),
13 | (3, "Java"),
14 | (4, "C++"),
15 | (5, "TypeScript"),
16 | (6, "Go"),
17 | (7, "Ruby"),
18 | (8, "Rust"),
19 | ]
20 |
21 | # Create dropdown items with two columns: rank and language name
22 | CANDIDATES = [
23 | DropdownItem(
24 | language, # Main text to be completed
25 | prefix=Content.from_markup(
26 | f"[$text-primary on $primary-muted] {rank} "
27 | ), # Prefix showing rank, styled with Textual markup!
28 | )
29 | for rank, language in LANGUAGES_WITH_RANK
30 | ]
31 |
32 |
33 | class TwoColumnAutoCompleteExample(App[None]):
34 | def compose(self) -> ComposeResult:
35 | yield Label("Start typing a programming language:")
36 | text_input = Input(placeholder="Type here...")
37 | yield text_input
38 |
39 | yield AutoComplete(
40 | target=text_input, # The widget to attach autocomplete to
41 | candidates=CANDIDATES, # The list of completion candidates
42 | )
43 |
44 |
45 | if __name__ == "__main__":
46 | app = TwoColumnAutoCompleteExample()
47 | app.run()
48 |
--------------------------------------------------------------------------------
/examples/basic_two_column_heavy_styles.py:
--------------------------------------------------------------------------------
1 | """A two-column autocomplete example with heavy styling."""
2 |
3 | from textual.app import App, ComposeResult
4 | from textual.content import Content
5 | from textual.widgets import Input, Label
6 |
7 | from textual_autocomplete import AutoComplete, DropdownItem
8 | from examples._headers import headers
9 |
10 | # Define a mapping of sections to colors
11 | SECTION_COLORS: dict[str, str] = {
12 | "Authentication": "$text-success",
13 | "Caching": "$text-primary",
14 | "Conditionals": "$text-warning",
15 | "Connection management": "$text-error",
16 | "Content negotiation": "$text-success",
17 | "Controls": "$text-accent",
18 | "Cookies": "$text-warning",
19 | "CORS": "$text-error",
20 | # Add fallback color for any other sections
21 | "default": "$foreground",
22 | }
23 |
24 | # Create dropdown items with two columns: rank and language name
25 | CANDIDATES = [
26 | DropdownItem(
27 | Content.styled(
28 | str(header["name"]),
29 | style=SECTION_COLORS.get(
30 | str(header.get("section", "default")), SECTION_COLORS["default"]
31 | ),
32 | ), # Main text to be completed with color based on section
33 | prefix=Content.from_markup(
34 | "[$text-primary on $primary-muted] $number", number=f"{i:<3}"
35 | ), # Prefix showing rank, styled with Textual markup!
36 | )
37 | for i, header in enumerate(headers)
38 | ]
39 |
40 |
41 | class TwoColumnAutoCompleteExample(App[None]):
42 | def compose(self) -> ComposeResult:
43 | yield Label("Start typing an HTTP header name:")
44 | text_input = Input(placeholder="Type here...")
45 | yield text_input
46 |
47 | yield AutoComplete(
48 | target=text_input, # The widget to attach autocomplete to
49 | candidates=CANDIDATES, # The list of completion candidates
50 | )
51 |
52 |
53 | if __name__ == "__main__":
54 | app = TwoColumnAutoCompleteExample()
55 | app.run()
56 |
--------------------------------------------------------------------------------
/examples/dynamic_data.py:
--------------------------------------------------------------------------------
1 | from textual.app import App, ComposeResult
2 | from textual.widgets import Input
3 |
4 | from textual_autocomplete import AutoComplete
5 | from textual_autocomplete._autocomplete import DropdownItem, TargetState
6 |
7 |
8 | class DynamicDataApp(App[None]):
9 | CSS = """
10 | Input {
11 | margin: 2 4;
12 | }
13 | """
14 |
15 | def compose(self) -> ComposeResult:
16 | input_widget = Input()
17 | yield input_widget
18 | yield AutoComplete(input_widget, candidates=self.get_candidates)
19 |
20 | def get_candidates(self, state: TargetState) -> list[DropdownItem]:
21 | left = len(state.text)
22 | return [
23 | DropdownItem(item, prefix=f"{left:>2} ")
24 | for item in [
25 | "Apple",
26 | "Banana",
27 | "Cherry",
28 | "Orange",
29 | "Pineapple",
30 | "Strawberry",
31 | "Watermelon",
32 | ]
33 | ]
34 |
35 |
36 | if __name__ == "__main__":
37 | app = DynamicDataApp()
38 | app.run()
39 |
--------------------------------------------------------------------------------
/examples/left_column_styling.py:
--------------------------------------------------------------------------------
1 | from textual.app import App, ComposeResult
2 | from textual.content import Content
3 | from textual.widgets import Input
4 |
5 | from textual_autocomplete import AutoComplete, DropdownItem
6 |
7 |
8 | LANGUAGES = [
9 | DropdownItem(
10 | "Python",
11 | prefix=Content.from_markup("[$text-success on $success-muted] 🐍 "),
12 | ),
13 | DropdownItem(
14 | "Golang",
15 | prefix=Content.from_markup("[$text-primary on $primary-muted] 🔷 "),
16 | ),
17 | DropdownItem("Java", prefix=Content.from_markup("[#6a2db5 on magenta 20%] ☕ ")),
18 | DropdownItem(
19 | "Rust", prefix=Content.from_markup("[$text-accent on $accent-muted] 🦀 ")
20 | ),
21 | ]
22 |
23 |
24 | class LanguagesSearchApp(App[None]):
25 | CSS = """
26 | Input {
27 | margin: 2 4;
28 | }
29 | """
30 |
31 | def compose(self) -> ComposeResult:
32 | input_widget = Input(placeholder="Search for a programming language...")
33 | yield input_widget
34 | yield AutoComplete(target=input_widget, candidates=LANGUAGES)
35 |
36 |
37 | if __name__ == "__main__":
38 | app = LanguagesSearchApp()
39 | app.run()
40 |
--------------------------------------------------------------------------------
/examples/partial_completions.py:
--------------------------------------------------------------------------------
1 | """By default, textual-autocomplete replaces the entire content of a widget
2 | with a completion.
3 |
4 | Sometimes, however, you may wish to insert the completion text, or otherwise use
5 | custom logic to determine the end-state of the Input after the user selects a completion.
6 |
7 | For example, if completing a path on a filesystem, you may wish to offer partial completions
8 | of the path based on the current content of the Input. Then, when the user selects a completion,
9 | you could offer a different set of completions based on the new path in the Input.
10 | """
11 |
12 | from textual.app import App, ComposeResult
13 | from textual.containers import Container
14 | from textual.widgets import Input, Label
15 |
16 | from textual_autocomplete import PathAutoComplete
17 |
18 |
19 | class FileSystemPathCompletions(App[None]):
20 | CSS = """
21 | #container {
22 | align: center middle;
23 | padding: 2 4;
24 | }
25 | #label {
26 | margin-left: 3;
27 | }
28 | Input {
29 | width: 80%;
30 | }
31 | """
32 |
33 | def compose(self) -> ComposeResult:
34 | with Container(id="container"):
35 | yield Label("Choose a file!", id="label")
36 | input_widget = Input(placeholder="Enter a path...")
37 | yield input_widget
38 | yield PathAutoComplete(target=input_widget, path="../textual")
39 |
40 |
41 | if __name__ == "__main__":
42 | app = FileSystemPathCompletions()
43 | app.run()
44 |
--------------------------------------------------------------------------------
/examples/path_completions_absolute.py:
--------------------------------------------------------------------------------
1 | """By default, textual-autocomplete replaces the entire content of a widget
2 | with a completion.
3 |
4 | Sometimes, however, you may wish to insert the completion text, or otherwise use
5 | custom logic to determine the end-state of the Input after the user selects a completion.
6 |
7 | For example, if completing a path on a filesystem, you may wish to offer partial completions
8 | of the path based on the current content of the Input. Then, when the user selects a completion,
9 | you could offer a different set of completions based on the new path in the Input.
10 | """
11 |
12 | from pathlib import Path
13 | from textual.app import App, ComposeResult
14 | from textual.containers import Container
15 | from textual.widgets import Input, Label
16 |
17 | from textual_autocomplete import PathAutoComplete
18 |
19 |
20 | class FileSystemPathCompletions(App[None]):
21 | CSS = """
22 | #container {
23 | padding: 2 4;
24 | }
25 | #label {
26 | margin-left: 3;
27 | }
28 | Input {
29 | width: 80%;
30 | }
31 | """
32 |
33 | def compose(self) -> ComposeResult:
34 | with Container(id="container"):
35 | yield Label("Choose a file!", id="label")
36 | input_widget = Input(placeholder="Enter a path...")
37 | yield input_widget
38 | yield PathAutoComplete(target=input_widget, path=Path.cwd())
39 |
40 |
41 | if __name__ == "__main__":
42 | app = FileSystemPathCompletions()
43 | app.run()
44 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "textual-autocomplete"
3 | version = "4.0.4"
4 | description = "Easily add autocomplete dropdowns to your Textual apps."
5 | authors = [
6 | { name = "Darren Burns", email = "darrenb900@gmail.com" }
7 | ]
8 | readme = "README.md"
9 | packages = [{ include = "textual_autocomplete" }]
10 | dependencies = [
11 | "textual>=2.0.0",
12 | "typing-extensions>=4.5.0",
13 | ]
14 | requires-python = ">=3.9.0"
15 |
16 | [tool.uv]
17 | dev-dependencies = [
18 | "mypy",
19 | "pytest>=8.3.5",
20 | "pytest-asyncio>=0.24.0",
21 | "pytest-textual-snapshot>=1.1.0",
22 | "pytest-xdist>=3.6.1",
23 | "textual-dev",
24 | ]
25 |
26 | [tool.hatch.build.targets.wheel]
27 | packages = ["textual_autocomplete"]
28 |
29 | [tool.hatch.metadata]
30 | allow-direct-references = true
31 |
32 | [build-system]
33 | requires = ["hatchling"]
34 | build-backend = "hatchling.build"
35 |
36 | [tool.pytest.ini_options]
37 | asyncio_mode = "auto"
38 | asyncio_default_fixture_loop_scope = "session"
39 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_input/test_candidate_can_be_selected_via_click.svg:
--------------------------------------------------------------------------------
1 |
155 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_input/test_completion_still_works_if_chosen_while_input_widget_has_selection.svg:
--------------------------------------------------------------------------------
1 |
155 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_input/test_hide_after_summoning_by_pressing_escape.svg:
--------------------------------------------------------------------------------
1 |
156 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_input/test_hide_after_typing_by_pressing_escape.svg:
--------------------------------------------------------------------------------
1 |
155 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_input/test_selecting_candidate_should_complete_input__enter_key.svg:
--------------------------------------------------------------------------------
1 |
155 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_input/test_selecting_candidate_should_complete_input__tab_key.svg:
--------------------------------------------------------------------------------
1 |
155 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_input/test_summon_when_only_one_full_match_does_not_show_dropdown.svg:
--------------------------------------------------------------------------------
1 |
155 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_styling/test_background_color_and_removed_style.svg:
--------------------------------------------------------------------------------
1 |
154 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_styling/test_cursor_color_change_and_dropdown_background_change.svg:
--------------------------------------------------------------------------------
1 |
157 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_styling/test_dropdown_styles_match_textual_theme.svg:
--------------------------------------------------------------------------------
1 |
154 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_styling/test_foreground_color_and_text_style.svg:
--------------------------------------------------------------------------------
1 |
154 |
--------------------------------------------------------------------------------
/tests/snapshots/__snapshots__/test_styling/test_max_height_and_scrolling.svg:
--------------------------------------------------------------------------------
1 |
155 |
--------------------------------------------------------------------------------
/tests/snapshots/test_cursor_tracking.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from textual.app import App, ComposeResult
4 | from textual.containers import Center, VerticalScroll
5 | from textual.pilot import Pilot
6 | from textual.widgets import Input
7 | from textual_autocomplete import AutoComplete
8 |
9 |
10 | class CursorTracking(App[None]):
11 | CSS = """
12 | #scrollable {
13 | overflow: scroll scroll;
14 | scrollbar-color: red;
15 | Center {
16 | background: $accent;
17 | height: 100;
18 | width: 100;
19 | align-vertical: middle;
20 | }
21 | Input {
22 | width: 24;
23 | }
24 | }
25 | """
26 |
27 | def compose(self) -> ComposeResult:
28 | with VerticalScroll(id="scrollable", can_focus=False):
29 | with Center():
30 | input = Input(placeholder="Type here...")
31 | input.cursor_blink = False
32 | yield input
33 |
34 | yield AutoComplete(
35 | target=input,
36 | candidates=["foo", "bar", "baz", "qux", "boop"],
37 | )
38 |
39 |
40 | def test_dropdown_tracks_terminal_cursor_when_parent_scrolls(snap_compare):
41 | """We type, the dropdown appears, then we scroll the parent container so that the position of the input
42 | and the dropdown changes on screen. The dropdown should remain aligned to the Input widget."""
43 |
44 | async def run_before(pilot: Pilot[None]) -> None:
45 | await pilot.press("b")
46 | scrollable = pilot.app.query_one("#scrollable")
47 | scrollable.scroll_relative(x=5, y=5, animate=False, force=True)
48 | await pilot.pause()
49 |
50 | assert snap_compare(CursorTracking(), run_before=run_before)
51 |
52 |
53 | def test_dropdown_tracks_input_cursor_and_cursor_prefix_as_search_string(snap_compare):
54 | """The completions should be based on the text between the start of the input and the cursor location.
55 |
56 | In this example, we type "ba", then move the cursor back one cell so that the search string is "b",
57 | meaning the completions should be "bar", "baz", and "boop".
58 | """
59 |
60 | async def run_before(pilot: Pilot[None]) -> None:
61 | await pilot.press(*"ba") # Type "ba"
62 | await pilot.press("left") # Move the cursor back one cell
63 |
64 | assert snap_compare(CursorTracking(), run_before=run_before)
65 |
66 |
67 | def test_dropdown_tracks_input_cursor_on_click_and_cursor_prefix_search_string(
68 | snap_compare,
69 | ):
70 | """The completions should be based on the text between the start of the input and the cursor location.
71 |
72 | In this example, we type "ba", then move the cursor back one cell by clicking, so that the search string is "b",
73 | meaning the completions should be "bar", "baz", and "boop".
74 | """
75 |
76 | async def run_before(pilot: Pilot[None]) -> None:
77 | await pilot.press(*"ba") # Type "ba"
78 | input_widget = pilot.app.query_one(Input)
79 | await pilot.click(input_widget, offset=(4, 1)) # Click on the "a"
80 |
81 | assert snap_compare(CursorTracking(), run_before=run_before)
82 |
--------------------------------------------------------------------------------
/tests/snapshots/test_function_candidates.py:
--------------------------------------------------------------------------------
1 | # TODO - Passing a function as candidates should work
2 |
3 | from __future__ import annotations
4 |
--------------------------------------------------------------------------------
/tests/snapshots/test_input.py:
--------------------------------------------------------------------------------
1 | """Snapshot tests for the Input widget with autocomplete."""
2 |
3 | from __future__ import annotations
4 |
5 | from textual.app import App, ComposeResult
6 | from textual.pilot import Pilot
7 | from textual.widgets import Input
8 | from textual.widgets.input import Selection
9 |
10 | from textual_autocomplete import AutoComplete, AutoCompleteList, DropdownItem
11 |
12 | LANGUAGES = [
13 | "Python",
14 | "JavaScript",
15 | "TypeScript",
16 | "Java",
17 | "C++",
18 | "Ruby",
19 | "Go",
20 | "Rust",
21 | "C#",
22 | "Swift",
23 | "Kotlin",
24 | "PHP",
25 | ]
26 | CANDIDATES = [DropdownItem(lang) for lang in LANGUAGES]
27 |
28 |
29 | class BasicInputAutocomplete(App[None]):
30 | def compose(self) -> ComposeResult:
31 | input = Input(placeholder="Type here...")
32 | input.cursor_blink = False
33 | yield input
34 | yield AutoComplete(
35 | target=input,
36 | candidates=CANDIDATES,
37 | )
38 | yield Input(placeholder="Another input which can be focused")
39 |
40 |
41 | def test_single_matching_candidate(snap_compare):
42 | """Typing should make the dropdown appear and show filtered results."""
43 |
44 | async def run_before(pilot: Pilot[None]) -> None:
45 | await pilot.press(*"py")
46 |
47 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
48 |
49 |
50 | def test_many_matching_candidates(snap_compare):
51 | """Typing should make the dropdown appear and show filtered results."""
52 |
53 | async def run_before(pilot: Pilot[None]) -> None:
54 | await pilot.press(*"ja")
55 |
56 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
57 |
58 |
59 | def test_selecting_candidate_should_complete_input__enter_key(snap_compare):
60 | """Selecting a candidate using the enter key should complete the input."""
61 |
62 | async def run_before(pilot: Pilot[None]) -> None:
63 | await pilot.press(*"ja")
64 | await pilot.press("down")
65 | await pilot.press("enter")
66 |
67 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
68 |
69 |
70 | def test_selecting_candidate_should_complete_input__tab_key(snap_compare):
71 | """Selecting a candidate using the tab key should complete the input."""
72 |
73 | async def run_before(pilot: Pilot[None]) -> None:
74 | await pilot.press(*"ja")
75 | await pilot.press("down")
76 | await pilot.press("tab")
77 |
78 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
79 |
80 |
81 | def test_tab_still_works_after_completion(snap_compare):
82 | """Tabbing after completing an input should still work (the autocomplete should not consume the tab key).
83 |
84 | The second input should become focused in this example."""
85 |
86 | async def run_before(pilot: Pilot[None]) -> None:
87 | await pilot.press(*"ja")
88 | await pilot.press("down")
89 | await pilot.press("tab")
90 | await pilot.press("tab")
91 |
92 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
93 |
94 |
95 | def test_summon_by_pressing_down(snap_compare):
96 | """We can summon the autocomplete dropdown by pressing the down arrow key."""
97 |
98 | async def run_before(pilot: Pilot[None]) -> None:
99 | await pilot.pause()
100 | await pilot.press("down")
101 |
102 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
103 |
104 |
105 | def test_summon_by_pressing_down_after_performing_completion(snap_compare):
106 | """We can summon the autocomplete dropdown by pressing the down arrow key,
107 | and it should be filled based on the current content of the Input.
108 |
109 | In this example, when we resummon the dropdown, the highlighted text should
110 | be "Java" - NOT "ja". "Java" is the current content of the input. "ja" was
111 | the text we previously performed the completion with.
112 |
113 | There was a bug where the dropdown would contain the pre-completion candidates.
114 | """
115 |
116 | async def run_before(pilot: Pilot[None]) -> None:
117 | await pilot.press(*"ja") # Filters down to 2 candidates: JavaScript and Java
118 | await pilot.press("down") # Move cursor over Java.
119 | await pilot.press("tab") # Press tab to complete.
120 | await pilot.press("down") # Press down to summon the dropdown.
121 |
122 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
123 |
124 |
125 | def test_hide_after_summoning_by_pressing_escape(snap_compare):
126 | """Dropdown summoned via down, then escape was pressed to hide it."""
127 |
128 | async def run_before(pilot: Pilot[None]) -> None:
129 | await pilot.press("down")
130 | await pilot.press("escape")
131 |
132 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
133 |
134 |
135 | def test_summon_when_only_one_full_match_does_not_show_dropdown(snap_compare):
136 | """If the dropdown contains only one item, and that item is an exact match for the dropdown
137 | content, then the dropdown should not be shown."""
138 |
139 | async def run_before(pilot: Pilot[None]) -> None:
140 | await pilot.press(*"py")
141 | await pilot.press("enter")
142 | await pilot.press("down")
143 |
144 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
145 |
146 |
147 | def test_hide_after_typing_by_pressing_escape(snap_compare):
148 | """Dropdown summoned via typing a matching query, then escape was pressed to hide it."""
149 |
150 | async def run_before(pilot: Pilot[None]) -> None:
151 | await pilot.press(*"py")
152 | await pilot.press("escape")
153 |
154 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
155 |
156 |
157 | def test_candidate_can_be_selected_via_click(snap_compare):
158 | """A candidate can be selected by clicking on it.
159 |
160 | In this example, we click on "Java", which is the second result in the dropdown.
161 | """
162 |
163 | async def run_before(pilot: Pilot[None]) -> None:
164 | await pilot.press(*"ja")
165 | await pilot.click(AutoCompleteList, offset=(1, 1)) # Click second result
166 |
167 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
168 |
169 |
170 | def test_text_selection_works_while_autocomplete_is_open(snap_compare):
171 | """If the dropdown is open, the text selection should still work."""
172 |
173 | async def run_before(pilot: Pilot[None]) -> None:
174 | await pilot.press(*"ja")
175 | input = pilot.app.query_one(Input)
176 | input.selection = Selection(0, 2)
177 |
178 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
179 |
180 |
181 | def test_completion_still_works_if_chosen_while_input_widget_has_selection(
182 | snap_compare,
183 | ):
184 | """If the dropdown is open, and a candidate is chosen, the completion should still
185 | work, and the selection should move to the end of the input."""
186 |
187 | async def run_before(pilot: Pilot[None]) -> None:
188 | await pilot.press(*"ja")
189 | input = pilot.app.query_one(Input)
190 | input.selection = Selection(0, 2)
191 | await pilot.press("down")
192 | await pilot.press("enter")
193 |
194 | assert snap_compare(BasicInputAutocomplete(), run_before=run_before)
195 |
196 |
197 | def test_dropdown_tracks_cursor_position(snap_compare):
198 | """The dropdown should track the cursor position of the target widget."""
199 |
200 | async def run_before(pilot: Pilot[None]) -> None:
201 | await pilot.press(*"ja")
202 | await pilot.press("down")
203 |
204 |
205 | def test_multiple_autocomplete_dropdowns_on_a_single_input(snap_compare):
206 | """Multiple autocomplete dropdowns can be open at the same time on a single input.
207 |
208 | I'm not sure why you'd want to do this. The behaviour is kind of undefined - both
209 | dropdowns should appear, but they'll overlap. Let's just ensure we don't crash.
210 | """
211 |
212 | class MultipleAutocompleteDropdowns(App[None]):
213 | def compose(self) -> ComposeResult:
214 | input_widget = Input(placeholder="Type here...")
215 | input_widget.cursor_blink = False
216 | yield input_widget
217 |
218 | yield AutoComplete(target=input_widget, candidates=LANGUAGES)
219 | yield AutoComplete(
220 | target=input_widget,
221 | candidates=["foo", "bar", "java", "javas", "javassss", "jajaja"],
222 | )
223 |
224 | async def run_before(pilot: Pilot[None]) -> None:
225 | await pilot.press("tab")
226 | await pilot.press(*"ja")
227 | await pilot.press("down")
228 |
229 | assert snap_compare(MultipleAutocompleteDropdowns(), run_before=run_before)
230 |
231 |
232 | def test_multiple_autocomplete_dropdowns_on_same_screen(snap_compare):
233 | """Multiple autocomplete dropdowns can exist on the same screen."""
234 |
235 | class MultipleAutocompleteDropdowns(App[None]):
236 | def compose(self) -> ComposeResult:
237 | input_widget = Input(placeholder="Type here...")
238 | input_widget.cursor_blink = False
239 | yield input_widget
240 |
241 | # Setup with strings...
242 | yield AutoComplete(target=input_widget, candidates=LANGUAGES)
243 | input2 = Input(placeholder="Also type here...")
244 | input2.cursor_blink = False
245 | yield input2
246 |
247 | # ...and with DropdownItems...
248 | yield AutoComplete(target=input2, candidates=CANDIDATES)
249 |
250 | async def run_before(pilot: Pilot[None]) -> None:
251 | await pilot.press("tab")
252 | await pilot.press(*"ja")
253 | await pilot.press("down")
254 |
255 | assert snap_compare(MultipleAutocompleteDropdowns(), run_before=run_before)
256 |
--------------------------------------------------------------------------------
/tests/snapshots/test_prevent_default.py:
--------------------------------------------------------------------------------
1 | """Let's ensure the `prevent_default_tab` and `prevent_default_enter` options work as expected."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any
6 | from textual.app import App, ComposeResult
7 | from textual.widgets import Button, Input
8 | from textual_autocomplete import AutoComplete
9 |
10 |
11 | class PreventDefaultTab(App[None]):
12 | """Test that the default tab key is permitted if `prevent_default_tab` is `False`."""
13 |
14 | def __init__(
15 | self, prevent_default_tab: bool = False, *args: Any, **kwargs: Any
16 | ) -> None:
17 | super().__init__(*args, **kwargs)
18 | self.prevent_default_tab = prevent_default_tab
19 |
20 | def compose(self) -> ComposeResult:
21 | input = Input(placeholder="Type something...")
22 | input.cursor_blink = False
23 | yield input
24 | yield AutoComplete(
25 | input,
26 | candidates=["foo", "bar"],
27 | prevent_default_tab=self.prevent_default_tab,
28 | )
29 | yield Button("I'm here to test focus.")
30 |
31 |
32 | async def test_switch_focus_on_completion_via_tab_key__prevent_default_tab_is_false():
33 | """The default tab key should not be prevented if `prevent_default_tab` is `False`."""
34 | app = PreventDefaultTab(prevent_default_tab=False)
35 | async with app.run_test() as pilot:
36 | await pilot.press("f", "tab")
37 | assert app.query_one(Input).value == "foo"
38 | assert app.query_one(Button).has_focus
39 |
40 |
41 | async def test_no_focus_switch_via_tab_key__prevent_default_tab_is_true():
42 | """`prevent_default_tab` is `True`, so focus should switch on completion with tab."""
43 | app = PreventDefaultTab(prevent_default_tab=True)
44 | async with app.run_test() as pilot:
45 | await pilot.press("f", "tab")
46 | input_widget = app.query_one(Input)
47 | assert input_widget.value == "foo"
48 | assert input_widget.has_focus
49 | assert not app.query_one(Button).has_focus
50 |
--------------------------------------------------------------------------------
/tests/snapshots/test_styling.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from textual.app import App, ComposeResult
4 | from textual.pilot import Pilot
5 | from textual.widgets import Input
6 | from textual_autocomplete import AutoComplete
7 |
8 |
9 | class StyledAutocomplete(App[None]):
10 | def compose(self) -> ComposeResult:
11 | input = Input(placeholder="Search...")
12 | input.cursor_blink = False
13 | yield input
14 | yield AutoComplete(target=input, candidates=["foo", "bar", "baz", "qux"])
15 |
16 |
17 | def test_foreground_color_and_text_style(snap_compare):
18 | """Background color should not be impacted by the text foreground and style."""
19 | StyledAutocomplete.CSS = """
20 | AutoComplete {
21 | & .autocomplete--highlight-match {
22 | color: $text-accent;
23 | text-style: bold italic underline;
24 | }
25 | }
26 | """
27 |
28 | async def run_before(pilot: Pilot) -> None:
29 | await pilot.press(*"ba")
30 |
31 | assert snap_compare(StyledAutocomplete(), run_before=run_before)
32 |
33 |
34 | def test_background_color_and_removed_style(snap_compare):
35 | StyledAutocomplete.CSS = """
36 | AutoComplete {
37 | & .autocomplete--highlight-match {
38 | color: $text-accent;
39 | background: $success-muted;
40 | text-style: not bold;
41 | }
42 | }
43 | """
44 |
45 | async def run_before(pilot: Pilot) -> None:
46 | await pilot.press(*"ba")
47 |
48 | assert snap_compare(StyledAutocomplete(), run_before=run_before)
49 |
50 |
51 | def test_max_height_and_scrolling(snap_compare):
52 | """We should be scrolled to qux, and the red scrollbar should reflect that."""
53 | StyledAutocomplete.CSS = """
54 | AutoComplete {
55 | & AutoCompleteList {
56 | scrollbar-color: red;
57 | max-height: 2;
58 | }
59 | }
60 | """
61 |
62 | async def run_before(pilot: Pilot) -> None:
63 | await pilot.press("down", "down", "down", "down")
64 |
65 | assert snap_compare(StyledAutocomplete(), run_before=run_before)
66 |
67 |
68 | def test_dropdown_styles_match_textual_theme(snap_compare):
69 | """Dropdown styles should match the textual theme. In this test, we've swapped the theme to nord."""
70 | StyledAutocomplete.CSS = """
71 | AutoComplete {
72 | & .autocomplete--highlight-match {
73 | color: $text-accent;
74 | }
75 | }
76 | """
77 |
78 | async def run_before(pilot: Pilot) -> None:
79 | pilot.app.theme = "nord"
80 | await pilot.press(*"ba")
81 |
82 | assert snap_compare(StyledAutocomplete(), run_before=run_before)
83 |
84 |
85 | def test_cursor_color_change_and_dropdown_background_change(snap_compare):
86 | """Checking various interactions between styles. See the test's CSS for info."""
87 | StyledAutocomplete.CSS = """
88 | AutoComplete {
89 |
90 | & AutoCompleteList {
91 | color: red;
92 | background: $error-muted;
93 | & > .option-list--option-highlighted {
94 | color: $text-primary;
95 | background: magenta;
96 | text-style: italic;
97 | }
98 | }
99 |
100 | & .autocomplete--highlight-match {
101 | color: $text-success;
102 | background: $success-muted;
103 | text-style: underline;
104 | }
105 |
106 | }
107 | """
108 |
109 | async def run_before(pilot: Pilot) -> None:
110 | await pilot.press(*"ba")
111 |
112 | assert snap_compare(StyledAutocomplete(), run_before=run_before)
113 |
--------------------------------------------------------------------------------
/textual_autocomplete/__init__.py:
--------------------------------------------------------------------------------
1 | from textual_autocomplete._autocomplete import (
2 | AutoComplete,
3 | AutoCompleteList,
4 | DropdownItem,
5 | DropdownItemHit,
6 | TargetState,
7 | )
8 |
9 | from textual_autocomplete._path_autocomplete import PathAutoComplete
10 |
11 | __all__ = [
12 | "AutoComplete",
13 | "PathAutoComplete",
14 | "AutoCompleteList",
15 | "DropdownItem",
16 | "DropdownItemHit",
17 | "TargetState",
18 | ]
19 |
--------------------------------------------------------------------------------
/textual_autocomplete/_path_autocomplete.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | from pathlib import Path
5 | from typing import Any, Callable
6 | from os import DirEntry
7 | from textual.content import Content
8 | from textual.widgets import Input
9 | from textual.cache import LRUCache
10 |
11 | from textual_autocomplete import DropdownItem, AutoComplete, TargetState
12 |
13 |
14 | class PathDropdownItem(DropdownItem):
15 | def __init__(self, completion: str, path: Path) -> None:
16 | super().__init__(completion)
17 | self.path = path
18 |
19 |
20 | def default_path_input_sort_key(item: PathDropdownItem) -> tuple[bool, bool, str]:
21 | """Sort key function for results within the dropdown.
22 |
23 | Args:
24 | item: The PathDropdownItem to get a sort key for.
25 |
26 | Returns:
27 | A tuple of (is_dotfile, is_file, lowercase_name) for sorting.
28 | """
29 | name = item.path.name
30 | is_dotfile = name.startswith(".")
31 | return (not item.path.is_dir(), not is_dotfile, name.lower())
32 |
33 |
34 | class PathAutoComplete(AutoComplete):
35 | def __init__(
36 | self,
37 | target: Input | str,
38 | path: str | Path = ".",
39 | *,
40 | show_dotfiles: bool = True,
41 | sort_key: Callable[[PathDropdownItem], Any] = default_path_input_sort_key,
42 | folder_prefix: Content = Content("📂"),
43 | file_prefix: Content = Content("📄"),
44 | prevent_default_enter: bool = True,
45 | prevent_default_tab: bool = True,
46 | cache_size: int = 100,
47 | name: str | None = None,
48 | id: str | None = None,
49 | classes: str | None = None,
50 | disabled: bool = False,
51 | ) -> None:
52 | """An autocomplete widget for filesystem paths.
53 |
54 | Args:
55 | target: The target input widget to autocomplete.
56 | path: The base path to autocomplete from.
57 | show_dotfiles: Whether to show dotfiles (files/dirs starting with ".").
58 | sort_key: Function to sort the dropdown items.
59 | folder_prefix: The prefix for folder items (e.g. 📂).
60 | file_prefix: The prefix for file items (e.g. 📄).
61 | prevent_default_enter: Whether to prevent the default enter behavior.
62 | prevent_default_tab: Whether to prevent the default tab behavior.
63 | cache_size: The number of directories to cache.
64 | name: The name of the widget.
65 | id: The DOM node id of the widget.
66 | classes: The CSS classes of the widget.
67 | disabled: Whether the widget is disabled.
68 | """
69 | super().__init__(
70 | target,
71 | None,
72 | prevent_default_enter=prevent_default_enter,
73 | prevent_default_tab=prevent_default_tab,
74 | name=name,
75 | id=id,
76 | classes=classes,
77 | disabled=disabled,
78 | )
79 | self.path = Path(path) if isinstance(path, str) else path
80 | self.show_dotfiles = show_dotfiles
81 | self.sort_key = sort_key
82 | self.folder_prefix = folder_prefix
83 | self.file_prefix = file_prefix
84 | self._directory_cache: LRUCache[str, list[DirEntry[str]]] = LRUCache(cache_size)
85 |
86 | def get_candidates(self, target_state: TargetState) -> list[DropdownItem]:
87 | """Get the candidates for the current path segment.
88 |
89 | This is called each time the input changes or the cursor position changes/
90 | """
91 | current_input = target_state.text[: target_state.cursor_position]
92 |
93 | if "/" in current_input:
94 | last_slash_index = current_input.rindex("/")
95 | path_segment = current_input[:last_slash_index] or "/"
96 | directory = self.path / path_segment if path_segment != "/" else self.path
97 | else:
98 | directory = self.path
99 |
100 | # Use the directory path as the cache key
101 | cache_key = str(directory)
102 | cached_entries = self._directory_cache.get(cache_key)
103 |
104 | if cached_entries is not None:
105 | entries = cached_entries
106 | else:
107 | try:
108 | entries = list(os.scandir(directory))
109 | self._directory_cache[cache_key] = entries
110 | except OSError:
111 | return []
112 |
113 | results: list[PathDropdownItem] = []
114 | for entry in entries:
115 | # Only include the entry name, not the full path
116 | completion = entry.name
117 | if not self.show_dotfiles and completion.startswith("."):
118 | continue
119 | if entry.is_dir():
120 | completion += "/"
121 | results.append(PathDropdownItem(completion, path=Path(entry.path)))
122 |
123 | results.sort(key=self.sort_key)
124 | folder_prefix = self.folder_prefix
125 | file_prefix = self.file_prefix
126 | return [
127 | DropdownItem(
128 | item.main,
129 | prefix=folder_prefix if item.path.is_dir() else file_prefix,
130 | )
131 | for item in results
132 | ]
133 |
134 | def get_search_string(self, target_state: TargetState) -> str:
135 | """Return only the current path segment for searching in the dropdown."""
136 | current_input = target_state.text[: target_state.cursor_position]
137 |
138 | if "/" in current_input:
139 | last_slash_index = current_input.rindex("/")
140 | search_string = current_input[last_slash_index + 1 :]
141 | return search_string
142 | else:
143 | return current_input
144 |
145 | def apply_completion(self, value: str, state: TargetState) -> None:
146 | """Apply the completion by replacing only the current path segment."""
147 | target = self.target
148 | current_input = state.text
149 | cursor_position = state.cursor_position
150 |
151 | # There's a slash before the cursor, so we only want to replace
152 | # the text after the last slash with the selected value
153 | try:
154 | replace_start_index = current_input.rindex("/", 0, cursor_position)
155 | except ValueError:
156 | # No slashes, so we do a full replacement
157 | new_value = value
158 | new_cursor_position = len(value)
159 | else:
160 | # Keep everything before and including the slash before the cursor.
161 | path_prefix = current_input[: replace_start_index + 1]
162 | new_value = path_prefix + value
163 | new_cursor_position = len(path_prefix) + len(value)
164 |
165 | with self.prevent(Input.Changed):
166 | target.value = new_value
167 | target.cursor_position = new_cursor_position
168 |
169 | def post_completion(self) -> None:
170 | if not self.target.value.endswith("/"):
171 | self.action_hide()
172 |
173 | def should_show_dropdown(self, search_string: str) -> bool:
174 | default_behavior = super().should_show_dropdown(search_string)
175 | return (
176 | default_behavior
177 | or (search_string == "" and self.target.value != "")
178 | and self.option_list.option_count > 1
179 | )
180 |
181 | def clear_directory_cache(self) -> None:
182 | """Clear the directory cache. If you know that the contents of the directory have changed,
183 | you can call this method to invalidate the cache.
184 | """
185 | self._directory_cache.clear()
186 | target_state = self._get_target_state()
187 | self._rebuild_options(target_state, self.get_search_string(target_state))
188 |
--------------------------------------------------------------------------------
/textual_autocomplete/fuzzy_search.py:
--------------------------------------------------------------------------------
1 | """
2 | Fuzzy matcher.
3 |
4 | This class is used by the [command palette](/guide/command_palette) to match search terms.
5 |
6 | This is the matcher that powers Textual's command palette.
7 |
8 | Thanks to Will McGugan for the implementation.
9 | """
10 |
11 | from __future__ import annotations
12 |
13 | from operator import itemgetter
14 | from re import IGNORECASE, escape, finditer, search
15 | from typing import Iterable, NamedTuple
16 |
17 | from textual.cache import LRUCache
18 |
19 |
20 | class _Search(NamedTuple):
21 | """Internal structure to keep track of a recursive search."""
22 |
23 | candidate_offset: int = 0
24 | query_offset: int = 0
25 | offsets: tuple[int, ...] = ()
26 |
27 | def branch(self, offset: int) -> tuple[_Search, _Search]:
28 | """Branch this search when an offset is found.
29 |
30 | Args:
31 | offset: Offset of a matching letter in the query.
32 |
33 | Returns:
34 | A pair of search objects.
35 | """
36 | _, query_offset, offsets = self
37 | return (
38 | _Search(offset + 1, query_offset + 1, offsets + (offset,)),
39 | _Search(offset + 1, query_offset, offsets),
40 | )
41 |
42 | @property
43 | def groups(self) -> int:
44 | """Number of groups in offsets."""
45 | groups = 1
46 | last_offset, *offsets = self.offsets
47 | for offset in offsets:
48 | if offset != last_offset + 1:
49 | groups += 1
50 | last_offset = offset
51 | return groups
52 |
53 |
54 | class FuzzySearch:
55 | """Performs a fuzzy search.
56 |
57 | Unlike a regex solution, this will finds all possible matches.
58 | """
59 |
60 | cache: LRUCache[tuple[str, str, bool], tuple[float, tuple[int, ...]]] = LRUCache(
61 | 1024 * 4
62 | )
63 |
64 | def __init__(self, case_sensitive: bool = False) -> None:
65 | """Initialize fuzzy search.
66 |
67 | Args:
68 | case_sensitive: Is the match case sensitive?
69 | """
70 |
71 | self.case_sensitive = case_sensitive
72 |
73 | def match(self, query: str, candidate: str) -> tuple[float, tuple[int, ...]]:
74 | """Match against a query.
75 |
76 | Args:
77 | query: The fuzzy query.
78 | candidate: A candidate to check,.
79 |
80 | Returns:
81 | A pair of (score, tuple of offsets). `(0, ())` for no result.
82 | """
83 | query_regex = ".*?".join(f"({escape(character)})" for character in query)
84 | if not search(
85 | query_regex, candidate, flags=0 if self.case_sensitive else IGNORECASE
86 | ):
87 | # Bail out early if there is no possibility of a match
88 | return (0.0, ())
89 |
90 | cache_key = (query, candidate, self.case_sensitive)
91 | if cache_key in self.cache:
92 | return self.cache[cache_key]
93 | result = max(
94 | self._match(query, candidate), key=itemgetter(0), default=(0.0, ())
95 | )
96 | self.cache[cache_key] = result
97 | return result
98 |
99 | def _match(
100 | self, query: str, candidate: str
101 | ) -> Iterable[tuple[float, tuple[int, ...]]]:
102 | """Generator to do the matching.
103 |
104 | Args:
105 | query: Query to match.
106 | candidate: Candidate to check against.
107 |
108 | Yields:
109 | Pairs of score and tuple of offsets.
110 | """
111 | if not self.case_sensitive:
112 | query = query.lower()
113 | candidate = candidate.lower()
114 |
115 | # We need this to give a bonus to first letters.
116 | first_letters = {match.start() for match in finditer(r"\w+", candidate)}
117 |
118 | def score(search: _Search) -> float:
119 | """Sore a search.
120 |
121 | Args:
122 | search: Search object.
123 |
124 | Returns:
125 | Score.
126 |
127 | """
128 | # This is a heuristic, and can be tweaked for better results
129 | # Boost first letter matches
130 | offset_count = len(search.offsets)
131 | score: float = offset_count + len(
132 | first_letters.intersection(search.offsets)
133 | )
134 | # Boost to favor less groups
135 | normalized_groups = (offset_count - (search.groups - 1)) / offset_count
136 | score *= 1 + (normalized_groups * normalized_groups)
137 | return score
138 |
139 | stack: list[_Search] = [_Search()]
140 | push = stack.append
141 | pop = stack.pop
142 | query_size = len(query)
143 | find = candidate.find
144 | # Limit the number of loops out of an abundance of caution.
145 | # This should be hard to reach without contrived data.
146 | remaining_loops = 10_000
147 | while stack and (remaining_loops := remaining_loops - 1):
148 | search = pop()
149 | offset = find(query[search.query_offset], search.candidate_offset)
150 | if offset != -1:
151 | if not set(candidate[search.candidate_offset :]).issuperset(
152 | query[search.query_offset :]
153 | ):
154 | # Early out if there is not change of a match
155 | continue
156 | advance_branch, branch = search.branch(offset)
157 | if advance_branch.query_offset == query_size:
158 | yield score(advance_branch), advance_branch.offsets
159 | push(branch)
160 | else:
161 | push(branch)
162 | push(advance_branch)
163 |
--------------------------------------------------------------------------------
/textual_autocomplete/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/darrenburns/textual-autocomplete/7577a903cc33394ff7c9b12d1800bc24e355a777/textual_autocomplete/py.typed
--------------------------------------------------------------------------------