diff --git a/docs/llms.txt b/docs/llms.txt
index cd504197..3901abfe 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -8,6 +8,8 @@ Install via `pip install wigglystuff` or `uv pip install wigglystuff`.
- [AnnotationWidget](https://koaning.github.io/wigglystuff/reference/annotation.md): Annotation input surface with buttons, keyboard shortcuts, gamepad, and speech-to-text
- [Slider2D](https://koaning.github.io/wigglystuff/reference/slider2d.md): 2D slider for x/y value selection
+- [Knob](https://koaning.github.io/wigglystuff/reference/knob.md): Audio-panel rotary knob with a configurable sweep, tick scale, discrete detents, and optional Web MIDI learn (bindings persist in browser localStorage)
+- [Fader](https://koaning.github.io/wigglystuff/reference/fader.md): Mixing-console fader (vertical or horizontal) with a configurable tick scale, discrete detents, and optional Web MIDI learn
- [BezierCurve](https://koaning.github.io/wigglystuff/reference/bezier-curve.md): Interactive arbitrary-degree Bezier curve editor with draggable control points
- [CurveEditor](https://koaning.github.io/wigglystuff/reference/curve-editor.md): Chart-space curve editor with draggable knots, D3 line interpolators, closed paths, and progress output
- [ChartPuck](https://koaning.github.io/wigglystuff/reference/chart-puck.md): Draggable puck overlay for matplotlib charts
diff --git a/docs/reference/fader.md b/docs/reference/fader.md
new file mode 100644
index 00000000..b7a8b236
--- /dev/null
+++ b/docs/reference/fader.md
@@ -0,0 +1,59 @@
+---
+title: "Fader: mixing-console fader widget"
+description: Fader is a channel-strip fader with a configurable tick scale, discrete detents, vertical or horizontal orientation, and optional Web MIDI learn, in marimo or Jupyter.
+image: fader
+image_alt: Vertical and horizontal mixing-console faders with dB-style tick scales beside the tracks
+---
+
+# Fader API
+
+
+
+
+
+
+
+`Fader` is a linear slider drawn to look like a channel fader on a mixing
+console: a cap slides along a slot, with a configurable tick scale (e.g. dB
+marks) alongside the track. It is vertical by default with `max_value` at the
+top; pass `orientation="horizontal"` for a left-to-right fader.
+
+Ticks are configurable — `ticks=N` for evenly spaced marks, a list of values, or
+`(value, label)` pairs. Pass `steps` instead for a **stepped fader** that snaps
+to discrete detents. With `midi=True` the fader shows a "MIDI learn" button:
+click it, move a control on your hardware, and the next control-change message
+binds to the fader (Web MIDI, Chromium browsers). The binding is remembered in
+browser localStorage so it survives a restart.
+
+See also: [Knob](knob.md) for the rotary console version, and
+[HoverSlider](hover-slider.md) for a linear slider that also reports the value
+under the pointer.
+
+::: wigglystuff.fader.Fader
+
+## Synced traitlets
+
+| Traitlet | Type | Notes |
+| --- | --- | --- |
+| `value` | `float` | Current value, mapped along the track. |
+| `min_value` | `float` | Lower bound (bottom / left). |
+| `max_value` | `float` | Upper bound (top / right). |
+| `step` | `float` | Snap increment in value units (continuous mode). |
+| `ticks` | `list[dict]` | Normalized `{"value", "label"}` tick marks. |
+| `steps` | `list[float]` | Discrete detents to snap to; empty means continuous. |
+| `orientation` | `str` | `"vertical"` (default) or `"horizontal"`. |
+| `length` | `int` | Track length in pixels (the long dimension). |
+| `label` | `str` | Optional text label shown above the fader. |
+| `show_value` | `bool` | Render the current value as text next to the fader. |
+| `color` | `str` | CSS color for the filled track and cap. Empty follows the theme. |
+| `midi` | `bool` | Show the MIDI-learn button and listen for control-change. |
+| `midi_cc` | `int` | Bound control-change number (0-127), or `-1` when unbound. |
+| `midi_channel` | `int` | Bound MIDI channel (0-15), or `-1` for any. |
+| `midi_device` | `str` | Name of the bound MIDI input device. |
+| `midi_supported` | `bool` | Whether the browser exposes Web MIDI (set from JS). |
+| `midi_learning` | `bool` | Whether the fader is currently in learn mode. |
+| `midi_key` | `str` | localStorage key for the persisted binding (defaults to `label`). |
+| `midi_scope` | `str` | Namespace for the binding; empty uses the browser URL path. |
diff --git a/docs/reference/index.md b/docs/reference/index.md
index 7f77c274..3e516827 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -16,6 +16,8 @@ the link at the top of any page. [`llms.txt`](../llms.txt) lists them all.
## Sliders and scalar inputs
- [Slider2D](slider2d.md) — 2D pointer for two coupled parameters
+- [Knob](knob.md) — audio-panel rotary knob with configurable sweep, detents and MIDI learn
+- [Fader](fader.md) — mixing-console fader with a configurable tick scale and MIDI learn
- [CircularSlider](circular-slider.md) — circular dial for a single value or a span
- [HoverSlider](hover-slider.md) — reports the committed value and the live hover value
- [PlaySlider](play-slider.md) — slider with a play/pause button that auto-advances
diff --git a/docs/reference/knob.md b/docs/reference/knob.md
new file mode 100644
index 00000000..b51b8436
--- /dev/null
+++ b/docs/reference/knob.md
@@ -0,0 +1,61 @@
+---
+title: "Knob: audio-panel rotary knob widget"
+description: Knob is a synth/mixer-style rotary control with a configurable sweep, tick scale, discrete detents, and optional Web MIDI learn, in marimo or Jupyter.
+image: knob
+image_alt: A row of rotary knobs with pointers and tick marks, including a partial-arc gain knob and a full-circle compass knob
+---
+
+# Knob API
+
+
+
+
+
+
+
+`Knob` is a rotary control that looks like a knob on a synth or mixing console.
+Unlike [CircularSlider](circular-slider.md) (a full 360° ring), it sweeps a
+partial arc with a gap at the bottom by default, with a pointer showing the
+current position. Angles are measured in degrees clockwise from 12 o'clock, so
+the default `start_angle=-135` / `end_angle=135` gives the classic 270° sweep;
+pass a full `start_angle=0, end_angle=360` for a gapless dial that wraps.
+
+Ticks are configurable — `ticks=N` for evenly spaced marks, a list of values, or
+`(value, label)` pairs. Pass `steps` instead for a **rotary selector** that snaps
+to discrete detents (numbers or named positions). With `midi=True` the knob shows
+a "MIDI learn" button: click it, move a control on your hardware, and the next
+control-change message binds to the knob (Web MIDI, Chromium browsers). The
+binding is remembered in browser localStorage so it survives a restart.
+
+See also: [Fader](fader.md) for the linear console version, and
+[CircularSlider](circular-slider.md) for a full-ring dial.
+
+::: wigglystuff.knob.Knob
+
+## Synced traitlets
+
+| Traitlet | Type | Notes |
+| --- | --- | --- |
+| `value` | `float` | Current value, mapped across the arc. |
+| `min_value` | `float` | Lower bound (at `start_angle`). |
+| `max_value` | `float` | Upper bound (at `end_angle`). |
+| `step` | `float` | Snap increment in value units (continuous mode). |
+| `start_angle` | `float` | Angle of `min_value`, degrees clockwise from 12 o'clock. |
+| `end_angle` | `float` | Angle of `max_value`; a 360° span makes a full circle. |
+| `ticks` | `list[dict]` | Normalized `{"value", "label"}` tick marks. |
+| `steps` | `list[float]` | Discrete detents to snap to; empty means continuous. |
+| `size` | `int` | Diameter in pixels. |
+| `label` | `str` | Optional text label shown above the knob. |
+| `show_value` | `bool` | Render the current value as text below the knob. |
+| `color` | `str` | CSS color for the value arc and pointer. Empty follows the theme. |
+| `midi` | `bool` | Show the MIDI-learn button and listen for control-change. |
+| `midi_cc` | `int` | Bound control-change number (0-127), or `-1` when unbound. |
+| `midi_channel` | `int` | Bound MIDI channel (0-15), or `-1` for any. |
+| `midi_device` | `str` | Name of the bound MIDI input device. |
+| `midi_supported` | `bool` | Whether the browser exposes Web MIDI (set from JS). |
+| `midi_learning` | `bool` | Whether the knob is currently in learn mode. |
+| `midi_key` | `str` | localStorage key for the persisted binding (defaults to `label`). |
+| `midi_scope` | `str` | Namespace for the binding; empty uses the browser URL path. |
diff --git a/pyproject.toml b/pyproject.toml
index 5fdb3055..0622bd59 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "wigglystuff"
-version = "0.5.24"
+version = "0.5.25"
description = "Collection of Anywidget Widgets"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/uv.lock b/uv.lock
index a2937b2e..c43848d2 100644
--- a/uv.lock
+++ b/uv.lock
@@ -40,16 +40,16 @@ wheels = [
[[package]]
name = "anywidget"
-version = "0.9.21"
+version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ipywidgets" },
{ name = "psygnal" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/be/5e/cbea445bf062b81e4d366ca29dae4f0aedc7a64f384afc24670e07bec560/anywidget-0.9.21.tar.gz", hash = "sha256:b8d0172029ac426573053c416c6a587838661612208bb390fa0607862e594b27", size = 390517, upload-time = "2025-11-12T17:06:03.035Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/31/0491d707c674b34267f55d96d6a7148e55e7b6718a271686232cf295fbe2/anywidget-0.11.0.tar.gz", hash = "sha256:6695fbef9449cf8c27f421b96c5837aa37f909ec1f60cfa33add333e1b70b169", size = 426999, upload-time = "2026-04-27T23:42:09.576Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5b/03/c17464bbf682ea87e7e3de2ddc63395e359a78ae9c01f55fc78759ecbd79/anywidget-0.9.21-py3-none-any.whl", hash = "sha256:78c268e0fbdb1dfd15da37fb578f9cf0a0df58a430e68d9156942b7a9391a761", size = 231797, upload-time = "2025-11-12T17:06:01.564Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/c2/8fec8e8e2eb920cc2280f569144080cd58622a2eda83bfa4c0c354a63264/anywidget-0.11.0-py3-none-any.whl", hash = "sha256:c574d9acc6503ad27b37a9acea48f957a8ba7c9c9876cfcb37898931c098ce9d", size = 317341, upload-time = "2026-04-27T23:42:08.356Z" },
]
[[package]]
@@ -2979,7 +2979,7 @@ wheels = [
[[package]]
name = "wigglystuff"
-version = "0.5.24"
+version = "0.5.25"
source = { editable = "." }
dependencies = [
{ name = "anywidget" },
diff --git a/wigglystuff/__init__.py b/wigglystuff/__init__.py
index 8277b71c..1a49b1aa 100644
--- a/wigglystuff/__init__.py
+++ b/wigglystuff/__init__.py
@@ -23,6 +23,7 @@
from .env_config import EnvConfig
from .esm_widget import EsmWidget
from .excalidraw import Excalidraw
+from .fader import Fader
from .frame_player import FramePlayer
from .gamepad import GamepadWidget
from .graph_widget import GraphWidget
@@ -33,6 +34,7 @@
from .hover_zoom import HoverZoom
from .html import HTMLRefreshWidget, ImageRefreshWidget, ProgressBar
from .keystroke import KeystrokeWidget
+from .knob import Knob
from .tangle_latex import TangleLatex
from .live_edit import LiveEdit, inspect_run
from .manim_web import ManimWeb
@@ -86,6 +88,7 @@
"EnvConfig",
"EsmWidget",
"Excalidraw",
+ "Fader",
"FramePlayer",
"GamepadWidget",
"GraphWidget",
@@ -93,6 +96,7 @@
"HeatmapSelect",
"Hint",
"KeystrokeWidget",
+ "Knob",
"TangleLatex",
"LiveEdit",
"ManimWeb",
diff --git a/wigglystuff/_controls.py b/wigglystuff/_controls.py
new file mode 100644
index 00000000..fcedcbdc
--- /dev/null
+++ b/wigglystuff/_controls.py
@@ -0,0 +1,103 @@
+"""Shared helpers for the audio-console control widgets (Knob, Fader).
+
+Both widgets map a numeric value onto a track and let you draw an optional set
+of ticks/axis labels. The tick spec is normalized here once, in Python, so the
+frontend JS only ever has to draw a plain list of ``{"value", "label"}`` dicts.
+"""
+
+from typing import Any, Dict, List, Sequence, Union
+
+# What a caller may pass for ``ticks``:
+# None / 0 / [] -> no ticks
+# an int N -> N evenly spaced ticks between min and max
+# [v1, v2, ...] -> ticks at those values (labels are the values)
+# [(v1, "a"), (v2, "b")] -> explicit value + label pairs
+TickSpec = Union[None, int, Sequence[Union[float, Sequence[Any]]]]
+
+
+def clamp(value: float, low: float, high: float) -> float:
+ """Clamp ``value`` into ``[low, high]``."""
+ return max(low, min(high, value))
+
+
+def _format(value: float) -> str:
+ """Render a tick value without trailing ``.0`` noise (12.0 -> "12")."""
+ if value == int(value):
+ return str(int(value))
+ return f"{value:g}"
+
+
+def normalize_ticks(
+ ticks: TickSpec, min_value: float, max_value: float
+) -> List[Dict[str, Any]]:
+ """Turn a flexible ``ticks`` spec into a list of ``{"value", "label"}`` dicts.
+
+ Args:
+ ticks: See :data:`TickSpec` for the accepted forms.
+ min_value: Lower bound of the value range (for the ``int`` count form).
+ max_value: Upper bound of the value range (for the ``int`` count form).
+
+ Returns:
+ A list of ``{"value": float, "label": str}`` dicts, sorted by value.
+ An empty list means "no ticks".
+ """
+ if ticks is None:
+ return []
+
+ # An int N (but not a bool) means "N evenly spaced ticks".
+ if isinstance(ticks, int) and not isinstance(ticks, bool):
+ if ticks <= 0:
+ return []
+ if ticks == 1:
+ values = [min_value]
+ else:
+ span = max_value - min_value
+ values = [min_value + span * i / (ticks - 1) for i in range(ticks)]
+ return [{"value": float(v), "label": _format(v)} for v in values]
+
+ out: List[Dict[str, Any]] = []
+ for item in ticks:
+ if isinstance(item, (int, float)) and not isinstance(item, bool):
+ value = float(item)
+ label = _format(value)
+ else:
+ # A (value, label) pair.
+ try:
+ raw_value, raw_label = item
+ except (TypeError, ValueError):
+ raise ValueError(
+ "Each tick must be a number or a (value, label) pair, "
+ f"got {item!r}."
+ )
+ value = float(raw_value)
+ label = str(raw_label)
+ out.append({"value": value, "label": label})
+
+ out.sort(key=lambda t: t["value"])
+ return out
+
+
+def normalize_steps(steps: Sequence[Any]) -> List[float]:
+ """Extract a sorted list of numeric stop values from a ``steps`` spec.
+
+ Accepts the same shape as ticks — bare numbers or ``(value, label)`` pairs —
+ but returns just the numeric positions the control should snap to. Requires
+ at least two entries. Use together with :func:`normalize_ticks` (called on
+ the same spec) to also get the labels for drawing.
+ """
+ values: List[float] = []
+ for item in steps:
+ if isinstance(item, (int, float)) and not isinstance(item, bool):
+ values.append(float(item))
+ else:
+ try:
+ raw_value, _label = item
+ except (TypeError, ValueError):
+ raise ValueError(
+ "Each step must be a number or a (value, label) pair, "
+ f"got {item!r}."
+ )
+ values.append(float(raw_value))
+ if len(values) < 2:
+ raise ValueError("Must pass at least two steps.")
+ return sorted(values)
diff --git a/wigglystuff/fader.py b/wigglystuff/fader.py
new file mode 100644
index 00000000..423bca67
--- /dev/null
+++ b/wigglystuff/fader.py
@@ -0,0 +1,169 @@
+from pathlib import Path
+from typing import Any, Optional, Sequence
+
+import anywidget
+import traitlets
+
+from ._controls import TickSpec, clamp, normalize_steps, normalize_ticks
+
+
+_ESM_PATH = Path(__file__).parent / "static" / "fader.js"
+_CSS_PATH = Path(__file__).parent / "static" / "fader.css"
+
+_ORIENTATIONS = ("vertical", "horizontal")
+
+
+class Fader(anywidget.AnyWidget):
+ """Mixing-console style fader: a cap that slides along a track.
+
+ A linear slider drawn to look like a channel fader, with a configurable
+ tick scale (e.g. dB marks) alongside the track. Vertical by default, with
+ ``max_value`` at the top; pass ``orientation="horizontal"`` for a
+ left-to-right fader.
+
+ Examples:
+ ```python
+ import marimo as mo
+ from wigglystuff import Fader
+
+ level = mo.ui.anywidget(
+ Fader(min_value=-60, max_value=6, value=0, ticks=[-60, -20, -6, 0, 6],
+ label="Level")
+ )
+ level
+ ```
+ """
+
+ _esm = _ESM_PATH
+ _css = _CSS_PATH
+
+ value = traitlets.Float(0.0).tag(sync=True)
+ min_value = traitlets.Float(0.0).tag(sync=True)
+ max_value = traitlets.Float(100.0).tag(sync=True)
+ step = traitlets.Float(1.0).tag(sync=True)
+ ticks = traitlets.List(traitlets.Dict()).tag(sync=True)
+ # Discrete detents to snap to; empty means continuous (use ``step``).
+ steps = traitlets.List(traitlets.Float(), default_value=[]).tag(sync=True)
+ orientation = traitlets.Unicode("vertical").tag(sync=True)
+ length = traitlets.Int(200).tag(sync=True)
+ label = traitlets.Unicode("").tag(sync=True)
+ show_value = traitlets.Bool(True).tag(sync=True)
+ color = traitlets.Unicode("").tag(sync=True)
+
+ # MIDI: an Ableton-style "learn" binding to a hardware control-change (CC).
+ midi = traitlets.Bool(False).tag(sync=True)
+ midi_supported = traitlets.Bool(False).tag(sync=True)
+ midi_learning = traitlets.Bool(False).tag(sync=True)
+ midi_cc = traitlets.Int(-1).tag(sync=True)
+ midi_channel = traitlets.Int(-1).tag(sync=True)
+ midi_device = traitlets.Unicode("").tag(sync=True)
+ # Persistence: bindings are stored in browser localStorage under
+ # ``wigglystuff-midi/{midi_scope}/{midi_key}``.
+ midi_key = traitlets.Unicode("").tag(sync=True)
+ midi_scope = traitlets.Unicode("").tag(sync=True)
+
+ def __init__(
+ self,
+ value: Optional[float] = None,
+ min_value: float = 0.0,
+ max_value: float = 100.0,
+ step: float = 1.0,
+ ticks: TickSpec = None,
+ steps: Optional[Sequence[Any]] = None,
+ orientation: str = "vertical",
+ length: int = 200,
+ label: str = "",
+ show_value: bool = True,
+ color: str = "",
+ midi: bool = False,
+ midi_cc: int = -1,
+ midi_channel: int = -1,
+ midi_key: str = "",
+ midi_scope: Optional[str] = None,
+ **kwargs: Any,
+ ) -> None:
+ """Create a Fader.
+
+ Args:
+ value: Initial value; defaults to ``min_value``. Clamped to range.
+ min_value: Lower bound of the value range (bottom / left).
+ max_value: Upper bound of the value range (top / right).
+ step: Snap increment in value units (must be > 0).
+ ticks: Tick/scale marks. ``None`` for none, an int ``N`` for ``N``
+ evenly spaced ticks, a list of values, or a list of
+ ``(value, label)`` pairs.
+ steps: Discrete detents to snap to (a stepped fader). Same shape as
+ ``ticks`` — numbers or ``(value, label)`` pairs. When given,
+ ``min_value``/``max_value`` are derived from the steps, the
+ detents double as the ticks, and dragging snaps to the nearest
+ one. Mutually exclusive with ``ticks``.
+ orientation: ``"vertical"`` (default) or ``"horizontal"``.
+ length: Track length in pixels (the long dimension).
+ label: Optional text label shown above the fader.
+ show_value: Render the current value as text next to the fader.
+ color: Optional CSS color for the filled track and cap. Empty
+ string uses the theme default.
+ midi: Show a "MIDI learn" button. Click it, then move a control on
+ your hardware; the next control-change (CC) message binds to
+ this fader and drives its value. Uses the Web MIDI API (Chromium
+ browsers, secure context). Read the binding back via
+ ``midi_cc`` / ``midi_channel`` / ``midi_device``.
+ midi_cc: Bind a control-change number (0-127) up front instead of
+ learning it. ``-1`` (default) leaves it unbound.
+ midi_channel: MIDI channel (0-15) for the binding, or ``-1`` for any.
+ midi_key: localStorage key for persisting the learned binding across
+ restarts. Defaults to ``label``. Empty (no label either) disables
+ persistence.
+ midi_scope: Namespace for the persisted binding, so different
+ notebooks don't collide. Empty (default) uses the browser's URL
+ path automatically; pass an explicit string to pin it (or to
+ intentionally share a mapping across notebooks).
+ **kwargs: Forwarded to ``anywidget.AnyWidget``.
+ """
+ if midi_scope is None:
+ midi_scope = ""
+ if step <= 0:
+ raise ValueError("step must be positive.")
+ if orientation not in _ORIENTATIONS:
+ raise ValueError(
+ f"orientation must be one of {_ORIENTATIONS}, got {orientation!r}."
+ )
+
+ if steps is not None:
+ if ticks is not None:
+ raise ValueError("`ticks` is mutually exclusive with `steps`.")
+ step_values = normalize_steps(steps)
+ min_value, max_value = step_values[0], step_values[-1]
+ tick_dicts = normalize_ticks(steps, min_value, max_value)
+ if value is None:
+ value = step_values[0]
+ else:
+ value = min(step_values, key=lambda s: abs(s - float(value)))
+ else:
+ step_values = []
+ if min_value >= max_value:
+ raise ValueError("min_value must be less than max_value.")
+ tick_dicts = normalize_ticks(ticks, min_value, max_value)
+ if value is None:
+ value = min_value
+ value = clamp(float(value), min_value, max_value)
+
+ super().__init__(
+ value=float(value),
+ min_value=float(min_value),
+ max_value=float(max_value),
+ step=float(step),
+ ticks=tick_dicts,
+ steps=step_values,
+ orientation=orientation,
+ length=length,
+ label=label,
+ show_value=show_value,
+ color=color,
+ midi=midi,
+ midi_cc=midi_cc,
+ midi_channel=midi_channel,
+ midi_key=midi_key,
+ midi_scope=midi_scope,
+ **kwargs,
+ )
diff --git a/wigglystuff/knob.py b/wigglystuff/knob.py
new file mode 100644
index 00000000..07e1987f
--- /dev/null
+++ b/wigglystuff/knob.py
@@ -0,0 +1,181 @@
+from pathlib import Path
+from typing import Any, Optional, Sequence
+
+import anywidget
+import traitlets
+
+from ._controls import TickSpec, clamp, normalize_steps, normalize_ticks
+
+
+_ESM_PATH = Path(__file__).parent / "static" / "knob.js"
+_CSS_PATH = Path(__file__).parent / "static" / "knob.css"
+
+
+class Knob(anywidget.AnyWidget):
+ """Audio-panel style rotary knob for selecting a single value.
+
+ Unlike :class:`CircularSlider` (a full 360° ring), the knob sweeps a partial
+ arc with a gap at the bottom, like a synth or mixer knob. A pointer line
+ shows the current position and you can drag it round. Angles are measured in
+ degrees clockwise from 12 o'clock, so the default ``start_angle=-135`` /
+ ``end_angle=135`` gives the classic 270° sweep.
+
+ The value range increases clockwise from ``start_angle`` (mapped to
+ ``min_value``) to ``end_angle`` (mapped to ``max_value``). Pass a full 360°
+ sweep (e.g. ``start_angle=0, end_angle=360``) for a gapless full-circle
+ knob that wraps at the seam.
+
+ Examples:
+ ```python
+ import marimo as mo
+ from wigglystuff import Knob
+
+ gain = mo.ui.anywidget(
+ Knob(min_value=0, max_value=11, value=5, ticks=12, label="Gain")
+ )
+ gain
+ ```
+ """
+
+ _esm = _ESM_PATH
+ _css = _CSS_PATH
+
+ value = traitlets.Float(0.0).tag(sync=True)
+ min_value = traitlets.Float(0.0).tag(sync=True)
+ max_value = traitlets.Float(100.0).tag(sync=True)
+ step = traitlets.Float(1.0).tag(sync=True)
+ start_angle = traitlets.Float(-135.0).tag(sync=True)
+ end_angle = traitlets.Float(135.0).tag(sync=True)
+ ticks = traitlets.List(traitlets.Dict()).tag(sync=True)
+ # Discrete detents to snap to; empty means continuous (use ``step``).
+ steps = traitlets.List(traitlets.Float(), default_value=[]).tag(sync=True)
+ size = traitlets.Int(80).tag(sync=True)
+ label = traitlets.Unicode("").tag(sync=True)
+ show_value = traitlets.Bool(True).tag(sync=True)
+ color = traitlets.Unicode("").tag(sync=True)
+
+ # MIDI: an Ableton-style "learn" binding to a hardware control-change (CC).
+ midi = traitlets.Bool(False).tag(sync=True)
+ midi_supported = traitlets.Bool(False).tag(sync=True)
+ midi_learning = traitlets.Bool(False).tag(sync=True)
+ midi_cc = traitlets.Int(-1).tag(sync=True)
+ midi_channel = traitlets.Int(-1).tag(sync=True)
+ midi_device = traitlets.Unicode("").tag(sync=True)
+ # Persistence: bindings are stored in browser localStorage under
+ # ``wigglystuff-midi/{midi_scope}/{midi_key}``.
+ midi_key = traitlets.Unicode("").tag(sync=True)
+ midi_scope = traitlets.Unicode("").tag(sync=True)
+
+ def __init__(
+ self,
+ value: Optional[float] = None,
+ min_value: float = 0.0,
+ max_value: float = 100.0,
+ step: float = 1.0,
+ start_angle: float = -135.0,
+ end_angle: float = 135.0,
+ ticks: TickSpec = None,
+ steps: Optional[Sequence[Any]] = None,
+ size: int = 80,
+ label: str = "",
+ show_value: bool = True,
+ color: str = "",
+ midi: bool = False,
+ midi_cc: int = -1,
+ midi_channel: int = -1,
+ midi_key: str = "",
+ midi_scope: Optional[str] = None,
+ **kwargs: Any,
+ ) -> None:
+ """Create a Knob.
+
+ Args:
+ value: Initial value; defaults to ``min_value``. Clamped to range.
+ min_value: Lower bound of the value range (at ``start_angle``).
+ max_value: Upper bound of the value range (at ``end_angle``).
+ step: Snap increment in value units (must be > 0).
+ start_angle: Angle of ``min_value``, in degrees clockwise from 12
+ o'clock. Default ``-135`` (lower-left).
+ end_angle: Angle of ``max_value``, in degrees clockwise from 12
+ o'clock. Default ``135`` (lower-right). Together with the
+ default ``start_angle`` this is a 270° sweep.
+ ticks: Tick/axis marks. ``None`` for none, an int ``N`` for ``N``
+ evenly spaced ticks, a list of values, or a list of
+ ``(value, label)`` pairs.
+ steps: Discrete detents to snap to (a rotary selector). Same shape
+ as ``ticks`` — numbers or ``(value, label)`` pairs. When given,
+ ``min_value``/``max_value`` are derived from the steps, the
+ detents double as the ticks, and dragging snaps to the nearest
+ one. Mutually exclusive with ``ticks``.
+ size: Diameter in pixels.
+ label: Optional text label shown above the knob.
+ show_value: Render the current value as text below the knob.
+ color: Optional CSS color for the value arc and pointer. Empty
+ string uses the theme default.
+ midi: Show a "MIDI learn" button. Click it, then move a control on
+ your hardware; the next control-change (CC) message binds to
+ this knob and drives its value. Uses the Web MIDI API (Chromium
+ browsers, secure context). Read the binding back via
+ ``midi_cc`` / ``midi_channel`` / ``midi_device``.
+ midi_cc: Bind a control-change number (0-127) up front instead of
+ learning it. ``-1`` (default) leaves it unbound.
+ midi_channel: MIDI channel (0-15) for the binding, or ``-1`` for any.
+ midi_key: localStorage key for persisting the learned binding across
+ restarts. Defaults to ``label``. Empty (no label either) disables
+ persistence.
+ midi_scope: Namespace for the persisted binding, so different
+ notebooks don't collide. Empty (default) uses the browser's URL
+ path automatically; pass an explicit string to pin it (or to
+ intentionally share a mapping across notebooks).
+ **kwargs: Forwarded to ``anywidget.AnyWidget``.
+ """
+ if midi_scope is None:
+ midi_scope = ""
+ if step <= 0:
+ raise ValueError("step must be positive.")
+ if start_angle == end_angle:
+ raise ValueError("start_angle and end_angle must differ.")
+ if abs(end_angle - start_angle) > 360:
+ raise ValueError(
+ "the sweep (end_angle - start_angle) cannot exceed 360 degrees."
+ )
+
+ if steps is not None:
+ if ticks is not None:
+ raise ValueError("`ticks` is mutually exclusive with `steps`.")
+ step_values = normalize_steps(steps)
+ min_value, max_value = step_values[0], step_values[-1]
+ tick_dicts = normalize_ticks(steps, min_value, max_value)
+ if value is None:
+ value = step_values[0]
+ else:
+ value = min(step_values, key=lambda s: abs(s - float(value)))
+ else:
+ step_values = []
+ if min_value >= max_value:
+ raise ValueError("min_value must be less than max_value.")
+ tick_dicts = normalize_ticks(ticks, min_value, max_value)
+ if value is None:
+ value = min_value
+ value = clamp(float(value), min_value, max_value)
+
+ super().__init__(
+ value=float(value),
+ min_value=float(min_value),
+ max_value=float(max_value),
+ step=float(step),
+ start_angle=float(start_angle),
+ end_angle=float(end_angle),
+ ticks=tick_dicts,
+ steps=step_values,
+ size=size,
+ label=label,
+ show_value=show_value,
+ color=color,
+ midi=midi,
+ midi_cc=midi_cc,
+ midi_channel=midi_channel,
+ midi_key=midi_key,
+ midi_scope=midi_scope,
+ **kwargs,
+ )
diff --git a/wigglystuff/static/fader.css b/wigglystuff/static/fader.css
new file mode 100644
index 00000000..2292f1e0
--- /dev/null
+++ b/wigglystuff/static/fader.css
@@ -0,0 +1,145 @@
+.fader-wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+ color-scheme: light dark;
+ padding: 4px;
+
+ --fader-track: #d1d5db;
+ --fader-fill: #3b82f6;
+ --fader-cap: #f9fafb;
+ --fader-cap-border: #9ca3af;
+ --fader-groove: #9ca3af;
+ --fader-tick: #9ca3af;
+ --fader-text: #111827;
+}
+
+.dark .fader-wrapper,
+.dark-theme .fader-wrapper,
+[data-theme="dark"] .fader-wrapper {
+ --fader-track: #374151;
+ --fader-fill: #60a5fa;
+ --fader-cap: #1f2937;
+ --fader-cap-border: #4b5563;
+ --fader-groove: #6b7280;
+ --fader-tick: #6b7280;
+ --fader-text: #f1f5f9;
+}
+
+.fader-title {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--fader-text);
+ opacity: 0.85;
+ margin-bottom: 3px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.fader-svg {
+ display: block;
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+}
+
+.fader-svg:active {
+ cursor: grabbing;
+}
+
+.fader-track {
+ stroke: var(--fader-track);
+ stroke-width: 6;
+ stroke-linecap: round;
+}
+
+.fader-fill {
+ stroke: var(--fader-fill);
+ stroke-width: 6;
+ stroke-linecap: round;
+}
+
+.fader-cap {
+ fill: var(--fader-cap);
+ stroke: var(--fader-cap-border);
+ stroke-width: 1.5;
+}
+
+.fader-groove {
+ stroke: var(--fader-groove);
+ stroke-width: 2;
+ stroke-linecap: round;
+}
+
+.fader-tick {
+ stroke: var(--fader-tick);
+ stroke-width: 1.5;
+}
+
+.fader-tick-label {
+ fill: var(--fader-text);
+ opacity: 0.75;
+ font-size: 9px;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+}
+
+.fader-value {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--fader-text);
+ font-variant-numeric: tabular-nums;
+ margin-top: 4px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.fader-midi-btn {
+ margin-top: 6px;
+ padding: 2px 8px;
+ font-size: 10px;
+ font-weight: 600;
+ font-family: inherit;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: var(--fader-text);
+ background: transparent;
+ border: 1px solid var(--fader-track);
+ border-radius: 5px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+}
+
+.fader-midi-btn:hover:not(:disabled) {
+ border-color: var(--fader-fill);
+}
+
+.fader-midi-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.fader-midi-btn.bound {
+ color: #fff;
+ background: var(--fader-fill);
+ border-color: var(--fader-fill);
+}
+
+.fader-midi-btn.learning {
+ color: #fff;
+ background: #ef4444;
+ border-color: #ef4444;
+ animation: fader-midi-pulse 1s ease-in-out infinite;
+}
+
+@keyframes fader-midi-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.55;
+ }
+}
diff --git a/wigglystuff/static/fader.js b/wigglystuff/static/fader.js
new file mode 100644
index 00000000..eee8d8da
--- /dev/null
+++ b/wigglystuff/static/fader.js
@@ -0,0 +1,552 @@
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+// Shared across all faders on the page so we only request access once.
+let _midiAccessPromise = null;
+function getMidiAccess() {
+ if (!navigator.requestMIDIAccess) return Promise.resolve(null);
+ if (!_midiAccessPromise) _midiAccessPromise = navigator.requestMIDIAccess();
+ return _midiAccessPromise;
+}
+
+function getPrecision(step) {
+ const s = String(step);
+ const dot = s.indexOf(".");
+ return dot === -1 ? 0 : s.length - dot - 1;
+}
+
+function snap(value, min, max, step) {
+ const clamped = Math.max(min, Math.min(max, value));
+ if (!step) return clamped;
+ const snapped = min + Math.round((clamped - min) / step) * step;
+ return Math.max(min, Math.min(max, snapped));
+}
+
+function formatValue(value, step) {
+ return value.toFixed(getPrecision(step));
+}
+
+function nearestStep(value, steps) {
+ let best = steps[0];
+ let bestDist = Math.abs(value - steps[0]);
+ for (const s of steps) {
+ const d = Math.abs(value - s);
+ if (d < bestDist) {
+ bestDist = d;
+ best = s;
+ }
+ }
+ return best;
+}
+
+const SLOT_W = 6;
+const TICK_LEN = 7;
+const ACROSS = 36; // cap size perpendicular to the track (the long grip edge)
+const ALONG = 14; // cap size along the track
+
+function render({ model, el }) {
+ el.innerHTML = "";
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "fader-wrapper";
+
+ const title = document.createElement("div");
+ title.className = "fader-title";
+ wrapper.appendChild(title);
+
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("class", "fader-svg");
+
+ const slot = document.createElementNS(SVG_NS, "line");
+ slot.setAttribute("class", "fader-track");
+ svg.appendChild(slot);
+
+ const fill = document.createElementNS(SVG_NS, "line");
+ fill.setAttribute("class", "fader-fill");
+ svg.appendChild(fill);
+
+ const ticksGroup = document.createElementNS(SVG_NS, "g");
+ ticksGroup.setAttribute("class", "fader-ticks");
+ svg.appendChild(ticksGroup);
+
+ const cap = document.createElementNS(SVG_NS, "rect");
+ cap.setAttribute("class", "fader-cap");
+ cap.setAttribute("rx", "3");
+ svg.appendChild(cap);
+
+ const groove = document.createElementNS(SVG_NS, "line");
+ groove.setAttribute("class", "fader-groove");
+ svg.appendChild(groove);
+
+ wrapper.appendChild(svg);
+
+ const valueLabel = document.createElement("div");
+ valueLabel.className = "fader-value";
+ wrapper.appendChild(valueLabel);
+
+ const midiBtn = document.createElement("button");
+ midiBtn.className = "fader-midi-btn";
+ midiBtn.type = "button";
+ wrapper.appendChild(midiBtn);
+
+ el.appendChild(wrapper);
+
+ let dragging = false;
+ let geom = null;
+
+ function valueFraction() {
+ const min = model.get("min_value");
+ const max = model.get("max_value");
+ return (model.get("value") - min) / (max - min);
+ }
+
+ function drawTicks(vertical, g) {
+ ticksGroup.innerHTML = "";
+ const ticks = model.get("ticks") || [];
+ const min = model.get("min_value");
+ const max = model.get("max_value");
+ const span = max - min;
+ if (span === 0) return;
+ for (const t of ticks) {
+ const f = (t.value - min) / span;
+ if (f < -1e-6 || f > 1 + 1e-6) continue;
+ const line = document.createElementNS(SVG_NS, "line");
+ line.setAttribute("class", "fader-tick");
+ let lx, ly, anchor, baseline;
+ if (vertical) {
+ const y = g.end - f * g.len;
+ line.setAttribute("x1", g.tick1);
+ line.setAttribute("y1", y);
+ line.setAttribute("x2", g.tick2);
+ line.setAttribute("y2", y);
+ lx = g.labelPos;
+ ly = y;
+ anchor = "start";
+ baseline = "middle";
+ } else {
+ const x = g.start + f * g.len;
+ line.setAttribute("x1", x);
+ line.setAttribute("y1", g.tick1);
+ line.setAttribute("x2", x);
+ line.setAttribute("y2", g.tick2);
+ lx = x;
+ ly = g.labelPos;
+ anchor = "middle";
+ baseline = "hanging";
+ }
+ ticksGroup.appendChild(line);
+ if (t.label) {
+ const text = document.createElementNS(SVG_NS, "text");
+ text.setAttribute("class", "fader-tick-label");
+ text.setAttribute("x", lx);
+ text.setAttribute("y", ly);
+ text.setAttribute("text-anchor", anchor);
+ text.setAttribute("dominant-baseline", baseline);
+ text.textContent = t.label;
+ ticksGroup.appendChild(text);
+ }
+ }
+ }
+
+ function updateGeometry() {
+ const len = model.get("length");
+ const vertical = model.get("orientation") !== "horizontal";
+ const ticks = model.get("ticks") || [];
+ const hasLabels = ticks.some((t) => t.label);
+ const f = valueFraction();
+
+ if (vertical) {
+ const trackX = ACROSS / 2 + 2;
+ const top = ALONG / 2 + 4;
+ const end = top + len; // bottom == min
+ const tick1 = trackX + SLOT_W / 2 + 1;
+ const tick2 = tick1 + TICK_LEN;
+ const labelPos = tick2 + 3;
+ // Include the cap's right edge so it isn't clipped when there are no labels.
+ const capRight = trackX + ACROSS / 2 + 2;
+ const svgW = Math.max(labelPos + (hasLabels ? 30 : 2), capRight);
+ const svgH = end + ALONG / 2 + 4;
+ geom = { vertical, len, top, end, trackX, tick1, tick2, labelPos, svgW, svgH };
+
+ svg.setAttribute("width", svgW);
+ svg.setAttribute("height", svgH);
+ svg.setAttribute("viewBox", `0 0 ${svgW} ${svgH}`);
+
+ slot.setAttribute("x1", trackX);
+ slot.setAttribute("x2", trackX);
+ slot.setAttribute("y1", top);
+ slot.setAttribute("y2", end);
+
+ const capC = end - f * len;
+ fill.setAttribute("x1", trackX);
+ fill.setAttribute("x2", trackX);
+ fill.setAttribute("y1", capC);
+ fill.setAttribute("y2", end);
+
+ cap.setAttribute("x", trackX - ACROSS / 2);
+ cap.setAttribute("y", capC - ALONG / 2);
+ cap.setAttribute("width", ACROSS);
+ cap.setAttribute("height", ALONG);
+ groove.setAttribute("x1", trackX - ACROSS / 2 + 5);
+ groove.setAttribute("x2", trackX + ACROSS / 2 - 5);
+ groove.setAttribute("y1", capC);
+ groove.setAttribute("y2", capC);
+ } else {
+ const trackY = ACROSS / 2 + 2;
+ const start = ALONG / 2 + 4; // left == min
+ const end = start + len; // right == max
+ const tick1 = trackY + SLOT_W / 2 + 1;
+ const tick2 = tick1 + TICK_LEN;
+ const labelPos = tick2 + 3;
+ const svgW = end + ALONG / 2 + 4;
+ // Include the cap's bottom edge so it isn't clipped when there are no labels.
+ const capBottom = trackY + ACROSS / 2 + 2;
+ const svgH = Math.max(labelPos + (hasLabels ? 14 : 2), capBottom);
+ geom = { vertical, len, start, end, trackY, tick1, tick2, labelPos, svgW, svgH };
+
+ svg.setAttribute("width", svgW);
+ svg.setAttribute("height", svgH);
+ svg.setAttribute("viewBox", `0 0 ${svgW} ${svgH}`);
+
+ slot.setAttribute("y1", trackY);
+ slot.setAttribute("y2", trackY);
+ slot.setAttribute("x1", start);
+ slot.setAttribute("x2", end);
+
+ const capC = start + f * len;
+ fill.setAttribute("y1", trackY);
+ fill.setAttribute("y2", trackY);
+ fill.setAttribute("x1", start);
+ fill.setAttribute("x2", capC);
+
+ cap.setAttribute("x", capC - ALONG / 2);
+ cap.setAttribute("y", trackY - ACROSS / 2);
+ cap.setAttribute("width", ALONG);
+ cap.setAttribute("height", ACROSS);
+ groove.setAttribute("y1", trackY - ACROSS / 2 + 5);
+ groove.setAttribute("y2", trackY + ACROSS / 2 - 5);
+ groove.setAttribute("x1", capC);
+ groove.setAttribute("x2", capC);
+ }
+
+ drawTicks(vertical, geom);
+ updateValueLabel();
+ }
+
+ function updateTitle() {
+ const text = model.get("label");
+ title.textContent = text || "";
+ title.style.display = text ? "" : "none";
+ }
+
+ function updateValueLabel() {
+ if (!model.get("show_value")) {
+ valueLabel.style.display = "none";
+ return;
+ }
+ valueLabel.style.display = "";
+ const value = model.get("value");
+ const steps = model.get("steps") || [];
+ if (steps.length) {
+ const ticks = model.get("ticks") || [];
+ const match = ticks.find((t) => Math.abs(t.value - value) < 1e-9);
+ valueLabel.textContent = match ? match.label : String(value);
+ } else {
+ valueLabel.textContent = formatValue(value, model.get("step"));
+ }
+ }
+
+ function pointerFraction(event) {
+ const rect = svg.getBoundingClientRect();
+ if (!geom) return valueFraction();
+ let f;
+ if (geom.vertical) {
+ const scale = rect.height / geom.svgH;
+ const y = (event.clientY - rect.top) / scale;
+ f = (geom.end - y) / geom.len;
+ } else {
+ const scale = rect.width / geom.svgW;
+ const x = (event.clientX - rect.left) / scale;
+ f = (x - geom.start) / geom.len;
+ }
+ return Math.max(0, Math.min(1, f));
+ }
+
+ function setFromFraction(fraction) {
+ const min = model.get("min_value");
+ const max = model.get("max_value");
+ const steps = model.get("steps") || [];
+ const raw = min + fraction * (max - min);
+ const next = steps.length
+ ? nearestStep(raw, steps)
+ : snap(raw, min, max, model.get("step"));
+ model.set("value", next);
+ model.save_changes();
+ }
+
+ function startDrag(event) {
+ event.preventDefault();
+ dragging = true;
+ setFromFraction(pointerFraction(event));
+ }
+ function moveDrag(event) {
+ if (!dragging) return;
+ event.preventDefault();
+ setFromFraction(pointerFraction(event));
+ }
+ function endDrag() {
+ dragging = false;
+ }
+
+ svg.addEventListener("mousedown", startDrag);
+ window.addEventListener("mousemove", moveDrag);
+ window.addEventListener("mouseup", endDrag);
+ svg.addEventListener(
+ "touchstart",
+ (e) => {
+ if (e.touches.length) startDrag(e.touches[0]);
+ },
+ { passive: false },
+ );
+ window.addEventListener(
+ "touchmove",
+ (e) => {
+ if (dragging && e.touches.length) moveDrag(e.touches[0]);
+ },
+ { passive: false },
+ );
+ window.addEventListener("touchend", endDrag);
+
+ function applyColor() {
+ const color = model.get("color");
+ if (color) {
+ wrapper.style.setProperty("--fader-fill", color);
+ wrapper.style.setProperty("--fader-cap-border", color);
+ } else {
+ wrapper.style.removeProperty("--fader-fill");
+ wrapper.style.removeProperty("--fader-cap-border");
+ }
+ }
+
+ // --- MIDI (Ableton-style learn) -----------------------------------------
+ let midiInputs = [];
+ let midiStateBound = false;
+
+ // Persist bindings in browser localStorage, namespaced by scope + key so two
+ // notebooks (which share an origin's storage) don't collide. The default
+ // scope is the browser URL path — localStorage is already per-origin, so the
+ // path is all that's needed to tell sibling notebooks apart.
+ function midiScope() {
+ const explicit = model.get("midi_scope");
+ if (explicit) return explicit;
+ try {
+ return window.location.pathname || "";
+ } catch (e) {
+ return "";
+ }
+ }
+
+ function storageKey() {
+ const key = model.get("midi_key") || model.get("label") || "";
+ if (!key) return null;
+ return `wigglystuff-midi/${midiScope()}/${key}`;
+ }
+
+ function saveBinding() {
+ const sk = storageKey();
+ if (!sk) return;
+ try {
+ localStorage.setItem(
+ sk,
+ JSON.stringify({
+ cc: model.get("midi_cc"),
+ channel: model.get("midi_channel"),
+ device: model.get("midi_device"),
+ }),
+ );
+ } catch (e) {
+ /* storage unavailable (private mode, quota) — ignore */
+ }
+ }
+
+ function clearBinding() {
+ const sk = storageKey();
+ if (!sk) return;
+ try {
+ localStorage.removeItem(sk);
+ } catch (e) {
+ /* ignore */
+ }
+ }
+
+ function loadBinding() {
+ const sk = storageKey();
+ if (!sk) return null;
+ try {
+ const raw = localStorage.getItem(sk);
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function onMidiMessage(event) {
+ const [status, data1, data2] = event.data;
+ if ((status & 0xf0) !== 0xb0) return; // control-change only
+ const channel = status & 0x0f;
+ const cc = data1;
+ const val = data2;
+
+ if (model.get("midi_learning")) {
+ model.set("midi_cc", cc);
+ model.set("midi_channel", channel);
+ model.set("midi_device", event.target.name || "");
+ model.set("midi_learning", false);
+ model.save_changes();
+ saveBinding();
+ return;
+ }
+
+ const boundCc = model.get("midi_cc");
+ if (boundCc < 0 || cc !== boundCc) return;
+ const boundCh = model.get("midi_channel");
+ if (boundCh >= 0 && channel !== boundCh) return;
+ setFromFraction(val / 127);
+ }
+
+ function detachMidi() {
+ for (const input of midiInputs) {
+ input.removeEventListener("midimessage", onMidiMessage);
+ }
+ midiInputs = [];
+ }
+
+ function attachMidi(access) {
+ detachMidi();
+ for (const input of access.inputs.values()) {
+ input.addEventListener("midimessage", onMidiMessage);
+ midiInputs.push(input);
+ }
+ }
+
+ async function enableMidi() {
+ const access = await getMidiAccess();
+ if (!access) return null;
+ attachMidi(access);
+ if (!midiStateBound) {
+ midiStateBound = true;
+ access.addEventListener("statechange", () => attachMidi(access));
+ }
+ return access;
+ }
+
+ function updateMidiButton() {
+ if (!model.get("midi")) {
+ midiBtn.style.display = "none";
+ return;
+ }
+ midiBtn.style.display = "";
+ midiBtn.classList.toggle("learning", model.get("midi_learning"));
+ const cc = model.get("midi_cc");
+ midiBtn.classList.toggle("bound", cc >= 0 && !model.get("midi_learning"));
+ if (!model.get("midi_supported")) {
+ midiBtn.textContent = "no MIDI";
+ midiBtn.disabled = true;
+ midiBtn.title = "Web MIDI is not available in this browser.";
+ } else if (model.get("midi_learning")) {
+ midiBtn.textContent = "move a control…";
+ midiBtn.disabled = false;
+ midiBtn.title = "Move a knob/fader on your MIDI device to bind it.";
+ } else if (cc >= 0) {
+ midiBtn.textContent = `CC ${cc}`;
+ midiBtn.disabled = false;
+ midiBtn.title =
+ (model.get("midi_device") || "MIDI") +
+ ` · CC ${cc}. Click to re-learn, right-click to clear.`;
+ } else {
+ midiBtn.textContent = "MIDI";
+ midiBtn.disabled = false;
+ midiBtn.title = "Click, then move a control on your MIDI device.";
+ }
+ }
+
+ midiBtn.addEventListener("click", async () => {
+ if (!model.get("midi_supported")) return;
+ if (model.get("midi_learning")) {
+ model.set("midi_learning", false); // toggle off
+ model.save_changes();
+ return;
+ }
+ await enableMidi();
+ model.set("midi_learning", true);
+ model.save_changes();
+ });
+
+ midiBtn.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ model.set("midi_cc", -1);
+ model.set("midi_channel", -1);
+ model.set("midi_device", "");
+ model.set("midi_learning", false);
+ model.save_changes();
+ clearBinding();
+ });
+
+ function initMidi() {
+ if (!model.get("midi")) {
+ updateMidiButton();
+ return;
+ }
+ const supported = !!navigator.requestMIDIAccess;
+ if (model.get("midi_supported") !== supported) {
+ model.set("midi_supported", supported);
+ model.save_changes();
+ }
+ // Restore a persisted binding when none was set explicitly in Python.
+ if (model.get("midi_cc") < 0) {
+ const stored = loadBinding();
+ if (stored && typeof stored.cc === "number" && stored.cc >= 0) {
+ model.set("midi_cc", stored.cc);
+ model.set(
+ "midi_channel",
+ typeof stored.channel === "number" ? stored.channel : -1,
+ );
+ model.set("midi_device", stored.device || "");
+ model.save_changes();
+ }
+ }
+ if (supported && model.get("midi_cc") >= 0) enableMidi();
+ updateMidiButton();
+ }
+
+ model.on("change:value", updateGeometry);
+ model.on("change:min_value", updateGeometry);
+ model.on("change:max_value", updateGeometry);
+ model.on("change:step", updateValueLabel);
+ model.on("change:ticks", updateGeometry);
+ model.on("change:steps", updateValueLabel);
+ model.on("change:orientation", updateGeometry);
+ model.on("change:length", updateGeometry);
+ model.on("change:show_value", updateValueLabel);
+ model.on("change:label", updateTitle);
+ model.on("change:color", applyColor);
+ model.on("change:midi", initMidi);
+ model.on("change:midi_learning", updateMidiButton);
+ model.on("change:midi_supported", updateMidiButton);
+ model.on("change:midi_cc", updateMidiButton);
+ model.on("change:midi_device", updateMidiButton);
+
+ applyColor();
+ updateTitle();
+ updateGeometry();
+ initMidi();
+
+ return () => {
+ window.removeEventListener("mousemove", moveDrag);
+ window.removeEventListener("mouseup", endDrag);
+ window.removeEventListener("touchmove", moveDrag);
+ window.removeEventListener("touchend", endDrag);
+ detachMidi();
+ };
+}
+
+export default { render };
diff --git a/wigglystuff/static/knob.css b/wigglystuff/static/knob.css
new file mode 100644
index 00000000..1cbabe7f
--- /dev/null
+++ b/wigglystuff/static/knob.css
@@ -0,0 +1,144 @@
+.knob-wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+ color-scheme: light dark;
+ padding: 4px;
+
+ --knob-track: #d1d5db;
+ --knob-fill: #3b82f6;
+ --knob-body: #f9fafb;
+ --knob-body-border: #9ca3af;
+ --knob-pointer: #1d4ed8;
+ --knob-tick: #9ca3af;
+ --knob-text: #111827;
+}
+
+.dark .knob-wrapper,
+.dark-theme .knob-wrapper,
+[data-theme="dark"] .knob-wrapper {
+ --knob-track: #374151;
+ --knob-fill: #60a5fa;
+ --knob-body: #1f2937;
+ --knob-body-border: #4b5563;
+ --knob-pointer: #93c5fd;
+ --knob-tick: #6b7280;
+ --knob-text: #f1f5f9;
+}
+
+.knob-title {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--knob-text);
+ opacity: 0.85;
+ margin-bottom: 3px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.knob-svg {
+ display: block;
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+}
+
+.knob-svg:active {
+ cursor: grabbing;
+}
+
+.knob-track {
+ stroke: var(--knob-track);
+ fill: none;
+ stroke-linecap: round;
+}
+
+.knob-fill {
+ stroke: var(--knob-fill);
+ fill: none;
+ stroke-linecap: round;
+}
+
+.knob-body {
+ fill: var(--knob-body);
+ stroke: var(--knob-body-border);
+ stroke-width: 1.5;
+}
+
+.knob-pointer {
+ stroke: var(--knob-pointer);
+ stroke-linecap: round;
+}
+
+.knob-tick {
+ stroke: var(--knob-tick);
+ stroke-width: 1.5;
+}
+
+.knob-tick-label {
+ fill: var(--knob-text);
+ opacity: 0.75;
+ font-size: 9px;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+}
+
+.knob-value {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--knob-text);
+ font-variant-numeric: tabular-nums;
+ margin-top: 4px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.knob-midi-btn {
+ margin-top: 6px;
+ padding: 2px 8px;
+ font-size: 10px;
+ font-weight: 600;
+ font-family: inherit;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: var(--knob-text);
+ background: transparent;
+ border: 1px solid var(--knob-track);
+ border-radius: 5px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+}
+
+.knob-midi-btn:hover:not(:disabled) {
+ border-color: var(--knob-fill);
+}
+
+.knob-midi-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.knob-midi-btn.bound {
+ color: #fff;
+ background: var(--knob-fill);
+ border-color: var(--knob-fill);
+}
+
+.knob-midi-btn.learning {
+ color: #fff;
+ background: #ef4444;
+ border-color: #ef4444;
+ animation: knob-midi-pulse 1s ease-in-out infinite;
+}
+
+@keyframes knob-midi-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.55;
+ }
+}
diff --git a/wigglystuff/static/knob.js b/wigglystuff/static/knob.js
new file mode 100644
index 00000000..6f879dce
--- /dev/null
+++ b/wigglystuff/static/knob.js
@@ -0,0 +1,532 @@
+const SVG_NS = "http://www.w3.org/2000/svg";
+const DEG = Math.PI / 180;
+
+// Shared across all knobs on the page so we only request access once.
+let _midiAccessPromise = null;
+function getMidiAccess() {
+ if (!navigator.requestMIDIAccess) return Promise.resolve(null);
+ if (!_midiAccessPromise) _midiAccessPromise = navigator.requestMIDIAccess();
+ return _midiAccessPromise;
+}
+
+function getPrecision(step) {
+ const s = String(step);
+ const dot = s.indexOf(".");
+ return dot === -1 ? 0 : s.length - dot - 1;
+}
+
+function snap(value, min, max, step) {
+ const clamped = Math.max(min, Math.min(max, value));
+ if (!step) return clamped;
+ const snapped = min + Math.round((clamped - min) / step) * step;
+ return Math.max(min, Math.min(max, snapped));
+}
+
+function formatValue(value, step) {
+ return value.toFixed(getPrecision(step));
+}
+
+function nearestStep(value, steps) {
+ let best = steps[0];
+ let bestDist = Math.abs(value - steps[0]);
+ for (const s of steps) {
+ const d = Math.abs(value - s);
+ if (d < bestDist) {
+ bestDist = d;
+ best = s;
+ }
+ }
+ return best;
+}
+
+// A point on a circle, angle in degrees measured clockwise from 12 o'clock.
+function pointAt(cx, cy, r, angleDeg) {
+ const a = angleDeg * DEG;
+ return { x: cx + r * Math.sin(a), y: cy - r * Math.cos(a) };
+}
+
+// SVG arc from angle a to angle b (degrees clockwise from top), drawn clockwise.
+function arcPath(cx, cy, r, aDeg, bDeg) {
+ const delta = bDeg - aDeg;
+ if (Math.abs(delta) < 1e-6) return "";
+ const sweep = delta > 0 ? 1 : 0;
+ // A single SVG arc can't draw a full circle (endpoints coincide and the
+ // renderer skips it), so split a full sweep into two semicircles.
+ if (Math.abs(delta) >= 360 - 1e-6) {
+ const p0 = pointAt(cx, cy, r, aDeg);
+ const pm = pointAt(cx, cy, r, aDeg + 180);
+ return `M ${p0.x} ${p0.y} A ${r} ${r} 0 1 ${sweep} ${pm.x} ${pm.y} A ${r} ${r} 0 1 ${sweep} ${p0.x} ${p0.y}`;
+ }
+ const a = pointAt(cx, cy, r, aDeg);
+ const b = pointAt(cx, cy, r, bDeg);
+ const large = Math.abs(delta) > 180 ? 1 : 0;
+ return `M ${a.x} ${a.y} A ${r} ${r} 0 ${large} ${sweep} ${b.x} ${b.y}`;
+}
+
+function render({ model, el }) {
+ el.innerHTML = "";
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "knob-wrapper";
+
+ const title = document.createElement("div");
+ title.className = "knob-title";
+ wrapper.appendChild(title);
+
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("class", "knob-svg");
+
+ const track = document.createElementNS(SVG_NS, "path");
+ track.setAttribute("class", "knob-track");
+ svg.appendChild(track);
+
+ const fill = document.createElementNS(SVG_NS, "path");
+ fill.setAttribute("class", "knob-fill");
+ svg.appendChild(fill);
+
+ const ticksGroup = document.createElementNS(SVG_NS, "g");
+ ticksGroup.setAttribute("class", "knob-ticks");
+ svg.appendChild(ticksGroup);
+
+ const body = document.createElementNS(SVG_NS, "circle");
+ body.setAttribute("class", "knob-body");
+ svg.appendChild(body);
+
+ const pointer = document.createElementNS(SVG_NS, "line");
+ pointer.setAttribute("class", "knob-pointer");
+ svg.appendChild(pointer);
+
+ wrapper.appendChild(svg);
+
+ const valueLabel = document.createElement("div");
+ valueLabel.className = "knob-value";
+ wrapper.appendChild(valueLabel);
+
+ const midiBtn = document.createElement("button");
+ midiBtn.className = "knob-midi-btn";
+ midiBtn.type = "button";
+ wrapper.appendChild(midiBtn);
+
+ el.appendChild(wrapper);
+
+ let dragging = false;
+
+ function angleFor(fraction) {
+ const start = model.get("start_angle");
+ const end = model.get("end_angle");
+ return start + fraction * (end - start);
+ }
+
+ function valueFraction() {
+ const min = model.get("min_value");
+ const max = model.get("max_value");
+ return (model.get("value") - min) / (max - min);
+ }
+
+ function drawTicks(cx, cy, tickIn, tickOut, labelR) {
+ ticksGroup.innerHTML = "";
+ const ticks = model.get("ticks") || [];
+ const min = model.get("min_value");
+ const max = model.get("max_value");
+ const span = max - min;
+ if (span === 0) return;
+ for (const t of ticks) {
+ const f = (t.value - min) / span;
+ if (f < -1e-6 || f > 1 + 1e-6) continue;
+ const angle = angleFor(f);
+ const p1 = pointAt(cx, cy, tickIn, angle);
+ const p2 = pointAt(cx, cy, tickOut, angle);
+ const line = document.createElementNS(SVG_NS, "line");
+ line.setAttribute("class", "knob-tick");
+ line.setAttribute("x1", p1.x);
+ line.setAttribute("y1", p1.y);
+ line.setAttribute("x2", p2.x);
+ line.setAttribute("y2", p2.y);
+ ticksGroup.appendChild(line);
+ if (t.label) {
+ const lp = pointAt(cx, cy, labelR, angle);
+ const text = document.createElementNS(SVG_NS, "text");
+ text.setAttribute("class", "knob-tick-label");
+ text.setAttribute("x", lp.x);
+ text.setAttribute("y", lp.y);
+ text.setAttribute("text-anchor", "middle");
+ text.setAttribute("dominant-baseline", "middle");
+ text.textContent = t.label;
+ ticksGroup.appendChild(text);
+ }
+ }
+ }
+
+ function updateGeometry() {
+ const size = model.get("size");
+ svg.setAttribute("width", size);
+ svg.setAttribute("height", size);
+ svg.setAttribute("viewBox", `0 0 ${size} ${size}`);
+ wrapper.style.width = size + "px";
+
+ const cx = size / 2;
+ const cy = size / 2;
+ const R = size / 2;
+ const ticks = model.get("ticks") || [];
+ const hasLabels = ticks.some((t) => t.label);
+ const pad = hasLabels ? 14 : 4;
+ const outer = R - pad;
+
+ const arcR = outer * 0.8;
+ const bodyR = outer * 0.58;
+ const tickIn = outer * 0.86;
+ const tickOut = outer * 0.98;
+ const labelR = outer + pad * 0.5;
+ const arcWidth = Math.max(4, outer * 0.14);
+
+ const start = model.get("start_angle");
+ const end = model.get("end_angle");
+
+ track.setAttribute("d", arcPath(cx, cy, arcR, start, end));
+ track.setAttribute("stroke-width", arcWidth);
+
+ const angle = angleFor(valueFraction());
+ fill.setAttribute("d", arcPath(cx, cy, arcR, start, angle));
+ fill.setAttribute("stroke-width", arcWidth);
+
+ body.setAttribute("cx", cx);
+ body.setAttribute("cy", cy);
+ body.setAttribute("r", bodyR);
+
+ const tip = pointAt(cx, cy, bodyR * 0.92, angle);
+ const base = pointAt(cx, cy, bodyR * 0.15, angle);
+ pointer.setAttribute("x1", base.x);
+ pointer.setAttribute("y1", base.y);
+ pointer.setAttribute("x2", tip.x);
+ pointer.setAttribute("y2", tip.y);
+ pointer.setAttribute("stroke-width", Math.max(2, bodyR * 0.12));
+
+ drawTicks(cx, cy, tickIn, tickOut, labelR);
+ updateValueLabel();
+ }
+
+ function updateTitle() {
+ const text = model.get("label");
+ title.textContent = text || "";
+ title.style.display = text ? "" : "none";
+ }
+
+ function updateValueLabel() {
+ if (!model.get("show_value")) {
+ valueLabel.style.display = "none";
+ return;
+ }
+ valueLabel.style.display = "";
+ const value = model.get("value");
+ const steps = model.get("steps") || [];
+ if (steps.length) {
+ // Prefer the matching tick label (so named detents read out by name).
+ const ticks = model.get("ticks") || [];
+ const match = ticks.find((t) => Math.abs(t.value - value) < 1e-9);
+ valueLabel.textContent = match ? match.label : String(value);
+ } else {
+ valueLabel.textContent = formatValue(value, model.get("step"));
+ }
+ }
+
+ // Map a pointer position to a fraction along the sweep, clamping into the
+ // bottom gap rather than wrapping around like a full-circle dial.
+ function pointerFraction(event) {
+ const rect = svg.getBoundingClientRect();
+ const cx = rect.left + rect.width / 2;
+ const cy = rect.top + rect.height / 2;
+ const dx = event.clientX - cx;
+ const dy = event.clientY - cy;
+ let a = Math.atan2(dx, -dy) / DEG; // degrees clockwise from top, (-180,180]
+ const start = model.get("start_angle");
+ const end = model.get("end_angle");
+ while (a < start) a += 360;
+ while (a >= start + 360) a -= 360;
+ const sweep = end - start;
+ if (a <= end) return (a - start) / sweep;
+ // Pointer is in the gap beyond the arc: clamp to the nearer end.
+ const gapMid = (end + start + 360) / 2;
+ return a < gapMid ? 1 : 0;
+ }
+
+ function setFromFraction(fraction) {
+ const min = model.get("min_value");
+ const max = model.get("max_value");
+ const steps = model.get("steps") || [];
+ const raw = min + fraction * (max - min);
+ const next = steps.length
+ ? nearestStep(raw, steps)
+ : snap(raw, min, max, model.get("step"));
+ model.set("value", next);
+ model.save_changes();
+ }
+
+ function startDrag(event) {
+ event.preventDefault();
+ dragging = true;
+ setFromFraction(pointerFraction(event));
+ }
+ function moveDrag(event) {
+ if (!dragging) return;
+ event.preventDefault();
+ setFromFraction(pointerFraction(event));
+ }
+ function endDrag() {
+ dragging = false;
+ }
+
+ svg.addEventListener("mousedown", startDrag);
+ window.addEventListener("mousemove", moveDrag);
+ window.addEventListener("mouseup", endDrag);
+ svg.addEventListener(
+ "touchstart",
+ (e) => {
+ if (e.touches.length) startDrag(e.touches[0]);
+ },
+ { passive: false },
+ );
+ window.addEventListener(
+ "touchmove",
+ (e) => {
+ if (dragging && e.touches.length) moveDrag(e.touches[0]);
+ },
+ { passive: false },
+ );
+ window.addEventListener("touchend", endDrag);
+
+ function applyColor() {
+ const color = model.get("color");
+ if (color) {
+ wrapper.style.setProperty("--knob-fill", color);
+ wrapper.style.setProperty("--knob-pointer", color);
+ } else {
+ wrapper.style.removeProperty("--knob-fill");
+ wrapper.style.removeProperty("--knob-pointer");
+ }
+ }
+
+ // --- MIDI (Ableton-style learn) -----------------------------------------
+ let midiInputs = [];
+ let midiStateBound = false;
+
+ // Persist bindings in browser localStorage, namespaced by scope + key so two
+ // notebooks (which share an origin's storage) don't collide. The default
+ // scope is the browser URL path — localStorage is already per-origin, so the
+ // path is all that's needed to tell sibling notebooks apart.
+ function midiScope() {
+ const explicit = model.get("midi_scope");
+ if (explicit) return explicit;
+ try {
+ return window.location.pathname || "";
+ } catch (e) {
+ return "";
+ }
+ }
+
+ function storageKey() {
+ const key = model.get("midi_key") || model.get("label") || "";
+ if (!key) return null;
+ return `wigglystuff-midi/${midiScope()}/${key}`;
+ }
+
+ function saveBinding() {
+ const sk = storageKey();
+ if (!sk) return;
+ try {
+ localStorage.setItem(
+ sk,
+ JSON.stringify({
+ cc: model.get("midi_cc"),
+ channel: model.get("midi_channel"),
+ device: model.get("midi_device"),
+ }),
+ );
+ } catch (e) {
+ /* storage unavailable (private mode, quota) — ignore */
+ }
+ }
+
+ function clearBinding() {
+ const sk = storageKey();
+ if (!sk) return;
+ try {
+ localStorage.removeItem(sk);
+ } catch (e) {
+ /* ignore */
+ }
+ }
+
+ function loadBinding() {
+ const sk = storageKey();
+ if (!sk) return null;
+ try {
+ const raw = localStorage.getItem(sk);
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function onMidiMessage(event) {
+ const [status, data1, data2] = event.data;
+ if ((status & 0xf0) !== 0xb0) return; // control-change only
+ const channel = status & 0x0f;
+ const cc = data1;
+ const val = data2;
+
+ if (model.get("midi_learning")) {
+ model.set("midi_cc", cc);
+ model.set("midi_channel", channel);
+ model.set("midi_device", event.target.name || "");
+ model.set("midi_learning", false);
+ model.save_changes();
+ saveBinding();
+ return;
+ }
+
+ const boundCc = model.get("midi_cc");
+ if (boundCc < 0 || cc !== boundCc) return;
+ const boundCh = model.get("midi_channel");
+ if (boundCh >= 0 && channel !== boundCh) return;
+ setFromFraction(val / 127);
+ }
+
+ function detachMidi() {
+ for (const input of midiInputs) {
+ input.removeEventListener("midimessage", onMidiMessage);
+ }
+ midiInputs = [];
+ }
+
+ function attachMidi(access) {
+ detachMidi();
+ for (const input of access.inputs.values()) {
+ input.addEventListener("midimessage", onMidiMessage);
+ midiInputs.push(input);
+ }
+ }
+
+ async function enableMidi() {
+ const access = await getMidiAccess();
+ if (!access) return null;
+ attachMidi(access);
+ if (!midiStateBound) {
+ midiStateBound = true;
+ access.addEventListener("statechange", () => attachMidi(access));
+ }
+ return access;
+ }
+
+ function updateMidiButton() {
+ if (!model.get("midi")) {
+ midiBtn.style.display = "none";
+ return;
+ }
+ midiBtn.style.display = "";
+ midiBtn.classList.toggle("learning", model.get("midi_learning"));
+ const cc = model.get("midi_cc");
+ midiBtn.classList.toggle("bound", cc >= 0 && !model.get("midi_learning"));
+ if (!model.get("midi_supported")) {
+ midiBtn.textContent = "no MIDI";
+ midiBtn.disabled = true;
+ midiBtn.title = "Web MIDI is not available in this browser.";
+ } else if (model.get("midi_learning")) {
+ midiBtn.textContent = "move a control…";
+ midiBtn.disabled = false;
+ midiBtn.title = "Move a knob/fader on your MIDI device to bind it.";
+ } else if (cc >= 0) {
+ midiBtn.textContent = `CC ${cc}`;
+ midiBtn.disabled = false;
+ midiBtn.title =
+ (model.get("midi_device") || "MIDI") +
+ ` · CC ${cc}. Click to re-learn, right-click to clear.`;
+ } else {
+ midiBtn.textContent = "MIDI";
+ midiBtn.disabled = false;
+ midiBtn.title = "Click, then move a control on your MIDI device.";
+ }
+ }
+
+ midiBtn.addEventListener("click", async () => {
+ if (!model.get("midi_supported")) return;
+ if (model.get("midi_learning")) {
+ model.set("midi_learning", false); // toggle off
+ model.save_changes();
+ return;
+ }
+ await enableMidi();
+ model.set("midi_learning", true);
+ model.save_changes();
+ });
+
+ midiBtn.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ model.set("midi_cc", -1);
+ model.set("midi_channel", -1);
+ model.set("midi_device", "");
+ model.set("midi_learning", false);
+ model.save_changes();
+ clearBinding();
+ });
+
+ function initMidi() {
+ if (!model.get("midi")) {
+ updateMidiButton();
+ return;
+ }
+ const supported = !!navigator.requestMIDIAccess;
+ if (model.get("midi_supported") !== supported) {
+ model.set("midi_supported", supported);
+ model.save_changes();
+ }
+ // Restore a persisted binding when none was set explicitly in Python.
+ if (model.get("midi_cc") < 0) {
+ const stored = loadBinding();
+ if (stored && typeof stored.cc === "number" && stored.cc >= 0) {
+ model.set("midi_cc", stored.cc);
+ model.set(
+ "midi_channel",
+ typeof stored.channel === "number" ? stored.channel : -1,
+ );
+ model.set("midi_device", stored.device || "");
+ model.save_changes();
+ }
+ }
+ // Start listening for any active binding (explicit or restored).
+ if (supported && model.get("midi_cc") >= 0) enableMidi();
+ updateMidiButton();
+ }
+
+ model.on("change:value", updateGeometry);
+ model.on("change:min_value", updateGeometry);
+ model.on("change:max_value", updateGeometry);
+ model.on("change:step", updateValueLabel);
+ model.on("change:start_angle", updateGeometry);
+ model.on("change:end_angle", updateGeometry);
+ model.on("change:ticks", updateGeometry);
+ model.on("change:steps", updateValueLabel);
+ model.on("change:size", updateGeometry);
+ model.on("change:show_value", updateValueLabel);
+ model.on("change:label", updateTitle);
+ model.on("change:color", applyColor);
+ model.on("change:midi", initMidi);
+ model.on("change:midi_learning", updateMidiButton);
+ model.on("change:midi_supported", updateMidiButton);
+ model.on("change:midi_cc", updateMidiButton);
+ model.on("change:midi_device", updateMidiButton);
+
+ applyColor();
+ updateTitle();
+ updateGeometry();
+ initMidi();
+
+ return () => {
+ window.removeEventListener("mousemove", moveDrag);
+ window.removeEventListener("mouseup", endDrag);
+ window.removeEventListener("touchmove", moveDrag);
+ window.removeEventListener("touchend", endDrag);
+ detachMidi();
+ };
+}
+
+export default { render };
diff --git a/zensical.toml b/zensical.toml
index 5751e601..1816064e 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -13,6 +13,8 @@ nav = [
{ Reference = [
{ Overview = "reference/index.md" },
{ Slider2D = "reference/slider2d.md" },
+ { Knob = "reference/knob.md" },
+ { Fader = "reference/fader.md" },
{ BezierCurve = "reference/bezier-curve.md" },
{ CurveEditor = "reference/curve-editor.md" },
{ ChartPuck = "reference/chart-puck.md" },