├── .flake8 ├── .github └── workflows │ └── publish_PYPI_each_tag.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── requirements.txt ├── setup.py ├── src └── st_keyup │ ├── __init__.py │ └── frontend │ ├── index.html │ ├── main.js │ ├── streamlit-component-lib.js │ └── style.css └── streamlit_app.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = 4 | E # pep8 errors 5 | F # pyflakes errors 6 | W # pep8 warnings 7 | B # flake8-bugbear warnings 8 | ignore = 9 | E501 # "Line lengths are recommended to be no greater than 79 characters" 10 | E203 # "Whitespace before ':'": conflicts with black 11 | W503 # "line break before binary operator": conflicts with black 12 | exclude = 13 | .git 14 | .vscode 15 | .pytest_cache 16 | .mypy_cache 17 | .venv 18 | .env 19 | .direnv 20 | per-file-ignores = -------------------------------------------------------------------------------- /.github/workflows/publish_PYPI_each_tag.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: "3.x" 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel twine 24 | - name: Build and publish 25 | env: 26 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: | 29 | pwd 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ######################################################################## 2 | # Python - https://github.com/github/gitignore/blob/master/Python.gitignore 3 | ######################################################################## 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # Distribution / packaging 10 | build/ 11 | dist/ 12 | eggs/ 13 | .eggs/ 14 | *.egg-info/ 15 | *.egg 16 | 17 | # Unit test / coverage reports 18 | .coverage 19 | .coverage\.* 20 | .pytest_cache/ 21 | .mypy_cache/ 22 | test-reports 23 | 24 | # Test fixtures 25 | cffi_bin 26 | 27 | # Pyenv Stuff 28 | .python-version 29 | 30 | # testing with seleniumbase 31 | latest_logs 32 | archived_logs 33 | current-screenshot.png 34 | 35 | ######################################################################## 36 | # OSX - https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 37 | ######################################################################## 38 | .DS_Store 39 | .DocumentRevisions-V100 40 | .fseventsd 41 | .Spotlight-V100 42 | .TemporaryItems 43 | .Trashes 44 | .VolumeIcon.icns 45 | .com.apple.timemachine.donotpresent 46 | 47 | ######################################################################## 48 | # node - https://github.com/github/gitignore/blob/master/Node.gitignore 49 | ######################################################################## 50 | # Logs 51 | npm-debug.log* 52 | yarn-debug.log* 53 | yarn-error.log* 54 | 55 | # Dependency directories 56 | node_modules/ 57 | 58 | # Coverage directory used by tools like istanbul 59 | coverage/ 60 | 61 | # Lockfiles 62 | yarn.lock 63 | package-lock.json 64 | 65 | ######################################################################## 66 | # JetBrains 67 | ######################################################################## 68 | .idea 69 | 70 | ######################################################################## 71 | # VSCode 72 | ######################################################################## 73 | .vscode/ 74 | 75 | .direnv/ 76 | .envrc 77 | 78 | .streamlit/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.3 4 | hooks: 5 | - id: ruff 6 | args: 7 | - --fix 8 | - repo: https://github.com/pre-commit/mirrors-mypy 9 | rev: "v1.13.0" 10 | hooks: 11 | - id: mypy 12 | args: 13 | - --ignore-missing-imports 14 | - --follow-imports=silent 15 | 16 | - repo: https://github.com/pre-commit/pre-commit-hooks 17 | rev: v4.6.0 # Use the ref you want to point at 18 | hooks: 19 | - id: trailing-whitespace 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Zachary Blackwood 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include src/st_keyup/frontend * 2 | include README.md 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # streamlit-keyup 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/streamlit-keyup.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/streamlit-keyup/) 4 | [![PyPI downloads](https://img.shields.io/pypi/dm/streamlit-keyup.svg)](https://pypistats.org/packages/streamlit-keyup) 5 | [![GitHub](https://img.shields.io/github/license/blackary/streamlit-keyup.svg)](LICENSE) 6 | [![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) 7 | 8 | If you're collecting text input from your users in your streamlit app, `st.text_input` works well -- as long as you're happy with 9 | waiting to get the response when they're finished typing. 10 | 11 | But, what if you want to get the input out, and do something with it every time they type a new key (AKA "on keyup")? 12 | 13 | [![Open in Streamlit](https://static.streamlit.io/badges/streamlit_badge_black_white.svg)](https://key-up.streamlitapp.com) 14 | 15 | ![filtering](https://user-images.githubusercontent.com/4040678/189153486-7ff7641c-1c76-4fa1-b0d5-f6634f8f0e41.gif) 16 | 17 | ## Installation 18 | 19 | `pip install streamlit-keyup` 20 | 21 | ## Usage 22 | 23 | ```python 24 | import streamlit as st 25 | from st_keyup import st_keyup 26 | 27 | value = st_keyup("Enter a value", key="0") 28 | 29 | # Notice that value updates after every key press 30 | st.write(value) 31 | 32 | # If you want to set a default value, you can pass one 33 | with_default = st_keyup("Enter a value", value="Example", key="1") 34 | 35 | # If you want to limit how often the value gets updated, pass `debounce` value, which 36 | # will force the value to only update after that many milliseconds have passed 37 | with_debounce = st_keyup("Enter a value", debounce=500, key="2") 38 | ``` 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 88 3 | select = ["E", "F", "I001"] 4 | 5 | [tool.mypy] 6 | files = ["**/*.py"] 7 | follow_imports = "silent" 8 | ignore_missing_imports = true 9 | scripts_are_modules = true 10 | python_version = 3.9 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | streamlit>=1.2.0 2 | streamlit-keyup>=0.1.9 3 | jinja2 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import setuptools 4 | 5 | this_directory = Path(__file__).parent 6 | long_description = (this_directory / "README.md").read_text() 7 | 8 | setuptools.setup( 9 | name="streamlit-keyup", 10 | version="0.3.0", 11 | author="Zachary Blackwood", 12 | author_email="zachary@streamlit.io", 13 | description="Text input that renders on keyup", 14 | long_description=long_description, 15 | long_description_content_type="text/markdown", 16 | url="https://github.com/blackary/streamlit-keyup", 17 | packages=setuptools.find_packages(where="src"), 18 | package_dir={"": "src"}, 19 | include_package_data=True, 20 | classifiers=[], 21 | python_requires=">=3.7", 22 | install_requires=["streamlit>=1.2", "jinja2"], 23 | ) 24 | -------------------------------------------------------------------------------- /src/st_keyup/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Callable, Dict, Optional, Tuple 3 | 4 | import streamlit as st 5 | import streamlit.components.v1 as components 6 | 7 | build_dir = Path(__file__).parent.absolute() / "frontend" 8 | _component_func = components.declare_component("st_keyup", path=str(build_dir)) 9 | 10 | 11 | def st_keyup( 12 | label: str, 13 | value: str = "", 14 | max_chars: Optional[int] = None, 15 | key: Optional[str] = None, 16 | type: str = "default", 17 | debounce: Optional[int] = None, 18 | on_change: Optional[Callable] = None, 19 | args: Optional[Tuple[Any, ...]] = None, 20 | kwargs: Optional[Dict[str, Any]] = None, 21 | *, 22 | placeholder: str = "", 23 | disabled: bool = False, 24 | label_visibility: str = "visible", 25 | ): 26 | """ 27 | Generate a text input that renders on keyup, debouncing the input by the 28 | specified amount of milliseconds. 29 | 30 | Debounce means that it will wait at least the specified amount of milliseconds 31 | before updating the value. This is useful for preventing excessive updates 32 | when the user is typing. Since the input updating will cause the app to rerun, 33 | if you are having performance issues, you should consider setting a debounce 34 | value. 35 | 36 | on_change is a callback function that will be called when the value changes. 37 | 38 | args and kwargs are optional arguments which are passed to the on_change callback 39 | function 40 | """ 41 | 42 | key_parts = [ 43 | key, 44 | disabled, 45 | label_visibility, 46 | debounce, 47 | max_chars, 48 | type, 49 | placeholder, 50 | ] 51 | 52 | computed_key = "st_keyup_" + "__".join( 53 | str(part) for part in key_parts if part is not None 54 | ) 55 | 56 | component_value = _component_func( 57 | label=label, 58 | value=value, 59 | key=computed_key, 60 | debounce=debounce, 61 | default=value, 62 | max_chars=max_chars, 63 | type=type, 64 | placeholder=placeholder, 65 | disabled=disabled, 66 | label_visibility=label_visibility, 67 | ) 68 | 69 | if key is not None: 70 | st.session_state[key] = component_value 71 | 72 | if key is None: 73 | key = "st_keyup_" + label 74 | 75 | if on_change is not None: 76 | if "__previous_values__" not in st.session_state: 77 | st.session_state["__previous_values__"] = {} 78 | 79 | if component_value != st.session_state["__previous_values__"].get(key, value): 80 | st.session_state["__previous_values__"][key] = component_value 81 | 82 | if args is None: 83 | args = () 84 | if kwargs is None: 85 | kwargs = {} 86 | on_change(*args, **kwargs) 87 | 88 | return component_value 89 | 90 | 91 | def main(): 92 | from datetime import datetime 93 | 94 | st.write("## Default keyup input") 95 | value = st_keyup("Enter a value") 96 | 97 | st.write(value) 98 | 99 | "## Keyup input with hidden label" 100 | value = st_keyup("You can't see this", label_visibility="hidden") 101 | 102 | "## Keyup input with collapsed label" 103 | value = st_keyup("This either", label_visibility="collapsed") 104 | 105 | "## Keyup with max_chars 5" 106 | value = st_keyup("Keyup with max chars", max_chars=5) 107 | 108 | "## Keyup input with password type" 109 | value = st_keyup("Password", value="Hello World", type="password") 110 | 111 | "## Keyup input with disabled" 112 | value = st_keyup("Disabled", value="Hello World", disabled=True) 113 | 114 | "## Keyup input with default value" 115 | value = st_keyup("Default value", value="Hello World") 116 | 117 | "## Keyup input with placeholder" 118 | value = st_keyup("Has placeholder", placeholder="A placeholder") 119 | 120 | "## Keyup input with 500 millesecond debounce" 121 | value = st_keyup("Enter a second value debounced", debounce=500) 122 | 123 | st.write(value) 124 | 125 | def on_change(): 126 | st.write("Value changed!", datetime.now()) 127 | 128 | def on_change2(*args, **kwargs): 129 | st.write("Value changed!", args, kwargs) 130 | 131 | "## Keyup input with on_change callback" 132 | value = st_keyup("Has an on_change", on_change=on_change) 133 | 134 | "## Keyup input with on_change callback and debounce" 135 | value = st_keyup("On_change + debounce", on_change=on_change, debounce=1000) 136 | st.write(value) 137 | 138 | "## Keyup input with args" 139 | value = st_keyup( 140 | "Enter a fourth value...", 141 | on_change=on_change2, 142 | args=("Hello", "World"), 143 | kwargs={"foo": "bar"}, 144 | ) 145 | st.write(value) 146 | 147 | "## Standard text input for comparison" 148 | value = st.text_input("Enter a value") 149 | 150 | st.write(value) 151 | 152 | st.write(st.session_state) 153 | 154 | 155 | if __name__ == "__main__": 156 | main() 157 | -------------------------------------------------------------------------------- /src/st_keyup/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Streamlit Keyup 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 | 17 |
18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/st_keyup/frontend/main.js: -------------------------------------------------------------------------------- 1 | function onKeyUp(event) { 2 | Streamlit.setComponentValue(event.target.value) 3 | } 4 | 5 | const debounce = (callback, wait) => { 6 | let timeoutId = null; 7 | return (...args) => { 8 | window.clearTimeout(timeoutId); 9 | timeoutId = window.setTimeout(() => { 10 | callback.apply(null, args); 11 | }, wait); 12 | }; 13 | } 14 | 15 | /** 16 | * The component's render function. This will be called immediately after 17 | * the component is initially loaded, and then again every time the 18 | * component gets new data from Python. 19 | */ 20 | function onRender(event) { 21 | // Get the RenderData from the event 22 | 23 | // This is called on every render to allow changing themes via settings 24 | const root = document.getElementById("root") 25 | 26 | root.style.setProperty("--base", event.detail.theme.base) 27 | root.style.setProperty("--primary-color", event.detail.theme.primaryColor) 28 | root.style.setProperty("--background-color", event.detail.theme.backgroundColor) 29 | root.style.setProperty("--secondary-background-color", event.detail.theme.secondaryBackgroundColor) 30 | root.style.setProperty("--text-color", event.detail.theme.textColor) 31 | root.style.setProperty("--font", event.detail.theme.font) 32 | 33 | if (!window.rendered) { 34 | const { 35 | label, 36 | value, 37 | debounce: debounce_time, 38 | max_chars, 39 | type, 40 | placeholder, 41 | disabled, 42 | label_visibility 43 | } = event.detail.args; 44 | 45 | const input = document.getElementById("input_box"); 46 | const label_el = document.getElementById("label") 47 | 48 | if (label_el) { 49 | label_el.innerText = label 50 | } 51 | 52 | if (value && !input.value) { 53 | input.value = value 54 | } 55 | 56 | if (type == "password") { 57 | input.type = "password" 58 | } 59 | else { 60 | input.type = "text" 61 | } 62 | 63 | if (max_chars) { 64 | input.maxLength = max_chars 65 | } 66 | 67 | if (placeholder) { 68 | input.placeholder = placeholder 69 | } 70 | 71 | if (disabled) { 72 | input.disabled = true 73 | label.disabled = true 74 | // Add "disabled" class to root element 75 | root.classList.add("disabled") 76 | } 77 | 78 | if (label_visibility == "hidden") { 79 | root.classList.add("label-hidden") 80 | } 81 | else if (label_visibility == "collapsed") { 82 | root.classList.add("label-collapsed") 83 | Streamlit.setFrameHeight(45) 84 | } 85 | 86 | if (debounce_time > 0) { // is false if debounce_time is 0 or undefined 87 | input.onkeyup = debounce(onKeyUp, debounce_time) 88 | } 89 | else { 90 | input.onkeyup = onKeyUp 91 | } 92 | 93 | // Render with the correct height 94 | Streamlit.setFrameHeight(73) 95 | 96 | window.rendered = true 97 | } 98 | } 99 | 100 | Streamlit.events.addEventListener(Streamlit.RENDER_EVENT, onRender) 101 | Streamlit.setComponentReady() 102 | -------------------------------------------------------------------------------- /src/st_keyup/frontend/streamlit-component-lib.js: -------------------------------------------------------------------------------- 1 | // Borrowed minimalistic Streamlit API from Thiago 2 | // https://discuss.streamlit.io/t/code-snippet-create-components-without-any-frontend-tooling-no-react-babel-webpack-etc/13064 3 | function sendMessageToStreamlitClient(type, data) { 4 | console.log(type, data) 5 | const outData = Object.assign({ 6 | isStreamlitMessage: true, 7 | type: type, 8 | }, data); 9 | window.parent.postMessage(outData, "*"); 10 | } 11 | 12 | const Streamlit = { 13 | setComponentReady: function() { 14 | sendMessageToStreamlitClient("streamlit:componentReady", {apiVersion: 1}); 15 | }, 16 | setFrameHeight: function(height) { 17 | sendMessageToStreamlitClient("streamlit:setFrameHeight", {height: height}); 18 | }, 19 | setComponentValue: function(value) { 20 | sendMessageToStreamlitClient("streamlit:setComponentValue", {value: value}); 21 | }, 22 | RENDER_EVENT: "streamlit:render", 23 | events: { 24 | addEventListener: function(type, callback) { 25 | window.addEventListener("message", function(event) { 26 | if (event.data.type === type) { 27 | event.detail = event.data 28 | callback(event); 29 | } 30 | }); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/st_keyup/frontend/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | -webkit-tap-highlight-color: var(--text-color); 3 | background-color: var(--background-color); 4 | color: var(--text-color); 5 | font-family: var(--font); 6 | } 7 | label { 8 | font-family: var(--font); 9 | font-weight: 400; 10 | line-height: 1.6; 11 | text-size-adjust: 100%; 12 | -webkit-tap-highlight-color: var(--text-color); 13 | -webkit-font-smoothing: auto; 14 | box-sizing: border-box; 15 | font-size: 13px; 16 | color: var(--text-color); 17 | margin-bottom: 0.5rem; 18 | height: auto; 19 | min-height: 1.5rem; 20 | vertical-align: middle; 21 | display: flex; 22 | flex-direction: row; 23 | -webkit-box-align: center; 24 | align-items: center; 25 | position: absolute; 26 | left: 0; 27 | top: 2px; 28 | } 29 | .input { 30 | text-size-adjust: 100%; 31 | -webkit-tap-highlight-color: var(--text-color); 32 | -webkit-font-smoothing: auto; 33 | color: var(--text-color); 34 | font-family: var(--font); 35 | font-size: 13px; 36 | font-weight: normal; 37 | line-height: 1.6; 38 | border-radius: 0.45rem; 39 | transition-duration: 200ms; 40 | display: flex; 41 | width: 100%; 42 | box-sizing: border-box; 43 | overflow: hidden; 44 | border-width: 1px; 45 | border-style: solid; 46 | transition-property: border; 47 | transition-timing-function: cubic-bezier(0.2, 0.8, 0.4, 1); 48 | border-color: var(--background-color); 49 | background-color: var(--secondary-background-color); 50 | position: absolute; 51 | left: 0; 52 | right: 0; 53 | bottom: 2px; 54 | top: 28px; 55 | } 56 | input { 57 | text-size-adjust: 100%; 58 | -webkit-tap-highlight-color: var(--text-color); 59 | -webkit-font-smoothing: auto; 60 | overflow: visible; 61 | font-family: var(--font); 62 | font-size: 15px; 63 | font-weight: normal; 64 | line-height: 1.6; 65 | width: 100%; 66 | box-sizing: border-box; 67 | color: var(--text-color); 68 | background-color: transparent; 69 | border-width: 0px; 70 | border-style: none; 71 | outline: none; 72 | max-width: 100%; 73 | cursor: text; 74 | margin: 0px; 75 | padding-top: 10px; 76 | padding-bottom: 10px; 77 | padding-left: 14px; 78 | padding-right: 14px; 79 | caret-color: var(--text-color); 80 | min-width: 0px; 81 | } 82 | .input:focus-within { 83 | text-size-adjust: 100%; 84 | -webkit-tap-highlight-color: var(--background-color); 85 | -webkit-font-smoothing: auto; 86 | color: var(--text-color); 87 | font-family: var(--font); 88 | font-size: 1rem; 89 | font-weight: normal; 90 | line-height: 1.6; 91 | border-radius: 0.45rem; 92 | transition-duration: 200ms; 93 | display: flex; 94 | width: 100%; 95 | box-sizing: border-box; 96 | overflow: hidden; 97 | border-width: 1px; 98 | border-style: solid; 99 | transition-property: border; 100 | transition-timing-function: cubic-bezier(0.2, 0.8, 0.4, 1); 101 | background-color: var(--secondary-background-color); 102 | padding-left: 0px; 103 | padding-right: 0px; 104 | border-color: var(--primary-color); 105 | } 106 | .disabled label { 107 | color: var(--secondary-background-color) !important; 108 | } 109 | 110 | .disabled input { 111 | cursor: not-allowed; 112 | color: var(--secondary-background-color) !important; 113 | } 114 | 115 | .label-hidden label { 116 | display: none !important; 117 | } 118 | 119 | .label-collapsed label { 120 | display: none !important; 121 | } 122 | 123 | .label-collapsed .input { 124 | top: 0 !important; 125 | } 126 | -------------------------------------------------------------------------------- /streamlit_app.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import streamlit as st 3 | 4 | from st_keyup import st_keyup 5 | 6 | 7 | @st.cache_data 8 | def get_cities() -> pd.DataFrame: 9 | url = "https://raw.githubusercontent.com/grammakov/USA-cities-and-states/master/us_cities_states_counties.csv" 10 | return pd.read_csv(url, sep="|") 11 | 12 | 13 | cities = get_cities() 14 | 15 | debounce = st.checkbox("Add 0.5s debounce?") 16 | 17 | disabled = st.checkbox("Disable input?") 18 | 19 | name = st_keyup( 20 | "Enter city name", debounce=500 if debounce else None, disabled=disabled 21 | ) 22 | 23 | if name: 24 | filtered = cities[cities.City.str.lower().str.contains(name.lower(), na=False)] 25 | else: 26 | filtered = cities 27 | 28 | st.write(len(filtered), "cities found") 29 | st.write(filtered) 30 | --------------------------------------------------------------------------------