diff --git a/README.md b/README.md index f0039cc..edcc77e 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,18 @@ style_absolute: bool = False Will position the searchbox as an absolute element. *NOTE:* this will affect all searchbox instances and should either be set for all boxes or none. See [#46](https://github.com/m-wrzr/streamlit-searchbox/issues/46) for inital workaround by [@JoshElgar](https://github.com/JoshElgar). +```python +debounce: int = 0 +``` + +Delay executing the callback from the react component by `x` milliseconds to avoid too many / redudant requests, i.e. during fast typing. + +```python +min_execution_time: int = 0 +``` + +Delay execution after the search function finished to reach a minimum amount of `x` milliseconds. This can be used to avoid fast consecutive reruns, which can cause resets of the component in some streamlit versions `>=1.35`. + ```python key: str = "searchbox" ``` @@ -182,3 +194,4 @@ We welcome contributions from everyone. Here are a few ways you can help: - [@JoshElgar](https://github.com/JoshElgar) absolute positioning workaround - [@dopc](https://github.com/dopc) bugfix for [#15](https://github.com/m-wrzr/streamlit-searchbox/issues/15) - [@Jumitti](https://github.com/Jumitti) `st.rerun` compatibility +- [@salmanrazzaq-94](https://github.com/salmanrazzaq-94) `st.fragment` support diff --git a/example.py b/example.py index 7d61249..df61851 100644 --- a/example.py +++ b/example.py @@ -98,6 +98,22 @@ def search_kwargs(searchterm: str, **kwargs) -> List[str]: clear_on_submit=False, key=search.__name__, ), + dict( + search_function=search, + default=None, + label=f"{search.__name__}_debounce_250ms", + clear_on_submit=False, + debounce=250, + key=f"{search.__name__}_debounce_250ms", + ), + dict( + search_function=search, + default=None, + label=f"{search.__name__}_min_execution_time_500ms", + clear_on_submit=False, + min_execution_time=500, + key=f"{search.__name__}_min_execution_time_500ms", + ), dict( search_function=search_rnd_delay, default=None, @@ -203,8 +219,14 @@ def search_kwargs(searchterm: str, **kwargs) -> List[str]: ] -searchboxes, visual_ref, form_example, manual_example = st.tabs( - ["Searchboxes", "Visual Reference", "Form Example", "Manual Example"] +searchboxes, visual_ref, form_example, manual_example, fragment_example = st.tabs( + [ + "Searchboxes", + "Visual Reference", + "Form Example", + "Manual Example", + "Fragment Example", + ] ) with searchboxes: @@ -292,3 +314,48 @@ def search_kwargs(searchterm: str, **kwargs) -> List[str]: ) st.write(manual) + +with fragment_example: + if st.__version__ < "1.37": + st.write(f"streamlit >=1.37 needed for this example. version={st.__version__}") + st.stop() + + if "app_runs" not in st.session_state: + st.session_state.app_runs = 0 + st.session_state.fragment_runs = 0 + + @st.fragment # type: ignore - code not reached in older streamlit versions + def _fragment(): + st.session_state.fragment_runs += 1 + st.button("Run Fragment") + + selected_value_fragment = st_searchbox( + search_wikipedia_ids, + key="wiki_searchbox_fragment", + rerun_on_update=True, + rerun_scope="fragment", + ) + + if selected_value_fragment: + st.write(selected_value_fragment) + + st.write(f"Fragment says it ran {st.session_state.fragment_runs} times.") + + st.session_state.app_runs += 1 + + _fragment() + + st.button("Rerun full app") + + selected_value_app = st_searchbox( + search_wikipedia_ids, + key="wiki_searchbox_full_app", + rerun_on_update=True, + rerun_scope="app", + ) + + if selected_value_app: + st.write(selected_value_app) + + st.write(f"Full app says it ran {st.session_state.app_runs} times.") + st.write(f"Full app sees that fragment ran {st.session_state.fragment_runs} times.") diff --git a/setup.py b/setup.py index 1e8a2f3..df4fa6d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setuptools.setup( name="streamlit-searchbox", - version="0.1.14", + version="0.1.15", author="m-wrzr", description="Autocomplete Searchbox", long_description="Streamlit searchbox that dynamically updates " @@ -14,9 +14,9 @@ classifiers=[], python_requires=">=3.8, !=3.9.7", install_requires=[ - # version 1.37 reruns lead to constant iFrame resets + # version >1.37 reruns can lead to constant iFrame resets # version 1.35/1.36 also have reset issues but less frequent - "streamlit >= 1.0, != 1.37.0", + "streamlit >= 1.0", ], extras_require={ "tests": [ diff --git a/streamlit_searchbox/__init__.py b/streamlit_searchbox/__init__.py index c4e097f..aa4977e 100644 --- a/streamlit_searchbox/__init__.py +++ b/streamlit_searchbox/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations +import datetime import functools import logging import os @@ -14,12 +15,18 @@ import streamlit.components.v1 as components try: - from streamlit import rerun as rerun # type: ignore + from streamlit import rerun # type: ignore except ImportError: # conditional import for streamlit version <1.27 from streamlit import experimental_rerun as rerun # type: ignore +# default milliseconds for the search function to run, this is used to avoid +# fast consecutive reruns. possibly remove this in later versions +# see: https://github.com/streamlit/streamlit/issues/9002 +MIN_EXECUTION_TIME_DEFAULT = 250 if st.__version__ >= "1.35" else 0 + + # point to build directory parent_dir = os.path.dirname(os.path.abspath(__file__)) build_dir = os.path.join(parent_dir, "frontend/build") @@ -78,13 +85,18 @@ def _process_search( key: str, searchterm: str, rerun_on_update: bool, + rerun_scope: Literal["app", "fragment"] = "app", + min_execution_time: int = 0, **kwargs, ) -> None: # nothing changed, avoid new search if searchterm == st.session_state[key]["search"]: - return st.session_state[key]["result"] + return st.session_state[key]["search"] = searchterm + + ts_start = datetime.datetime.now() + search_results = search_function(searchterm, **kwargs) if search_results is None: @@ -94,7 +106,18 @@ def _process_search( st.session_state[key]["options_py"] = _list_to_options_py(search_results) if rerun_on_update: - rerun() + ts_stop = datetime.datetime.now() + execution_time_ms = (ts_stop - ts_start).total_seconds() * 1000 + + # wait until minimal execution time is reached + if execution_time_ms < min_execution_time: + time.sleep((min_execution_time - execution_time_ms) / 1000) + + # only pass scope if the version is >= 1.37 + if st.__version__ >= "1.37": + rerun(scope=rerun_scope) # type: ignore + else: + rerun() def _set_defaults( @@ -176,7 +199,10 @@ def st_searchbox( edit_after_submit: Literal["disabled", "current", "option", "concat"] = "disabled", style_absolute: bool = False, style_overrides: StyleOverrides | None = None, + debounce: int = 150, + min_execution_time: int = MIN_EXECUTION_TIME_DEFAULT, key: str = "searchbox", + rerun_scope: Literal["app", "fragment"] = "app", **kwargs, ) -> Any: """ @@ -207,6 +233,16 @@ def st_searchbox( searchboxes and should be passed to every element. Defaults to False. style_overrides (StyleOverrides, optional): CSS styling passed directly to the react components. Defaults to None. + rerun_scope ("app", "fragment", optional): + The scope in which to rerun the Streamlit app. Only applicable if Streamlit + version >= 1.37. Defaults to "app". + debounce (int, optional): + Time in milliseconds to wait before sending the input to the search function + to avoid too many requests, i.e. during fast keystrokes. Defaults to 0. + min_execution_time (int, optional): + Minimal execution time for the search function in milliseconds. This is used + to avoid fast consecutive reruns, where fast reruns can lead to resets + within the component in some streamlit versions. Defaults to 0. key (str, optional): Streamlit session key. Defaults to "searchbox". @@ -225,6 +261,7 @@ def st_searchbox( label=label, edit_after_submit=edit_after_submit, style_overrides=style_overrides, + debounce=debounce, # react return state within streamlit session_state key=st.session_state[key]["key_react"], ) @@ -252,7 +289,15 @@ def st_searchbox( st.session_state[key]["result"] = value # triggers rerun, no ops afterwards executed - _process_search(search_function, key, value, rerun_on_update, **kwargs) + _process_search( + search_function, + key, + value, + rerun_on_update, + rerun_scope=rerun_scope, + min_execution_time=min_execution_time, + **kwargs, + ) if interaction == "submit": st.session_state[key]["result"] = ( @@ -266,7 +311,11 @@ def st_searchbox( _set_defaults(key, default, default_options) if rerun_on_update: - rerun() + # only pass scope if the version is >= 1.37 + if st.__version__ >= "1.37": + rerun(scope=rerun_scope) # type: ignore + else: + rerun() return default diff --git a/streamlit_searchbox/frontend/package-lock.json b/streamlit_searchbox/frontend/package-lock.json index 3f91f2d..f9a1ceb 100644 --- a/streamlit_searchbox/frontend/package-lock.json +++ b/streamlit_searchbox/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "streamlit_searchbox", - "version": "0.1.14", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "streamlit_searchbox", - "version": "0.1.14", + "version": "0.1.15", "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/streamlit_searchbox/frontend/package.json b/streamlit_searchbox/frontend/package.json index d4d39f1..be0208a 100644 --- a/streamlit_searchbox/frontend/package.json +++ b/streamlit_searchbox/frontend/package.json @@ -1,12 +1,13 @@ { "name": "streamlit_searchbox", - "version": "0.1.14", + "version": "0.1.15", "private": true, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1", "react-select": "^5.8.0", - "streamlit-component-lib": "^2.0.0" + "streamlit-component-lib": "^2.0.0", + "lodash": "^4.17.21" }, "devDependencies": { "@types/lodash": "^4.14.150", diff --git a/streamlit_searchbox/frontend/src/Searchbox.tsx b/streamlit_searchbox/frontend/src/Searchbox.tsx index 8b09dd2..9403a85 100644 --- a/streamlit_searchbox/frontend/src/Searchbox.tsx +++ b/streamlit_searchbox/frontend/src/Searchbox.tsx @@ -7,6 +7,7 @@ import React, { ReactNode } from "react"; import SearchboxStyle from "./styling"; import Select, { InputActionMeta, components } from "react-select"; +import { debounce } from "lodash"; type Option = { value: string; @@ -45,6 +46,18 @@ class Searchbox extends StreamlitComponentBase { ); private ref: any = React.createRef(); + constructor(props: any) { + super(props); + + // bind the search function and debounce to avoid too many requests + if (props.args.debounce && props.args.debounce > 0) { + this.callbackSearch = debounce( + this.callbackSearch.bind(this), + props.args.debounce, + ); + } + } + /** * new keystroke on searchbox * @param input diff --git a/streamlit_searchbox/frontend/src/icons.tsx b/streamlit_searchbox/frontend/src/icons.tsx index 3436c34..fd8e3a8 100644 --- a/streamlit_searchbox/frontend/src/icons.tsx +++ b/streamlit_searchbox/frontend/src/icons.tsx @@ -8,8 +8,19 @@ This file contains the icons used in the searchbox component. See https://lucide export const DropdownIcon: React.FC> = ( props, ) => ( - - + + ); diff --git a/streamlit_searchbox/frontend/src/styling.tsx b/streamlit_searchbox/frontend/src/styling.tsx index 0a25f05..73a5d0d 100644 --- a/streamlit_searchbox/frontend/src/styling.tsx +++ b/streamlit_searchbox/frontend/src/styling.tsx @@ -124,7 +124,7 @@ class SearchboxStyle { // streamlit has fixed icon sizes at 15x15 width={15} height={15} - fill={this.theme.textColor} + stroke={this.theme.textColor} transform={menu && overrides && overrides.rotate ? "rotate(180)" : ""} {...overrides} />