diff --git a/src/manywidgets/_base.py b/src/manywidgets/_base.py index ba94384..e912389 100644 --- a/src/manywidgets/_base.py +++ b/src/manywidgets/_base.py @@ -28,6 +28,21 @@ def _next_id(prefix: str) -> str: return f"{prefix}_{_id_counters[prefix]}" +def _flatten(children) -> list: + """Flatten one level of list/tuple args into a flat list of children. + + Lets layout widgets accept a mix of widgets and lists of widgets, e.g. + ``Column(slider, binders, m)`` where ``binders`` is itself a list. + """ + flat: list = [] + for child in children: + if isinstance(child, (list, tuple)): + flat.extend(child) + else: + flat.append(child) + return flat + + def asset(module_file: str, *parts: str) -> pathlib.Path: """Resolve a path next to a widget's ``widget.py``. diff --git a/src/manywidgets/column/tests/test_column.py b/src/manywidgets/column/tests/test_column.py index 2604766..8e205b1 100644 --- a/src/manywidgets/column/tests/test_column.py +++ b/src/manywidgets/column/tests/test_column.py @@ -7,6 +7,12 @@ def test_children_positional(): assert c.children == [a, b] +def test_flattens_list_args(): + a, b, c = Slider(), NumberDisplay(), Slider() + col = Column(a, [b, c]) # second arg is itself a list + assert col.children == [a, b, c] + + def test_defaults_and_marker(): c = Column() assert c.gap == "8px" and c.align == "stretch" diff --git a/src/manywidgets/column/widget.py b/src/manywidgets/column/widget.py index 81d2267..6fb6ce0 100644 --- a/src/manywidgets/column/widget.py +++ b/src/manywidgets/column/widget.py @@ -10,7 +10,7 @@ import traitlets from ipywidgets import Widget, widget_serialization -from .._base import BaseWidget, asset +from .._base import BaseWidget, _flatten, asset class Column(BaseWidget): @@ -31,5 +31,5 @@ class Column(BaseWidget): def __init__(self, *children, **kwargs): if children: - kwargs.setdefault("children", list(children)) + kwargs.setdefault("children", _flatten(children)) super().__init__(**kwargs) diff --git a/src/manywidgets/grid/tests/test_grid.py b/src/manywidgets/grid/tests/test_grid.py index 6601277..8a5486b 100644 --- a/src/manywidgets/grid/tests/test_grid.py +++ b/src/manywidgets/grid/tests/test_grid.py @@ -8,6 +8,12 @@ def test_children_positional_and_defaults(): assert g.columns == 2 and g.gap == "8px" +def test_flattens_list_args(): + cards = [Stat(), Stat()] + g = Grid(Stat(), cards) # second arg is itself a list + assert len(g.children) == 3 + + def test_columns_kwarg(): g = Grid(Stat(), Stat(), columns=3) assert g.columns == 3 diff --git a/src/manywidgets/grid/widget.py b/src/manywidgets/grid/widget.py index d774356..1822bcb 100644 --- a/src/manywidgets/grid/widget.py +++ b/src/manywidgets/grid/widget.py @@ -10,7 +10,7 @@ import traitlets from ipywidgets import Widget, widget_serialization -from .._base import BaseWidget, asset +from .._base import BaseWidget, _flatten, asset class Grid(BaseWidget): @@ -29,5 +29,5 @@ class Grid(BaseWidget): def __init__(self, *children, **kwargs): if children: - kwargs.setdefault("children", list(children)) + kwargs.setdefault("children", _flatten(children)) super().__init__(**kwargs) diff --git a/src/manywidgets/lonboard/filter_binder/doc.md b/src/manywidgets/lonboard/filter_binder/doc.md index f775008..eaf4919 100644 --- a/src/manywidgets/lonboard/filter_binder/doc.md +++ b/src/manywidgets/lonboard/filter_binder/doc.md @@ -37,6 +37,12 @@ binder = FilterBinder(slider, layer) # slider.low/high -> layer.filter_range Column(slider, binder, m) ``` +```{note} +For **static export**, include the binder somewhere in your displayed layout (e.g. +`Column(slider, binder, m)`) so its JavaScript activates — an exported page has no +kernel. In a **live kernel** the binding also works without displaying the binder. +``` + ## API {api-table} diff --git a/src/manywidgets/lonboard/filter_binder/src/index.ts b/src/manywidgets/lonboard/filter_binder/src/index.ts index 2ff519f..0177efb 100644 --- a/src/manywidgets/lonboard/filter_binder/src/index.ts +++ b/src/manywidgets/lonboard/filter_binder/src/index.ts @@ -28,12 +28,16 @@ async function render({ model, el }: RenderProps): Promise resolveModel(model, idOf(ref)))), ]); } catch (err) { status.textContent = `❌ filter: ${(err as Error).message}`; @@ -44,11 +48,13 @@ async function render({ model, el }: RenderProps): Promise { const low = asNumber(source.get(lowField)); const high = asNumber(source.get(highField)); - const key = `${low}:${high}:${layer.models.length}`; + const key = `${low}:${high}:${layers.map((l) => l.models.length).join(",")}`; if (key === lastKey) return; lastKey = key; - layer.setByPath(filterField, [low, high]); - layer.save(); + for (const layer of layers) { + layer.setByPath(filterField, [low, high]); + layer.save(); + } status.textContent = `✅ ${label}: [${low}, ${high}]`; }; diff --git a/src/manywidgets/lonboard/filter_binder/tests/filter_binder.test.ts b/src/manywidgets/lonboard/filter_binder/tests/filter_binder.test.ts index 237f4aa..37e6c50 100644 --- a/src/manywidgets/lonboard/filter_binder/tests/filter_binder.test.ts +++ b/src/manywidgets/lonboard/filter_binder/tests/filter_binder.test.ts @@ -45,4 +45,28 @@ describe("FilterBinder", () => { expect(p2.get("filter_range")).toEqual([1, 2]); cleanup(); }); + + it("writes to every layer when given a list of layers", async () => { + const slider = fakeModel({ widget_id: "rs3", low: 5, high: 25 }); + const layerA = fakeModel({ filter_range: null }, { model_id: "layerA" }); + const layerB = fakeModel({ filter_range: null }, { model_id: "layerB" }); + const cleanup = installHostRegistry([slider, layerA, layerB]); + const model = fakeModel({ + source: "IPY_MODEL_rs3", + layer: ["IPY_MODEL_layerA", "IPY_MODEL_layerB"], + low_field: "low", + high_field: "high", + filter_field: "filter_range", + label: "", + }); + + await widget.render({ model, el: mountEl() } as never); + expect(layerA.get("filter_range")).toEqual([5, 25]); + expect(layerB.get("filter_range")).toEqual([5, 25]); + + slider.set("high", 15); + expect(layerA.get("filter_range")).toEqual([5, 15]); + expect(layerB.get("filter_range")).toEqual([5, 15]); + cleanup(); + }); }); diff --git a/src/manywidgets/lonboard/filter_binder/tests/test_filter_binder.py b/src/manywidgets/lonboard/filter_binder/tests/test_filter_binder.py index fa9a54f..d257340 100644 --- a/src/manywidgets/lonboard/filter_binder/tests/test_filter_binder.py +++ b/src/manywidgets/lonboard/filter_binder/tests/test_filter_binder.py @@ -1,4 +1,6 @@ import pytest +import traitlets +from ipywidgets import Widget pytest.importorskip("lonboard") @@ -6,6 +8,12 @@ from manywidgets.lonboard import FilterBinder # noqa: E402 +class _FakeLayer(Widget): + """Minimal real Widget standing in for a lonboard layer with filter_range.""" + + filter_range = traitlets.Any(allow_none=True).tag(sync=True) + + def test_source_layer_and_defaults(): src = RangeSlider() layer = Slider() # stand-in Widget for the layer @@ -15,6 +23,32 @@ def test_source_layer_and_defaults(): assert fb.filter_field == "filter_range" +def test_accepts_list_of_layers(): + src = RangeSlider() + layers = [Slider(), Slider()] # stand-in Widgets for layers + fb = FilterBinder(src, layers) + assert fb.layer == layers + + +def test_python_observer_syncs_without_display(): + # The binder is never rendered; the Python observer should still drive the layer. + src = RangeSlider(low=0, high=10) + layer = _FakeLayer() + FilterBinder(src, layer) + assert layer.filter_range == [0, 10] # initial sync + src.high = 5 + assert layer.filter_range == [0, 5] + + +def test_python_observer_syncs_multiple_layers(): + src = RangeSlider(low=1, high=9) + layers = [_FakeLayer(), _FakeLayer()] + FilterBinder(src, layers) + src.low = 3 + assert layers[0].filter_range == [3, 9] + assert layers[1].filter_range == [3, 9] + + def test_traits_synced(): for name in ("source", "layer", "low_field", "high_field", "filter_field"): assert FilterBinder.class_traits()[name].metadata.get("sync") is True diff --git a/src/manywidgets/lonboard/filter_binder/widget.py b/src/manywidgets/lonboard/filter_binder/widget.py index 2027048..09470ba 100644 --- a/src/manywidgets/lonboard/filter_binder/widget.py +++ b/src/manywidgets/lonboard/filter_binder/widget.py @@ -4,6 +4,10 @@ a :class:`~manywidgets.Slider` with ``low_field == high_field == "value"``) and writes it to a layer's ``filter_range`` (from ``DataFilterExtension``). Works live and in static export. + +In a live kernel a Python observer keeps the layer in sync even if this widget is +never displayed; static export has no kernel, so there the binder must be rendered +(e.g. inside a ``Column``) for its JavaScript to activate. """ from __future__ import annotations @@ -15,15 +19,24 @@ class FilterBinder(BaseWidget): - """Bind a (Range)Slider to a lonboard layer's ``filter_range``.""" + """Bind a (Range)Slider to one or more lonboard layers' ``filter_range``. + + ``layer`` may be a single layer or a list of layers; one slider then drives + them all. + """ _esm = asset(__file__, "dist", "widget.js") source = traitlets.Instance( Widget, allow_none=True, help="The slider providing low/high values." ).tag(sync=True, **widget_serialization) - layer = traitlets.Instance( - Widget, allow_none=True, help="The lonboard layer to filter." + layer = traitlets.Union( + [ + traitlets.Instance(Widget), + traitlets.List(traitlets.Instance(Widget)), + ], + allow_none=True, + help="A single lonboard layer, or a list of layers, to filter.", ).tag(sync=True, **widget_serialization) low_field = traitlets.Unicode("low", help="Source trait for the low bound.").tag(sync=True) high_field = traitlets.Unicode("high", help="Source trait for the high bound.").tag(sync=True) @@ -38,3 +51,22 @@ def __init__(self, source=None, layer=None, **kwargs): if layer is not None: kwargs.setdefault("layer", layer) super().__init__(**kwargs) + # Live-kernel fallback: keep ``filter_range`` in sync via a Python observer + # so the binding works even when this widget is never displayed. Inert in + # static export (no kernel); the JS ``render()`` covers that path. See the + # docstring note. (The observer attaches only if ``source`` is set here.) + if self.source is not None: + names = list(dict.fromkeys([self.low_field, self.high_field])) + self.source.observe(self._sync_filter, names=names) + self._sync_filter() + + def _sync_filter(self, change=None): + """Mirror the source's ``[low, high]`` onto each layer's ``filter_range``.""" + if self.source is None: + return + low = getattr(self.source, self.low_field) + high = getattr(self.source, self.high_field) + layers = self.layer if isinstance(self.layer, list) else [self.layer] + for layer in layers: + if layer is not None: + setattr(layer, self.filter_field, [low, high]) diff --git a/src/manywidgets/row/tests/test_row.py b/src/manywidgets/row/tests/test_row.py index 6a6d3fc..fb094ac 100644 --- a/src/manywidgets/row/tests/test_row.py +++ b/src/manywidgets/row/tests/test_row.py @@ -7,6 +7,12 @@ def test_children_positional(): assert r.children == [a, b] +def test_flattens_list_args(): + a, b, c = Slider(), Stat(), Slider() + r = Row(a, [b, c]) # second arg is itself a list + assert r.children == [a, b, c] + + def test_children_kwarg_and_defaults(): a = Slider() r = Row(children=[a]) diff --git a/src/manywidgets/row/widget.py b/src/manywidgets/row/widget.py index df6fb28..2aed44b 100644 --- a/src/manywidgets/row/widget.py +++ b/src/manywidgets/row/widget.py @@ -12,7 +12,7 @@ import traitlets from ipywidgets import Widget, widget_serialization -from .._base import BaseWidget, asset +from .._base import BaseWidget, _flatten, asset class Row(BaseWidget): @@ -34,5 +34,5 @@ class Row(BaseWidget): def __init__(self, *children, **kwargs): if children: - kwargs.setdefault("children", list(children)) + kwargs.setdefault("children", _flatten(children)) super().__init__(**kwargs) diff --git a/src/manywidgets/skill/references/widgets-api.md b/src/manywidgets/skill/references/widgets-api.md index e899fae..9be75b1 100644 --- a/src/manywidgets/skill/references/widgets-api.md +++ b/src/manywidgets/skill/references/widgets-api.md @@ -306,7 +306,7 @@ LayerFilter(layer, categories, value, label='Filter') ### `FilterBinder` -Bind a (Range)Slider to a lonboard layer's ``filter_range``. +Bind a (Range)Slider to one or more lonboard layers' ``filter_range``. ```python FilterBinder(source, layer, low_field='low', high_field='high', filter_field='filter_range', label='') @@ -315,7 +315,7 @@ FilterBinder(source, layer, low_field='low', high_field='high', filter_field='fi | Trait | Type | Default | Description | |---|---|---|---| | `source` | Instance | — | The slider providing low/high values. | -| `layer` | Instance | — | The lonboard layer to filter. | +| `layer` | Union | — | A single lonboard layer, or a list of layers, to filter. | | `low_field` | Unicode | `'low'` | Source trait for the low bound. | | `high_field` | Unicode | `'high'` | Source trait for the high bound. | | `filter_field` | Unicode | `'filter_range'` | Layer trait to write [low, high] to. |