├── .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 | ![autocomplete-readme-header](https://github.com/user-attachments/assets/eda6f78a-fbaa-4a5b-ac1d-223e41f6eabb) 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 | image 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 | image 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 | ![Screen Recording 2025-03-18 at 18 26 42](https://github.com/user-attachments/assets/ca0e039b-8ae0-48ac-ba96-9ec936720ded) 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 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | BasicInputAutocomplete 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 129 | Java 130 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 131 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 132 | Another input which can be focused 133 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_input/test_completion_still_works_if_chosen_while_input_widget_has_selection.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | BasicInputAutocomplete 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 129 | Java 130 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 131 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 132 | Another input which can be focused 133 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_input/test_hide_after_summoning_by_pressing_escape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | BasicInputAutocomplete 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 130 | Type here... 131 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 132 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 133 | Another input which can be focused 134 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_input/test_hide_after_typing_by_pressing_escape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | BasicInputAutocomplete 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 129 | py 130 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 131 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 132 | Another input which can be focused 133 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_input/test_selecting_candidate_should_complete_input__enter_key.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | BasicInputAutocomplete 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 129 | Java 130 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 131 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 132 | Another input which can be focused 133 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_input/test_selecting_candidate_should_complete_input__tab_key.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | BasicInputAutocomplete 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 129 | Java 130 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 131 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 132 | Another input which can be focused 133 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_input/test_summon_when_only_one_full_match_does_not_show_dropdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | BasicInputAutocomplete 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 129 | Python 130 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 131 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 132 | Another input which can be focused 133 | ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_styling/test_background_color_and_removed_style.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | StyledAutocomplete 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 128 | ba 129 | ▁▁▁▁bar▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 130 | baz 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_styling/test_cursor_color_change_and_dropdown_background_change.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | StyledAutocomplete 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 131 | ba 132 | ▁▁▁▁bar▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 133 | baz 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_styling/test_dropdown_styles_match_textual_theme.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | StyledAutocomplete 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 128 | ba 129 | ▁▁▁▁bar▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 130 | baz 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_styling/test_foreground_color_and_text_style.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | StyledAutocomplete 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 128 | ba 129 | ▁▁▁▁bar▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 130 | baz 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /tests/snapshots/__snapshots__/test_styling/test_max_height_and_scrolling.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | StyledAutocomplete 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 129 | Search... 130 | ▁▁baz▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 131 | qux 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 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 --------------------------------------------------------------------------------