Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/manywidgets/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down
6 changes: 6 additions & 0 deletions src/manywidgets/column/tests/test_column.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/manywidgets/column/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
6 changes: 6 additions & 0 deletions src/manywidgets/grid/tests/test_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/manywidgets/grid/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
6 changes: 6 additions & 0 deletions src/manywidgets/lonboard/filter_binder/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
18 changes: 12 additions & 6 deletions src/manywidgets/lonboard/filter_binder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ async function render({ model, el }: RenderProps<FilterBinderModel>): Promise<vo
const label = model.get("label") || `${lowField}/${highField} → ${filterField}`;
status.textContent = `🔗 filter: connecting… (${label})`;

// `layer` may be a single widget-ref or a list of them; resolve all of them.
const rawLayer = model.get("layer");
const layerRefs: unknown[] = Array.isArray(rawLayer) ? rawLayer : [rawLayer];

let source: ModelHandle;
let layer: ModelHandle;
let layers: ModelHandle[];
try {
[source, layer] = await Promise.all([
[source, layers] = await Promise.all([
resolveModel(model, idOf(model.get("source"))),
resolveModel(model, idOf(model.get("layer"))),
Promise.all(layerRefs.map((ref) => resolveModel(model, idOf(ref)))),
]);
} catch (err) {
status.textContent = `❌ filter: ${(err as Error).message}`;
Expand All @@ -44,11 +48,13 @@ async function render({ model, el }: RenderProps<FilterBinderModel>): Promise<vo
const apply = (): void => {
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}]`;
};

Expand Down
24 changes: 24 additions & 0 deletions src/manywidgets/lonboard/filter_binder/tests/filter_binder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
34 changes: 34 additions & 0 deletions src/manywidgets/lonboard/filter_binder/tests/test_filter_binder.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import pytest
import traitlets
from ipywidgets import Widget

pytest.importorskip("lonboard")

from manywidgets import RangeSlider, Slider # noqa: E402
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
Expand All @@ -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
Expand Down
38 changes: 35 additions & 3 deletions src/manywidgets/lonboard/filter_binder/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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])
6 changes: 6 additions & 0 deletions src/manywidgets/row/tests/test_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
4 changes: 2 additions & 2 deletions src/manywidgets/row/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
4 changes: 2 additions & 2 deletions src/manywidgets/skill/references/widgets-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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='')
Expand All @@ -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. |
Expand Down
Loading