manywidgets 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. manywidgets/__init__.py +49 -0
  2. manywidgets/__main__.py +107 -0
  3. manywidgets/_base.py +73 -0
  4. manywidgets/_version.py +1 -0
  5. manywidgets/binder/__init__.py +3 -0
  6. manywidgets/binder/dist/widget.js +171 -0
  7. manywidgets/binder/doc.md +38 -0
  8. manywidgets/binder/src/index.ts +81 -0
  9. manywidgets/binder/widget.py +81 -0
  10. manywidgets/button/__init__.py +3 -0
  11. manywidgets/button/dist/widget.js +31 -0
  12. manywidgets/button/doc.md +46 -0
  13. manywidgets/button/src/index.ts +27 -0
  14. manywidgets/button/style.css +22 -0
  15. manywidgets/button/widget.py +46 -0
  16. manywidgets/chart/__init__.py +3 -0
  17. manywidgets/chart/dist/widget.js +14663 -0
  18. manywidgets/chart/doc.md +50 -0
  19. manywidgets/chart/src/index.ts +201 -0
  20. manywidgets/chart/style.css +10 -0
  21. manywidgets/chart/widget.py +126 -0
  22. manywidgets/column/__init__.py +3 -0
  23. manywidgets/column/dist/widget.js +72 -0
  24. manywidgets/column/doc.md +31 -0
  25. manywidgets/column/src/index.ts +50 -0
  26. manywidgets/column/style.css +7 -0
  27. manywidgets/column/widget.py +35 -0
  28. manywidgets/dropdown/__init__.py +3 -0
  29. manywidgets/dropdown/dist/widget.js +60 -0
  30. manywidgets/dropdown/doc.md +36 -0
  31. manywidgets/dropdown/src/index.ts +72 -0
  32. manywidgets/dropdown/style.css +28 -0
  33. manywidgets/dropdown/widget.py +41 -0
  34. manywidgets/grid/__init__.py +3 -0
  35. manywidgets/grid/dist/widget.js +72 -0
  36. manywidgets/grid/doc.md +35 -0
  37. manywidgets/grid/src/index.ts +50 -0
  38. manywidgets/grid/style.css +7 -0
  39. manywidgets/grid/widget.py +33 -0
  40. manywidgets/legend/__init__.py +3 -0
  41. manywidgets/legend/dist/widget.js +55 -0
  42. manywidgets/legend/doc.md +32 -0
  43. manywidgets/legend/src/index.ts +62 -0
  44. manywidgets/legend/style.css +46 -0
  45. manywidgets/legend/widget.py +42 -0
  46. manywidgets/lonboard/__init__.py +24 -0
  47. manywidgets/lonboard/conftest.py +20 -0
  48. manywidgets/lonboard/filter_binder/__init__.py +3 -0
  49. manywidgets/lonboard/filter_binder/dist/widget.js +177 -0
  50. manywidgets/lonboard/filter_binder/doc.md +52 -0
  51. manywidgets/lonboard/filter_binder/src/index.ts +77 -0
  52. manywidgets/lonboard/filter_binder/widget.py +72 -0
  53. manywidgets/lonboard/layer_filter/__init__.py +3 -0
  54. manywidgets/lonboard/layer_filter/dist/widget.js +190 -0
  55. manywidgets/lonboard/layer_filter/doc.md +41 -0
  56. manywidgets/lonboard/layer_filter/src/index.ts +94 -0
  57. manywidgets/lonboard/layer_filter/style.css +37 -0
  58. manywidgets/lonboard/layer_filter/widget.py +43 -0
  59. manywidgets/lonboard/layer_toggle/__init__.py +3 -0
  60. manywidgets/lonboard/layer_toggle/dist/widget.js +177 -0
  61. manywidgets/lonboard/layer_toggle/doc.md +36 -0
  62. manywidgets/lonboard/layer_toggle/src/index.ts +78 -0
  63. manywidgets/lonboard/layer_toggle/style.css +54 -0
  64. manywidgets/lonboard/layer_toggle/widget.py +31 -0
  65. manywidgets/lonboard/map_compare/__init__.py +3 -0
  66. manywidgets/lonboard/map_compare/dist/widget.js +317 -0
  67. manywidgets/lonboard/map_compare/doc.md +65 -0
  68. manywidgets/lonboard/map_compare/src/index.ts +243 -0
  69. manywidgets/lonboard/map_compare/style.css +99 -0
  70. manywidgets/lonboard/map_compare/widget.py +111 -0
  71. manywidgets/lonboard/map_flyer/__init__.py +3 -0
  72. manywidgets/lonboard/map_flyer/dist/widget.js +177 -0
  73. manywidgets/lonboard/map_flyer/doc.md +43 -0
  74. manywidgets/lonboard/map_flyer/src/index.ts +78 -0
  75. manywidgets/lonboard/map_flyer/style.css +41 -0
  76. manywidgets/lonboard/map_flyer/widget.py +47 -0
  77. manywidgets/number_display/__init__.py +3 -0
  78. manywidgets/number_display/dist/widget.js +80 -0
  79. manywidgets/number_display/doc.md +28 -0
  80. manywidgets/number_display/src/index.ts +100 -0
  81. manywidgets/number_display/style.css +23 -0
  82. manywidgets/number_display/widget.py +31 -0
  83. manywidgets/number_input/__init__.py +3 -0
  84. manywidgets/number_input/dist/widget.js +58 -0
  85. manywidgets/number_input/doc.md +34 -0
  86. manywidgets/number_input/src/index.ts +55 -0
  87. manywidgets/number_input/style.css +28 -0
  88. manywidgets/number_input/widget.py +24 -0
  89. manywidgets/range_slider/__init__.py +3 -0
  90. manywidgets/range_slider/dist/widget.js +84 -0
  91. manywidgets/range_slider/doc.md +36 -0
  92. manywidgets/range_slider/src/index.ts +89 -0
  93. manywidgets/range_slider/style.css +40 -0
  94. manywidgets/range_slider/widget.py +26 -0
  95. manywidgets/row/__init__.py +3 -0
  96. manywidgets/row/dist/widget.js +73 -0
  97. manywidgets/row/doc.md +32 -0
  98. manywidgets/row/src/index.ts +51 -0
  99. manywidgets/row/style.css +7 -0
  100. manywidgets/row/widget.py +38 -0
  101. manywidgets/skill/SKILL.md +71 -0
  102. manywidgets/skill/references/authoring.md +113 -0
  103. manywidgets/skill/references/usage.md +143 -0
  104. manywidgets/skill/references/widgets-api.md +358 -0
  105. manywidgets/slider/__init__.py +3 -0
  106. manywidgets/slider/dist/widget.js +61 -0
  107. manywidgets/slider/doc.md +34 -0
  108. manywidgets/slider/src/index.ts +66 -0
  109. manywidgets/slider/style.css +35 -0
  110. manywidgets/slider/widget.py +25 -0
  111. manywidgets/stat/__init__.py +3 -0
  112. manywidgets/stat/dist/widget.js +51 -0
  113. manywidgets/stat/doc.md +28 -0
  114. manywidgets/stat/src/index.ts +57 -0
  115. manywidgets/stat/style.css +55 -0
  116. manywidgets/stat/widget.py +26 -0
  117. manywidgets/text/__init__.py +3 -0
  118. manywidgets/text/dist/widget.js +2104 -0
  119. manywidgets/text/doc.md +31 -0
  120. manywidgets/text/src/index.ts +31 -0
  121. manywidgets/text/style.css +23 -0
  122. manywidgets/text/widget.py +22 -0
  123. manywidgets/toggle/__init__.py +3 -0
  124. manywidgets/toggle/dist/widget.js +40 -0
  125. manywidgets/toggle/doc.md +34 -0
  126. manywidgets/toggle/src/index.ts +43 -0
  127. manywidgets/toggle/style.css +54 -0
  128. manywidgets/toggle/widget.py +17 -0
  129. manywidgets-0.1.0.dist-info/METADATA +168 -0
  130. manywidgets-0.1.0.dist-info/RECORD +133 -0
  131. manywidgets-0.1.0.dist-info/WHEEL +4 -0
  132. manywidgets-0.1.0.dist-info/entry_points.txt +2 -0
  133. manywidgets-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,49 @@
1
+ """manywidgets — a composable set of anywidget-based widgets for notebooks.
2
+
3
+ Widgets are plain anywidgets: they work in a live Jupyter kernel and are
4
+ authored to also render correctly when statically exported (no kernel) via the
5
+ ``myst-anywidget-static-export`` plugin. Link them with ``ipywidgets.jslink`` /
6
+ ``jsdlink`` for the simple cases, or the :class:`~manywidgets.Binder` widget for
7
+ transforms and nested-path targets that jslink can't express.
8
+
9
+ The optional ``manywidgets.lonboard`` subpackage adds control widgets for lonboard
10
+ maps; install it with ``pip install "manywidgets[lonboard]"``.
11
+ """
12
+
13
+ from ._base import BaseWidget
14
+ from ._version import __version__
15
+ from .binder import Binder
16
+ from .button import Button
17
+ from .chart import Chart
18
+ from .column import Column
19
+ from .dropdown import Dropdown
20
+ from .grid import Grid
21
+ from .legend import Legend
22
+ from .number_display import NumberDisplay
23
+ from .number_input import NumberInput
24
+ from .range_slider import RangeSlider
25
+ from .row import Row
26
+ from .slider import Slider
27
+ from .stat import Stat
28
+ from .text import Text
29
+ from .toggle import Toggle
30
+
31
+ __all__ = [
32
+ "BaseWidget",
33
+ "Binder",
34
+ "Button",
35
+ "Chart",
36
+ "Column",
37
+ "Dropdown",
38
+ "Grid",
39
+ "Legend",
40
+ "NumberDisplay",
41
+ "NumberInput",
42
+ "RangeSlider",
43
+ "Row",
44
+ "Slider",
45
+ "Stat",
46
+ "Text",
47
+ "Toggle",
48
+ "__version__",
49
+ ]
@@ -0,0 +1,107 @@
1
+ """``manywidgets`` command-line entry point.
2
+
3
+ Currently exposes a single subcommand, ``install-skill``, which copies the
4
+ bundled agent skill (``manywidgets/skill/``) into a location your coding agent
5
+ discovers. Claude Code looks in ``.claude/skills/`` (project) and
6
+ ``~/.claude/skills/`` (personal), but the skill is plain Markdown — use
7
+ ``--path`` to drop it wherever another agent expects it.
8
+
9
+ manywidgets install-skill # ./.claude/skills/manywidgets/
10
+ manywidgets install-skill --user # ~/.claude/skills/manywidgets/
11
+ manywidgets install-skill --path DIR # DIR/manywidgets/
12
+ manywidgets install-skill --force # overwrite an existing copy
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import pathlib
19
+ import shutil
20
+ import sys
21
+ from importlib import resources
22
+
23
+
24
+ def _bundled_skill_dir() -> pathlib.Path:
25
+ """Path to the skill files shipped inside the installed package."""
26
+ return pathlib.Path(str(resources.files("manywidgets") / "skill"))
27
+
28
+
29
+ def _resolve_target(args: argparse.Namespace) -> pathlib.Path:
30
+ """Where the ``manywidgets/`` skill folder should be written."""
31
+ if args.path:
32
+ base = pathlib.Path(args.path).expanduser()
33
+ elif args.user:
34
+ base = pathlib.Path.home() / ".claude" / "skills"
35
+ else:
36
+ base = pathlib.Path.cwd() / ".claude" / "skills"
37
+ return base / "manywidgets"
38
+
39
+
40
+ def install_skill(args: argparse.Namespace) -> int:
41
+ src = _bundled_skill_dir()
42
+ if not (src / "SKILL.md").is_file():
43
+ print(
44
+ f"error: bundled skill not found at {src}. "
45
+ "Reinstall manywidgets, or run from a source checkout after "
46
+ "`npm run skill:gen`.",
47
+ file=sys.stderr,
48
+ )
49
+ return 1
50
+
51
+ dest = _resolve_target(args)
52
+ if dest.exists():
53
+ if not args.force:
54
+ print(
55
+ f"error: {dest} already exists. Re-run with --force to overwrite.",
56
+ file=sys.stderr,
57
+ )
58
+ return 1
59
+ shutil.rmtree(dest)
60
+
61
+ dest.parent.mkdir(parents=True, exist_ok=True)
62
+ # Copy only the skill content (SKILL.md + references/*.md), not stray files.
63
+ shutil.copytree(
64
+ src,
65
+ dest,
66
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
67
+ )
68
+ print(f"Installed manywidgets skill to {dest}")
69
+ return 0
70
+
71
+
72
+ def build_parser() -> argparse.ArgumentParser:
73
+ parser = argparse.ArgumentParser(prog="manywidgets")
74
+ sub = parser.add_subparsers(dest="command", required=True)
75
+
76
+ p = sub.add_parser(
77
+ "install-skill",
78
+ help="Copy the bundled agent skill into a discoverable location.",
79
+ description=install_skill.__doc__,
80
+ )
81
+ where = p.add_mutually_exclusive_group()
82
+ where.add_argument(
83
+ "--user",
84
+ action="store_true",
85
+ help="Install to ~/.claude/skills/ instead of the current project.",
86
+ )
87
+ where.add_argument(
88
+ "--path",
89
+ metavar="DIR",
90
+ help="Install under an arbitrary directory (for agents other than Claude).",
91
+ )
92
+ p.add_argument(
93
+ "--force",
94
+ action="store_true",
95
+ help="Overwrite an existing installation.",
96
+ )
97
+ p.set_defaults(func=install_skill)
98
+ return parser
99
+
100
+
101
+ def main(argv: list[str] | None = None) -> int:
102
+ args = build_parser().parse_args(argv)
103
+ return args.func(args)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
manywidgets/_base.py ADDED
@@ -0,0 +1,73 @@
1
+ """Shared base class and small helpers for every manywidgets widget.
2
+
3
+ ``BaseWidget`` is a thin ``anywidget.AnyWidget`` subclass — no magic; widgets
4
+ remain plain anywidgets. Its one job is to give every widget a stable, unique
5
+ ``widget_id`` trait, which is the handle that ``Binder`` and the shared
6
+ ``@manywidgets/core`` registry use to find a widget across the live-kernel /
7
+ static-export boundary.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pathlib
13
+ import threading
14
+ from collections import defaultdict
15
+
16
+ import anywidget
17
+ import traitlets
18
+
19
+ # Per-class monotonic counters so auto-generated ids are stable and readable
20
+ # (``chart_1``, ``chart_2``, ``slider_1``, …) within a kernel session.
21
+ _id_counters: dict[str, int] = defaultdict(int)
22
+ _id_lock = threading.Lock()
23
+
24
+
25
+ def _next_id(prefix: str) -> str:
26
+ with _id_lock:
27
+ _id_counters[prefix] += 1
28
+ return f"{prefix}_{_id_counters[prefix]}"
29
+
30
+
31
+ def _flatten(children) -> list:
32
+ """Flatten one level of list/tuple args into a flat list of children.
33
+
34
+ Lets layout widgets accept a mix of widgets and lists of widgets, e.g.
35
+ ``Column(slider, binders, m)`` where ``binders`` is itself a list.
36
+ """
37
+ flat: list = []
38
+ for child in children:
39
+ if isinstance(child, (list, tuple)):
40
+ flat.extend(child)
41
+ else:
42
+ flat.append(child)
43
+ return flat
44
+
45
+
46
+ def asset(module_file: str, *parts: str) -> pathlib.Path:
47
+ """Resolve a path next to a widget's ``widget.py``.
48
+
49
+ Usage inside a widget module::
50
+
51
+ _esm = asset(__file__, "dist", "widget.js")
52
+ _css = asset(__file__, "style.css")
53
+ """
54
+ return pathlib.Path(module_file).parent.joinpath(*parts)
55
+
56
+
57
+ class BaseWidget(anywidget.AnyWidget):
58
+ """Base class for all manywidgets widgets.
59
+
60
+ Adds an auto-populated ``widget_id`` (``f"{classname_lower}_{n}"``) used for
61
+ cross-widget linking. Subclasses set their own ``_esm`` / ``_css`` (relative
62
+ to their own file, via :func:`asset`) exactly like the golden-example
63
+ structure.
64
+ """
65
+
66
+ widget_id = traitlets.Unicode(
67
+ help="Stable unique id used for cross-widget linking (auto-assigned)."
68
+ ).tag(sync=True)
69
+
70
+ def __init__(self, **kwargs):
71
+ super().__init__(**kwargs)
72
+ if not self.widget_id:
73
+ self.widget_id = _next_id(type(self).__name__.lower())
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .widget import Binder
2
+
3
+ __all__ = ["Binder"]
@@ -0,0 +1,171 @@
1
+ // packages/core/src/index.ts
2
+ function safeSaveChanges(model) {
3
+ try {
4
+ model?.save_changes?.();
5
+ } catch {
6
+ }
7
+ }
8
+ function stripIpy(id) {
9
+ return id ? String(id).replace(/^IPY_MODEL_/, "") : "";
10
+ }
11
+ function asNumber(value, fallback = 0) {
12
+ const n = typeof value === "number" ? value : Number(value);
13
+ return Number.isFinite(n) ? n : fallback;
14
+ }
15
+ function setByPath(model, path, value) {
16
+ const parts = path.split(".");
17
+ if (parts.length === 1) {
18
+ model.set(parts[0], value);
19
+ return;
20
+ }
21
+ const topKey = parts[0];
22
+ const existing = model.get(topKey);
23
+ const next = existing && typeof existing === "object" ? { ...existing } : {};
24
+ let cursor = next;
25
+ for (let i = 1; i < parts.length - 1; i++) {
26
+ const k = parts[i];
27
+ const child = cursor[k];
28
+ cursor[k] = child && typeof child === "object" ? { ...child } : {};
29
+ cursor = cursor[k];
30
+ }
31
+ cursor[parts[parts.length - 1]] = value;
32
+ model.set(topKey, next);
33
+ }
34
+ function deliverCustomMessage(model, content, buffers = []) {
35
+ if (typeof model.receiveCustomMessage === "function") {
36
+ model.receiveCustomMessage(content, buffers);
37
+ } else if (typeof model.trigger === "function") {
38
+ model.trigger("msg:custom", content, buffers);
39
+ }
40
+ }
41
+ function getStaticRegistry() {
42
+ const hosts = globalThis.__myst_anywidget_hosts;
43
+ if (!hosts || typeof hosts.values !== "function") return null;
44
+ for (const v of hosts.values()) return v;
45
+ return null;
46
+ }
47
+ function findAllInRegistry(reg, id) {
48
+ const out = [];
49
+ const seen = /* @__PURE__ */ new Set();
50
+ const push = (m) => {
51
+ if (m && !seen.has(m)) {
52
+ seen.add(m);
53
+ out.push(m);
54
+ }
55
+ };
56
+ if (typeof reg.get === "function") push(reg.get(id));
57
+ const all = typeof reg.filter === "function" ? reg.filter((w) => !!w && w.model_id === id) : typeof reg.all === "function" ? reg.all().filter((w) => !!w && w.model_id === id) : [];
58
+ for (const m of all) push(m);
59
+ return out;
60
+ }
61
+ function makeHandle(getModels) {
62
+ return {
63
+ get models() {
64
+ return getModels();
65
+ },
66
+ get(field) {
67
+ const ms = getModels();
68
+ return ms.length ? ms[0].get(field) : void 0;
69
+ },
70
+ set(field, value) {
71
+ for (const m of getModels()) m.set(field, value);
72
+ },
73
+ setByPath(path, value) {
74
+ for (const m of getModels()) setByPath(m, path, value);
75
+ },
76
+ save() {
77
+ for (const m of getModels()) safeSaveChanges(m);
78
+ },
79
+ on(field, fn) {
80
+ for (const m of getModels()) m.on?.(`change:${field}`, () => fn(m.get(field)));
81
+ },
82
+ sendCustom(content, buffers = []) {
83
+ for (const m of getModels()) deliverCustomMessage(m, content, buffers);
84
+ }
85
+ };
86
+ }
87
+ async function resolveModel(model, ref, { timeout = 5e3 } = {}) {
88
+ const id = stripIpy(ref);
89
+ const reg = getStaticRegistry();
90
+ if (reg) {
91
+ return makeHandle(() => findAllInRegistry(reg, id));
92
+ }
93
+ if (model.widget_manager?.get_model) {
94
+ const resolved = await withTimeout(model.widget_manager.get_model(id), timeout);
95
+ let current = resolved ?? null;
96
+ return makeHandle(() => current ? [current] : []);
97
+ }
98
+ throw new Error(`[manywidgets] cannot resolve model "${id}": no host registry and no widget_manager`);
99
+ }
100
+ function withTimeout(promise, ms) {
101
+ return new Promise((resolve, reject) => {
102
+ const t = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
103
+ promise.then(
104
+ (v) => {
105
+ clearTimeout(t);
106
+ resolve(v);
107
+ },
108
+ (e) => {
109
+ clearTimeout(t);
110
+ reject(e);
111
+ }
112
+ );
113
+ });
114
+ }
115
+
116
+ // src/manywidgets/binder/src/index.ts
117
+ var POLL_MS = 100;
118
+ var MAX_RUN_MS = 30 * 60 * 1e3;
119
+ async function render({ model, el }) {
120
+ const sourceId = model.get("source_widget_id");
121
+ const sourceField = model.get("source_field") || "value";
122
+ const targetId = model.get("target_widget_id");
123
+ const targetField = model.get("target_field");
124
+ const multiplier = model.get("multiplier");
125
+ const offset = model.get("offset");
126
+ const label = model.get("label") || `${sourceId}.${sourceField} \u2192 ${targetId}.${targetField}`;
127
+ el.className = "manywidgets-binder";
128
+ el.style.cssText = "font:12px/1.4 ui-monospace,SFMono-Regular,monospace;color:#24292e;padding:6px 10px;border:1px solid #e1e4e8;border-radius:6px;background:#f6f8fa;max-width:fit-content;margin:6px 0;";
129
+ const status = document.createElement("div");
130
+ status.textContent = `\u{1F517} binder: connecting\u2026 (${label})`;
131
+ el.appendChild(status);
132
+ let source;
133
+ let target;
134
+ try {
135
+ [source, target] = await Promise.all([
136
+ resolveModel(model, sourceId),
137
+ resolveModel(model, targetId)
138
+ ]);
139
+ } catch (err) {
140
+ status.textContent = `\u274C binder failed: ${err.message}`;
141
+ console.error("[manywidgets:binder] resolve failed", err, { sourceId, targetId });
142
+ return;
143
+ }
144
+ let lastKey = null;
145
+ const apply = () => {
146
+ const raw = source.get(sourceField);
147
+ const next = typeof raw === "number" ? asNumber(raw) * multiplier + offset : raw;
148
+ const key = JSON.stringify(next ?? null);
149
+ if (key === lastKey) return;
150
+ lastKey = key;
151
+ target.setByPath(targetField, next);
152
+ target.save();
153
+ };
154
+ source.on(sourceField, apply);
155
+ apply();
156
+ status.textContent = `\u2705 ${label}`;
157
+ const stopAt = Date.now() + MAX_RUN_MS;
158
+ const loop = () => {
159
+ try {
160
+ apply();
161
+ } catch (err) {
162
+ console.warn("[manywidgets:binder] tick error", err);
163
+ }
164
+ if (Date.now() < stopAt) setTimeout(loop, POLL_MS);
165
+ };
166
+ setTimeout(loop, POLL_MS);
167
+ }
168
+ var index_default = { render };
169
+ export {
170
+ index_default as default
171
+ };
@@ -0,0 +1,38 @@
1
+ # Binder
2
+
3
+ Link a source widget's trait into a target's, with an optional linear transform
4
+ (`multiplier`/`offset`) or a nested dotted-path target.
5
+
6
+ ## Import
7
+
8
+ ```python
9
+ from manywidgets import Binder
10
+ ```
11
+
12
+ ## Example
13
+
14
+ Drag the slider — the chart's `width` follows `value × 100`:
15
+
16
+ ```{code-cell} python
17
+ import numpy as np
18
+ from manywidgets import Slider, Chart, Binder
19
+
20
+ x = np.linspace(0, 10, 100)
21
+ chart = Chart(title="sin")
22
+ chart.add_series(x=x, y=np.sin(x), name="sin")
23
+
24
+ width = Slider(label="Width", min=4, max=12, value=8)
25
+ binder = Binder(source=width, source_field="value",
26
+ target=chart, target_field="width", multiplier=100)
27
+
28
+ display(width, chart, binder)
29
+ ```
30
+
31
+ ## API
32
+
33
+ {api-table}
34
+
35
+ `Binder` covers what `jslink`/`jsdlink` can't: a linear transform on the value, or
36
+ writing a nested **dotted-path** target (e.g. `target_field="view_state.zoom"`),
37
+ merged into the parent dict. For plain pass-through links, prefer `jslink` — see the
38
+ [linking guide](../guides/linking.md).
@@ -0,0 +1,81 @@
1
+ import type { RenderProps } from "@anywidget/types";
2
+ import { asNumber, resolveModel, type ModelHandle } from "@manywidgets/core";
3
+
4
+ interface BinderModel {
5
+ source_widget_id: string;
6
+ source_field: string;
7
+ target_widget_id: string;
8
+ target_field: string;
9
+ multiplier: number;
10
+ offset: number;
11
+ label: string;
12
+ }
13
+
14
+ const POLL_MS = 100;
15
+ const MAX_RUN_MS = 30 * 60 * 1000;
16
+
17
+ async function render({ model, el }: RenderProps<BinderModel>): Promise<void> {
18
+ const sourceId = model.get("source_widget_id");
19
+ const sourceField = model.get("source_field") || "value";
20
+ const targetId = model.get("target_widget_id");
21
+ const targetField = model.get("target_field");
22
+ const multiplier = model.get("multiplier");
23
+ const offset = model.get("offset");
24
+ const label =
25
+ model.get("label") ||
26
+ `${sourceId}.${sourceField} → ${targetId}.${targetField}`;
27
+
28
+ el.className = "manywidgets-binder";
29
+ el.style.cssText =
30
+ "font:12px/1.4 ui-monospace,SFMono-Regular,monospace;color:#24292e;" +
31
+ "padding:6px 10px;border:1px solid #e1e4e8;border-radius:6px;" +
32
+ "background:#f6f8fa;max-width:fit-content;margin:6px 0;";
33
+ const status = document.createElement("div");
34
+ status.textContent = `🔗 binder: connecting… (${label})`;
35
+ el.appendChild(status);
36
+
37
+ let source: ModelHandle;
38
+ let target: ModelHandle;
39
+ try {
40
+ [source, target] = await Promise.all([
41
+ resolveModel(model, sourceId),
42
+ resolveModel(model, targetId),
43
+ ]);
44
+ } catch (err) {
45
+ status.textContent = `❌ binder failed: ${(err as Error).message}`;
46
+ console.error("[manywidgets:binder] resolve failed", err, { sourceId, targetId });
47
+ return;
48
+ }
49
+
50
+ let lastKey: string | null = null;
51
+ const apply = (): void => {
52
+ const raw = source.get(sourceField);
53
+ const next = typeof raw === "number" ? asNumber(raw) * multiplier + offset : raw;
54
+ const key = JSON.stringify(next ?? null);
55
+ if (key === lastKey) return;
56
+ lastKey = key;
57
+ target.setByPath(targetField, next);
58
+ target.save();
59
+ };
60
+
61
+ // Live kernel: change events fire on the canonical source model.
62
+ source.on(sourceField, apply);
63
+ // Apply once so the target reflects the current source state immediately.
64
+ apply();
65
+ status.textContent = `✅ ${label}`;
66
+
67
+ // Static export (and a safety net live): proxies may not emit change events
68
+ // and the target proxy can register late, so poll and re-apply on change.
69
+ const stopAt = Date.now() + MAX_RUN_MS;
70
+ const loop = (): void => {
71
+ try {
72
+ apply();
73
+ } catch (err) {
74
+ console.warn("[manywidgets:binder] tick error", err);
75
+ }
76
+ if (Date.now() < stopAt) setTimeout(loop, POLL_MS);
77
+ };
78
+ setTimeout(loop, POLL_MS);
79
+ }
80
+
81
+ export default { render };
@@ -0,0 +1,81 @@
1
+ """Binder — a tiny widget that wires one widget's trait to another's, in JS.
2
+
3
+ The cleaned-up successor to the ``widget_binder`` experiment. ``jslink`` /
4
+ ``jsdlink`` is the canonical way to link manywidgets (see the linking guide);
5
+ ``Binder`` covers the cases jslink can't express:
6
+
7
+ * a linear transform on the value (``multiplier`` / ``offset``), and
8
+ * writing a **nested / dotted-path** target (e.g. lonboard ``view_state.zoom``),
9
+ which is merged into the parent dict so listeners see one coherent update.
10
+
11
+ For ergonomics it accepts widget **instances** and reads their ``widget_id``::
12
+
13
+ Binder(source=slider, source_field="value",
14
+ target=chart, target_field="x_max",
15
+ multiplier=1000)
16
+
17
+ It works in a live kernel and in static export — the JS side resolves both
18
+ widgets through ``@manywidgets/core``'s ``resolveModel`` (which handles the
19
+ multi-proxy / late-registration hazards).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import traitlets
25
+
26
+ from .._base import BaseWidget, asset
27
+
28
+
29
+ def _id_of(widget_or_id):
30
+ """Accept a widget instance (read its ``widget_id``) or a raw id string."""
31
+ if widget_or_id is None:
32
+ return ""
33
+ if isinstance(widget_or_id, str):
34
+ return widget_or_id
35
+ wid = getattr(widget_or_id, "widget_id", None)
36
+ if not wid:
37
+ raise TypeError(
38
+ f"{widget_or_id!r} has no 'widget_id'; pass a manywidgets widget "
39
+ "instance or an explicit id string."
40
+ )
41
+ return wid
42
+
43
+
44
+ class Binder(BaseWidget):
45
+ """Link a source widget's trait to a target widget's trait, in the browser."""
46
+
47
+ _esm = asset(__file__, "dist", "widget.js")
48
+
49
+ source_widget_id = traitlets.Unicode("", help="widget_id of the source widget.").tag(sync=True)
50
+ source_field = traitlets.Unicode("value", help="Source trait to read.").tag(sync=True)
51
+ target_widget_id = traitlets.Unicode("", help="widget_id of the target widget.").tag(sync=True)
52
+ target_field = traitlets.Unicode(
53
+ "", help="Target trait/dotted-path to write (e.g. 'view_state.zoom')."
54
+ ).tag(sync=True)
55
+ multiplier = traitlets.Float(1.0, help="Linear transform multiplier.").tag(sync=True)
56
+ offset = traitlets.Float(0.0, help="Linear transform offset.").tag(sync=True)
57
+ label = traitlets.Unicode("", help="Optional status label.").tag(sync=True)
58
+
59
+ def __init__(
60
+ self,
61
+ source=None,
62
+ target=None,
63
+ *,
64
+ source_field="value",
65
+ target_field=None,
66
+ multiplier=1.0,
67
+ offset=0.0,
68
+ **kwargs,
69
+ ):
70
+ if source is not None and "source_widget_id" not in kwargs:
71
+ kwargs["source_widget_id"] = _id_of(source)
72
+ if target is not None and "target_widget_id" not in kwargs:
73
+ kwargs["target_widget_id"] = _id_of(target)
74
+ kwargs.setdefault("source_field", source_field)
75
+ if target_field is not None:
76
+ kwargs.setdefault("target_field", target_field)
77
+ kwargs.setdefault("multiplier", multiplier)
78
+ kwargs.setdefault("offset", offset)
79
+ super().__init__(**kwargs)
80
+ if not self.target_field:
81
+ raise ValueError("Binder requires a 'target_field'.")
@@ -0,0 +1,3 @@
1
+ from .widget import Button
2
+
3
+ __all__ = ["Button"]
@@ -0,0 +1,31 @@
1
+ // packages/core/src/index.ts
2
+ function safeSaveChanges(model) {
3
+ try {
4
+ model?.save_changes?.();
5
+ } catch {
6
+ }
7
+ }
8
+ function asNumber(value, fallback = 0) {
9
+ const n = typeof value === "number" ? value : Number(value);
10
+ return Number.isFinite(n) ? n : fallback;
11
+ }
12
+
13
+ // src/manywidgets/button/src/index.ts
14
+ function render({ model, el }) {
15
+ const button = document.createElement("button");
16
+ button.className = "manywidgets-button";
17
+ button.type = "button";
18
+ button.textContent = model.get("label");
19
+ el.appendChild(button);
20
+ button.addEventListener("click", () => {
21
+ model.set("clicks", asNumber(model.get("clicks")) + 1);
22
+ safeSaveChanges(model);
23
+ });
24
+ model.on("change:label", () => {
25
+ button.textContent = model.get("label");
26
+ });
27
+ }
28
+ var index_default = { render };
29
+ export {
30
+ index_default as default
31
+ };
@@ -0,0 +1,46 @@
1
+ # Button
2
+
3
+ A click button with a `clicks` counter and Python `on_click` callbacks.
4
+
5
+ ## Import
6
+
7
+ ```python
8
+ from manywidgets import Button
9
+ ```
10
+
11
+ ## Example
12
+
13
+ ```{code-cell} python
14
+ from manywidgets import Button
15
+
16
+ btn = Button(label="Run")
17
+ btn
18
+ ```
19
+
20
+ ## API
21
+
22
+ {api-table}
23
+
24
+ `on_click(callback, remove=False)` registers/removes a `callback(button)` fired
25
+ when `clicks` increases (live kernel only).
26
+
27
+ ```python
28
+ btn = Button(label="Run")
29
+
30
+ @btn.on_click
31
+ def _(b):
32
+ print("clicked", b.clicks, "times")
33
+ ```
34
+
35
+ ## Linking
36
+
37
+ Link `clicks` like any trait (see the [linking guide](../guides/linking.md)):
38
+
39
+ ```python
40
+ from ipywidgets import jsdlink
41
+ from manywidgets import Button, NumberDisplay
42
+
43
+ btn = Button(label="+1")
44
+ count = NumberDisplay(label="Clicks", duration=0)
45
+ jsdlink((btn, "clicks"), (count, "value"))
46
+ ```