From 2aea6b58d71aae815df01e773de2f3fda4f3dfb5 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 4 Jul 2026 16:40:11 -0400 Subject: [PATCH 1/2] fix: debounce toolbar hover events to stop flickering (#1330) The toolbar expand/collapse is driven by ipyevents mouseenter/mouseleave events that swap the widget's children. In webview-based frontends such as VS Code Jupyter, swapping the children re-renders the widget, which in turn fires another mouseenter/mouseleave, creating a feedback loop that makes the toolbar flicker rapidly and require multiple clicks to open. Add `throttle_or_debounce="debounce"` with a 250 ms `wait` to every toolbar hover Event so the child swap only happens after the mouse events settle, breaking the re-render -> event feedback loop while leaving click-to-toggle unaffected. Fixes #1330 --- leafmap/toolbar.py | 90 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/leafmap/toolbar.py b/leafmap/toolbar.py index f6e6db8fd6..ee6bfcf0d4 100644 --- a/leafmap/toolbar.py +++ b/leafmap/toolbar.py @@ -548,7 +548,10 @@ def tool_callback(change): toolbar_footer.children = [toolbar_grid] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -2077,7 +2080,10 @@ def census_widget(m) -> widgets.Widget: ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -2261,7 +2267,10 @@ def providers_change(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -2469,7 +2478,10 @@ def float_slider_changed(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -2669,7 +2681,10 @@ def inspector_gui( ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def chk_change(change): @@ -3092,7 +3107,10 @@ def tool_callback(change): toolbar_footer.children = [toolbar_grid] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -3271,7 +3289,10 @@ def plotly_tool_template(canvas): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -3486,7 +3507,10 @@ def providers_change(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -3600,7 +3624,10 @@ def plotly_whitebox_gui(canvas): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -3726,7 +3753,10 @@ def search_geojson_gui(m): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def layer_change(change): @@ -3906,7 +3936,10 @@ def select_table_gui(m=None): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -4182,7 +4215,10 @@ def height_changed(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -4369,7 +4405,10 @@ def int_slider_changed(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -4913,7 +4952,10 @@ def reset_options(reset_url=True): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def update_bands(): @@ -5608,7 +5650,10 @@ def reset_options(): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def update_bands(): @@ -6149,7 +6194,10 @@ def int_slider_changed(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -6596,7 +6644,10 @@ def change_dataset(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): @@ -7007,7 +7058,10 @@ def change_dataset(change): ] toolbar_event = ipyevents.Event( - source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + source=toolbar_widget, + watched_events=["mouseenter", "mouseleave"], + wait=250, + throttle_or_debounce="debounce", ) def handle_toolbar_event(event): From a8a90002fb924ed191bfc71d3fd413b3dd94adb1 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 4 Jul 2026 16:46:11 -0400 Subject: [PATCH 2/2] Address Copilot review feedback - Pin ipyevents>=0.8.0 in requirements.txt. The debounce kwargs (wait / throttle_or_debounce) have been supported since ipyevents 0.8.0, so pinning the minimum guarantees pip upgrades an older install and the new kwargs cannot raise a TypeError at runtime (Copilot). - Centralize the debounced hover-event configuration in a single _toolbar_hover_event() factory plus TOOLBAR_HOVER_DEBOUNCE_MS / TOOLBAR_HOVER_MODE constants, and use it across all 18 toolbar hover handlers instead of duplicating the wait=250 / "debounce" literals (Copilot). --- leafmap/toolbar.py | 152 +++++++++++++-------------------------------- requirements.txt | 2 +- 2 files changed, 45 insertions(+), 109 deletions(-) diff --git a/leafmap/toolbar.py b/leafmap/toolbar.py index ee6bfcf0d4..1db232723f 100644 --- a/leafmap/toolbar.py +++ b/leafmap/toolbar.py @@ -12,6 +12,32 @@ from . import pc from .common import * +# Debounce toolbar hover events to prevent the expand/collapse feedback loop +# that makes the toolbar flicker in webview frontends such as VS Code (#1330). +TOOLBAR_HOVER_DEBOUNCE_MS = 250 +TOOLBAR_HOVER_MODE = "debounce" + + +def _toolbar_hover_event(source: widgets.Widget) -> "ipyevents.Event": + """Create a debounced ipyevents Event for a toolbar hover container. + + Debouncing the ``mouseenter``/``mouseleave`` events avoids the rapid + expand/collapse feedback loop that makes the toolbar flicker in + webview-based frontends such as VS Code (see #1330). + + Args: + source (widgets.Widget): The widget to watch for hover events. + + Returns: + ipyevents.Event: The configured hover event. + """ + return ipyevents.Event( + source=source, + watched_events=["mouseenter", "mouseleave"], + wait=TOOLBAR_HOVER_DEBOUNCE_MS, + throttle_or_debounce=TOOLBAR_HOVER_MODE, + ) + def tool_template(m, opened: Optional[bool] = True) -> Optional[widgets.Widget]: """Generates a tool GUI template using ipywidgets. Icons can be found at https://fontawesome.com/v4/icons @@ -547,12 +573,7 @@ def tool_callback(change): toolbar_footer = widgets.VBox() toolbar_footer.children = [toolbar_grid] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -2079,12 +2100,7 @@ def census_widget(m) -> widgets.Widget: # output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -2266,12 +2282,7 @@ def providers_change(change): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -2477,12 +2488,7 @@ def float_slider_changed(change): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -2680,12 +2686,7 @@ def inspector_gui( output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def chk_change(change): if hasattr(m, "pixel_values"): @@ -3106,12 +3107,7 @@ def tool_callback(change): toolbar_footer = widgets.VBox(layout=widgets.Layout(overflow="hidden")) toolbar_footer.children = [toolbar_grid] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -3288,12 +3284,7 @@ def plotly_tool_template(canvas): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -3506,12 +3497,7 @@ def providers_change(change): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -3623,12 +3609,7 @@ def plotly_whitebox_gui(canvas): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -3752,12 +3733,7 @@ def search_geojson_gui(m): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def layer_change(change): if change["new"]: @@ -3935,12 +3911,7 @@ def select_table_gui(m=None): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -4214,12 +4185,7 @@ def height_changed(change): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -4404,12 +4370,7 @@ def int_slider_changed(change): buttons, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -4951,12 +4912,7 @@ def reset_options(reset_url=True): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def update_bands(): if len(stac_data) > 0: @@ -5649,12 +5605,7 @@ def reset_options(): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def update_bands(): excluded = [ @@ -6193,12 +6144,7 @@ def int_slider_changed(change): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -6643,12 +6589,7 @@ def change_dataset(change): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": @@ -7057,12 +6998,7 @@ def change_dataset(change): output, ] - toolbar_event = ipyevents.Event( - source=toolbar_widget, - watched_events=["mouseenter", "mouseleave"], - wait=250, - throttle_or_debounce="debounce", - ) + toolbar_event = _toolbar_hover_event(toolbar_widget) def handle_toolbar_event(event): if event["type"] == "mouseenter": diff --git a/requirements.txt b/requirements.txt index 067fc47998..6b9dd82ccb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ folium gdown geojson geopandas -ipyevents +ipyevents>=0.8.0 ipyfilechooser ipyleaflet ipyvue>=1.12.0