From 9cc7a285f72c0d2b036ad7b259e992216a0f90eb Mon Sep 17 00:00:00 2001 From: koaning Date: Tue, 11 Aug 2026 17:06:12 +0200 Subject: [PATCH 1/3] progress --- demos/fader.py | 83 ++++++++ demos/knob.py | 98 +++++++++ demos/mix_panel.py | 72 +++++++ pyproject.toml | 2 +- uv.lock | 8 +- wigglystuff/__init__.py | 6 + wigglystuff/_controls.py | 119 +++++++++++ wigglystuff/fader.py | 130 ++++++++++++ wigglystuff/knob.py | 142 +++++++++++++ wigglystuff/mix_panel.py | 120 +++++++++++ wigglystuff/static/fader.css | 96 +++++++++ wigglystuff/static/fader.js | 337 +++++++++++++++++++++++++++++++ wigglystuff/static/knob.css | 95 +++++++++ wigglystuff/static/knob.js | 320 +++++++++++++++++++++++++++++ wigglystuff/static/mix-panel.css | 82 ++++++++ wigglystuff/static/mix-panel.js | 89 ++++++++ 16 files changed, 1794 insertions(+), 5 deletions(-) create mode 100644 demos/fader.py create mode 100644 demos/knob.py create mode 100644 demos/mix_panel.py create mode 100644 wigglystuff/_controls.py create mode 100644 wigglystuff/fader.py create mode 100644 wigglystuff/knob.py create mode 100644 wigglystuff/mix_panel.py create mode 100644 wigglystuff/static/fader.css create mode 100644 wigglystuff/static/fader.js create mode 100644 wigglystuff/static/knob.css create mode 100644 wigglystuff/static/knob.js create mode 100644 wigglystuff/static/mix-panel.css create mode 100644 wigglystuff/static/mix-panel.js diff --git a/demos/fader.py b/demos/fader.py new file mode 100644 index 00000000..4b56d01b --- /dev/null +++ b/demos/fader.py @@ -0,0 +1,83 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# "wigglystuff==0.5.24", +# ] +# /// + +import marimo + +__generated_with = "0.23.16" +app = marimo.App(width="medium") + + +@app.cell +def _(mo): + mo.md(""" + # Fader + """) + return + + +@app.cell +def _(mo): + from wigglystuff import Fader + + level = mo.ui.anywidget( + Fader( + min_value=-60, + max_value=6, + value=0, + ticks=[(-60, "-60"), (-20, "-20"), (-6, "-6"), (0, "0"), (6, "+6")], + label="Level (dB)", + ) + ) + return Fader, level + + +@app.cell +def _(Fader, mo): + send = mo.ui.anywidget( + Fader(min_value=0, max_value=100, value=75, ticks=5, label="Send", color="teal") + ) + crossfade = mo.ui.anywidget( + Fader( + min_value=0, + max_value=1, + step=0.01, + value=0.5, + orientation="horizontal", + ticks=[(0, "A"), (1, "B")], + length=180, + label="Crossfade", + ) + ) + return crossfade, send + + +@app.cell +def _(crossfade, level, mo, send): + mo.hstack([level, send, crossfade], justify="center", align="center", gap=2) + return + + +@app.cell(hide_code=True) +def _(crossfade, level, mo, send): + mo.md(f""" + **Level:** `{level.value['value']:.1f} dB`   + **Send:** `{send.value['value']:.0f}`   + **Crossfade:** `{crossfade.value['value']:.2f}` + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/demos/knob.py b/demos/knob.py new file mode 100644 index 00000000..d698b0b0 --- /dev/null +++ b/demos/knob.py @@ -0,0 +1,98 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# "wigglystuff==0.5.24", +# ] +# /// + +import marimo + +__generated_with = "0.23.16" +app = marimo.App(width="medium") + + +@app.cell +def _(mo): + mo.md(""" + # Knob + """) + return + + +@app.cell +def _(mo): + from wigglystuff import Knob + + gain = mo.ui.anywidget( + Knob(min_value=0, max_value=11, value=5, ticks=12, label="Gain", color="tomato") + ) + return Knob, gain + + +@app.cell +def _(Knob, mo): + # A wider sweep with labelled endpoints, and a smaller pan-style knob. + pan = mo.ui.anywidget( + Knob( + min_value=-1, + max_value=1, + step=0.05, + value=0, + ticks=[(-1, "L"), (0, "C"), (1, "R")], + label="Pan", + ) + ) + wide = mo.ui.anywidget( + Knob( + min_value=0, + max_value=100, + value=30, + start_angle=-160, + end_angle=160, + ticks=5, + size=110, + label="Wide sweep", + ) + ) + # A gapless full-circle knob (start_angle=0, end_angle=360) that wraps. + full = mo.ui.anywidget( + Knob( + min_value=0, + max_value=360, + value=90, + start_angle=0, + end_angle=360, + ticks=[(0, "N"), (90, "E"), (180, "S"), (270, "W")], + label="Full circle", + ) + ) + return full, pan, wide + + +@app.cell +def _(full, gain, mo, pan, wide): + mo.hstack([gain, pan, wide, full], justify="center", gap=2) + return + + +@app.cell(hide_code=True) +def _(full, gain, mo, pan, wide): + mo.md(f""" + **Gain:** `{gain.value['value']:.1f}`   + **Pan:** `{pan.value['value']:.2f}`   + **Wide:** `{wide.value['value']:.0f}`   + **Full:** `{full.value['value']:.0f}` + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/demos/mix_panel.py b/demos/mix_panel.py new file mode 100644 index 00000000..8027b03f --- /dev/null +++ b/demos/mix_panel.py @@ -0,0 +1,72 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# "wigglystuff==0.5.24", +# ] +# /// + +import marimo + +__generated_with = "0.23.16" +app = marimo.App(width="medium") + + +@app.cell +def _(mo): + mo.md(""" + # MixPanel + + A rack of nested `Knob`/`Fader` widgets. The children are mounted *inside* + the panel via anywidget's widget-composition host (needs `anywidget>=0.11`). + Each child still syncs its own `value`, and the panel aggregates them into a + combined `values` dict. + """) + return + + +@app.cell +def _(mo): + from wigglystuff import MixPanel, Knob, Fader, Slider2D + + panel = mo.ui.anywidget( + MixPanel( + { + "gain": Knob(min_value=0, max_value=11, value=5, ticks=6, label="Gain"), + # A stepped rotary selector (discrete detents). + "mode": Knob( + steps=[(0, "Off"), (1, "Low"), (2, "Mid"), (3, "Hi")], + value=1, label="Mode", + ), + "level": Fader( + min_value=-60, max_value=6, value=0, + ticks=[(-60, "-60"), (-20, "-20"), (0, "0"), (6, "+6")], + label="Level", + ), + # A nested 2D slider — MixPanel aggregates its (x, y). + "xy": Slider2D(x=0.3, y=-0.2, width=90, height=90), + }, + title="Channel 1", + ) + ) + panel + return (panel,) + + +@app.cell(hide_code=True) +def _(mo, panel): + mo.md(f""" + **Combined values:** `{panel.value['values']}` + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/pyproject.toml b/pyproject.toml index 5fdb3055..a066a4ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ - "anywidget>=0.9.2", + "anywidget>=0.11.0", "drawdata", ] diff --git a/uv.lock b/uv.lock index a2937b2e..1548b7a2 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]] @@ -3038,7 +3038,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "altair", marker = "extra == 'docs'", specifier = ">=6.0.0" }, - { name = "anywidget", specifier = ">=0.9.2" }, + { name = "anywidget", specifier = ">=0.11.0" }, { name = "black", marker = "extra == 'docs'", specifier = ">=24.8.0" }, { name = "drawdata" }, { name = "marimo", marker = "extra == 'docs'", specifier = ">=0.18.0" }, diff --git a/wigglystuff/__init__.py b/wigglystuff/__init__.py index 8277b71c..ef339bfd 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,10 +34,12 @@ 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 from .matrix import Matrix +from .mix_panel import MixPanel from .module_tree import ModuleTreeWidget from .neo4j_widget import Neo4jWidget from .nested_table import NestedTable @@ -86,6 +89,7 @@ "EnvConfig", "EsmWidget", "Excalidraw", + "Fader", "FramePlayer", "GamepadWidget", "GraphWidget", @@ -93,10 +97,12 @@ "HeatmapSelect", "Hint", "KeystrokeWidget", + "Knob", "TangleLatex", "LiveEdit", "ManimWeb", "Matrix", + "MixPanel", "ModuleTreeWidget", "Neo4jWidget", "NestedTable", diff --git a/wigglystuff/_controls.py b/wigglystuff/_controls.py new file mode 100644 index 00000000..69a3ffba --- /dev/null +++ b/wigglystuff/_controls.py @@ -0,0 +1,119 @@ +"""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, Optional, 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) + + +def widget_refs(widgets: Sequence[Any], _obj: Optional[Any] = None) -> List[Any]: + """Serialize a list of child widgets to anywidget ref strings. + + Used as the ``to_json`` for a synced ``List`` trait holding child widgets. + anywidget's ``WidgetTrait`` handles a *single* widget, but a ``List`` trait + does not apply the per-element serializer, so we do it here. Every + ``anywidget.AnyWidget`` exposes a stable ``model_id``; the frontend host + resolves ``"anywidget:"`` back into a renderable child. + """ + refs: List[Any] = [] + for w in widgets: + model_id = getattr(w, "model_id", None) + refs.append(f"anywidget:{model_id}" if model_id else w) + return refs diff --git a/wigglystuff/fader.py b/wigglystuff/fader.py new file mode 100644 index 00000000..1416e5b4 --- /dev/null +++ b/wigglystuff/fader.py @@ -0,0 +1,130 @@ +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) + + 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 = "", + **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. + **kwargs: Forwarded to ``anywidget.AnyWidget``. + """ + 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, + **kwargs, + ) diff --git a/wigglystuff/knob.py b/wigglystuff/knob.py new file mode 100644 index 00000000..323e7b86 --- /dev/null +++ b/wigglystuff/knob.py @@ -0,0 +1,142 @@ +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) + + 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 = "", + **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. + **kwargs: Forwarded to ``anywidget.AnyWidget``. + """ + 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, + **kwargs, + ) diff --git a/wigglystuff/mix_panel.py b/wigglystuff/mix_panel.py new file mode 100644 index 00000000..ff42e0fd --- /dev/null +++ b/wigglystuff/mix_panel.py @@ -0,0 +1,120 @@ +from pathlib import Path +from typing import Any, Dict, List, Mapping, Sequence, Union + +import anywidget +import traitlets + +from ._controls import widget_refs + + +_ESM_PATH = Path(__file__).parent / "static" / "mix-panel.js" +_CSS_PATH = Path(__file__).parent / "static" / "mix-panel.css" + +Controls = Union[Mapping[str, Any], Sequence[Any]] + + +class MixPanel(anywidget.AnyWidget): + """A rack of child control widgets (Knobs and Faders) laid out as channel strips. + + MixPanel is a true *nested* anywidget: the child widgets are mounted inside + the panel's own view via anywidget's widget-composition host (requires + ``anywidget>=0.11.0`` and a host that implements it, e.g. marimo or + Jupyter). Each child still syncs its own ``value`` as usual; MixPanel also + aggregates them into a combined ``values`` dict keyed by name. + + Pass either a mapping of ``{name: widget}`` or a list of widgets (names are + taken from each widget's ``label``, falling back to ``"channel N"``). + + Examples: + ```python + import marimo as mo + from wigglystuff import MixPanel, Knob, Fader + + panel = mo.ui.anywidget(MixPanel({ + "gain": Knob(min_value=0, max_value=11, value=5, label="Gain"), + "level": Fader(min_value=-60, max_value=6, value=0, label="Level"), + }, title="Channel 1")) + panel + ``` + + Read the aggregated values back with ``panel.values`` -> + ``{"gain": 5.0, "level": 0.0}``. + """ + + _esm = _ESM_PATH + _css = _CSS_PATH + + controls = traitlets.List().tag(sync=True, to_json=widget_refs) + names = traitlets.List(traitlets.Unicode()).tag(sync=True) + values = traitlets.Dict().tag(sync=True) + title = traitlets.Unicode("").tag(sync=True) + width = traitlets.Int(0).tag(sync=True) + + def __init__( + self, + controls: Controls, + title: str = "", + width: int = 0, + **kwargs: Any, + ) -> None: + """Create a MixPanel. + + Args: + controls: Either a ``{name: widget}`` mapping or a list of control + widgets. With a list, each name comes from the widget's + ``label`` (falling back to ``"channel N"``). + title: Optional title shown above the rack. + width: Optional fixed panel width in pixels (0 = size to content). + **kwargs: Forwarded to ``anywidget.AnyWidget``. + """ + if isinstance(controls, Mapping): + names = [str(name) for name in controls.keys()] + widgets = list(controls.values()) + else: + widgets = list(controls) + names = [ + getattr(w, "label", "") or f"channel {i + 1}" + for i, w in enumerate(widgets) + ] + + if len(set(names)) != len(names): + raise ValueError(f"control names must be unique, got {names}.") + + self._widgets: List[Any] = widgets + self._names: List[str] = names + super().__init__( + controls=widgets, + names=names, + values={name: _child_value(w) for name, w in zip(names, widgets)}, + title=title, + width=width, + **kwargs, + ) + + # Aggregate each child's value into the combined dict. Done in Python so + # the {name: value} view works regardless of frontend host support. + for widget in widgets: + observed = _value_traits(widget) + if observed: + widget.observe(self._sync_values, names=observed) + + def _sync_values(self, _change: Dict[str, Any]) -> None: + self.values = { + name: _child_value(w) for name, w in zip(self._names, self._widgets) + } + + +def _value_traits(widget: Any) -> List[str]: + """Which traits carry this child's "value" (so we know what to observe).""" + if widget.has_trait("value"): + return ["value"] + return [t for t in ("x", "y") if widget.has_trait(t)] + + +def _child_value(widget: Any) -> Any: + """The child's current value: ``value`` if it has one, else an ``(x, y)`` pair.""" + if widget.has_trait("value"): + return widget.value + if widget.has_trait("x") and widget.has_trait("y"): + return (widget.x, widget.y) + return None diff --git a/wigglystuff/static/fader.css b/wigglystuff/static/fader.css new file mode 100644 index 00000000..a2f834d9 --- /dev/null +++ b/wigglystuff/static/fader.css @@ -0,0 +1,96 @@ +.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; +} diff --git a/wigglystuff/static/fader.js b/wigglystuff/static/fader.js new file mode 100644 index 00000000..43009c6b --- /dev/null +++ b/wigglystuff/static/fader.js @@ -0,0 +1,337 @@ +const SVG_NS = "http://www.w3.org/2000/svg"; + +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); + + 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; + const svgW = labelPos + (hasLabels ? 30 : 2); + 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; + const svgH = labelPos + (hasLabels ? 14 : 2); + 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"); + } + } + + 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); + + applyColor(); + updateTitle(); + updateGeometry(); + + return () => { + window.removeEventListener("mousemove", moveDrag); + window.removeEventListener("mouseup", endDrag); + window.removeEventListener("touchmove", moveDrag); + window.removeEventListener("touchend", endDrag); + }; +} + +export default { render }; diff --git a/wigglystuff/static/knob.css b/wigglystuff/static/knob.css new file mode 100644 index 00000000..c8c8d979 --- /dev/null +++ b/wigglystuff/static/knob.css @@ -0,0 +1,95 @@ +.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; +} diff --git a/wigglystuff/static/knob.js b/wigglystuff/static/knob.js new file mode 100644 index 00000000..74b60ade --- /dev/null +++ b/wigglystuff/static/knob.js @@ -0,0 +1,320 @@ +const SVG_NS = "http://www.w3.org/2000/svg"; +const DEG = Math.PI / 180; + +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); + + 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"); + } + } + + 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); + + applyColor(); + updateTitle(); + updateGeometry(); + + return () => { + window.removeEventListener("mousemove", moveDrag); + window.removeEventListener("mouseup", endDrag); + window.removeEventListener("touchmove", moveDrag); + window.removeEventListener("touchend", endDrag); + }; +} + +export default { render }; diff --git a/wigglystuff/static/mix-panel.css b/wigglystuff/static/mix-panel.css new file mode 100644 index 00000000..57c53971 --- /dev/null +++ b/wigglystuff/static/mix-panel.css @@ -0,0 +1,82 @@ +.mix-panel { + display: inline-flex; + flex-direction: column; + font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color-scheme: light dark; + padding: 10px; + border-radius: 10px; + + --mp-bg: #f3f4f6; + --mp-strip-bg: #ffffff; + --mp-border: #d1d5db; + --mp-text: #111827; + + background: var(--mp-bg); + border: 1px solid var(--mp-border); +} + +.dark .mix-panel, +.dark-theme .mix-panel, +[data-theme="dark"] .mix-panel { + --mp-bg: #111827; + --mp-strip-bg: #1f2937; + --mp-border: #374151; + --mp-text: #f1f5f9; +} + +.mix-panel-title { + font-size: 13px; + font-weight: 600; + color: var(--mp-text); + margin-bottom: 8px; + text-align: center; + letter-spacing: 0.02em; +} + +.mix-panel-strips { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 10px; + flex-wrap: wrap; +} + +.mix-panel-strip { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 6px; + border-radius: 8px; + background: var(--mp-strip-bg); + border: 1px solid var(--mp-border); +} + +.mix-panel-strip-name { + font-size: 11px; + font-weight: 600; + color: var(--mp-text); + opacity: 0.8; + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.mix-panel-mount { + display: flex; + align-items: center; + justify-content: center; +} + +.mix-panel-error { + font-size: 12px; + color: #b91c1c; + padding: 6px; + max-width: 220px; +} + +.dark .mix-panel-error, +.dark-theme .mix-panel-error, +[data-theme="dark"] .mix-panel-error { + color: #fca5a5; +} diff --git a/wigglystuff/static/mix-panel.js b/wigglystuff/static/mix-panel.js new file mode 100644 index 00000000..0a5a76fd --- /dev/null +++ b/wigglystuff/static/mix-panel.js @@ -0,0 +1,89 @@ +async function render({ model, el, host, signal }) { + el.innerHTML = ""; + + const root = document.createElement("div"); + root.className = "mix-panel"; + const width = model.get("width"); + if (width) root.style.width = width + "px"; + el.appendChild(root); + + let controller = null; + + function teardown() { + if (controller) { + controller.abort(); + controller = null; + } + } + + async function build() { + teardown(); + root.innerHTML = ""; + + const titleText = model.get("title"); + if (titleText) { + const h = document.createElement("div"); + h.className = "mix-panel-title"; + h.textContent = titleText; + root.appendChild(h); + } + + const strips = document.createElement("div"); + strips.className = "mix-panel-strips"; + root.appendChild(strips); + + const controls = model.get("controls") || []; + const names = model.get("names") || []; + + if (!host || typeof host.getWidget !== "function") { + const err = document.createElement("div"); + err.className = "mix-panel-error"; + err.textContent = + "MixPanel needs a host that supports anywidget composition (anywidget >= 0.11)."; + strips.appendChild(err); + return; + } + + controller = new AbortController(); + // Tear the children down when the parent view goes away. + if (signal) signal.addEventListener("abort", teardown, { once: true }); + const childSignal = controller.signal; + + for (let i = 0; i < controls.length; i++) { + const strip = document.createElement("div"); + strip.className = "mix-panel-strip"; + + const nameEl = document.createElement("div"); + nameEl.className = "mix-panel-strip-name"; + nameEl.textContent = names[i] ?? `channel ${i + 1}`; + strip.appendChild(nameEl); + + const mount = document.createElement("div"); + mount.className = "mix-panel-mount"; + strip.appendChild(mount); + strips.appendChild(strip); + + try { + const child = await host.getWidget(controls[i]); + if (childSignal.aborted) return; + await child.render({ el: mount, signal: childSignal }); + } catch (e) { + mount.classList.add("mix-panel-error"); + mount.textContent = "⚠ " + (e && e.message ? e.message : "failed to mount"); + } + } + } + + model.on("change:controls", build); + model.on("change:names", build); + model.on("change:title", build); + model.on("change:width", () => { + root.style.width = model.get("width") ? model.get("width") + "px" : ""; + }); + + await build(); + + return teardown; +} + +export default { render }; From dfc2826c657fc0e868075fb47cda504c5b16b9f5 Mon Sep 17 00:00:00 2001 From: koaning Date: Tue, 11 Aug 2026 17:14:51 +0200 Subject: [PATCH 2/3] midi1 --- demos/knob.py | 24 +++++++ wigglystuff/knob.py | 15 ++++ wigglystuff/static/knob.css | 49 +++++++++++++ wigglystuff/static/knob.js | 139 ++++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+) diff --git a/demos/knob.py b/demos/knob.py index d698b0b0..7f9a9b7e 100644 --- a/demos/knob.py +++ b/demos/knob.py @@ -87,6 +87,30 @@ def _(full, gain, mo, pan, wide): return +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## MIDI learn + + Click the **MIDI** button, then move a control on your hardware — the next + control-change message binds to the knob (like Ableton). Needs a Chromium + browser and a connected MIDI device; right-click the button to clear. + """) + return + + +@app.cell +def _(Knob, mo): + midi_knob1 = mo.ui.anywidget( + Knob(min_value=0, max_value=127, value=64, midi=True, label="MIDI") + ) + midi_knob2 = mo.ui.anywidget( + Knob(min_value=0, max_value=127, value=64, midi=True, label="MIDI") + ) + mo.hstack([midi_knob1, midi_knob2], justify="center") + return + + @app.cell def _(): import marimo as mo diff --git a/wigglystuff/knob.py b/wigglystuff/knob.py index 323e7b86..2fdecb34 100644 --- a/wigglystuff/knob.py +++ b/wigglystuff/knob.py @@ -54,6 +54,14 @@ class Knob(anywidget.AnyWidget): 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) + def __init__( self, value: Optional[float] = None, @@ -68,6 +76,7 @@ def __init__( label: str = "", show_value: bool = True, color: str = "", + midi: bool = False, **kwargs: Any, ) -> None: """Create a Knob. @@ -95,6 +104,11 @@ def __init__( 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``. **kwargs: Forwarded to ``anywidget.AnyWidget``. """ if step <= 0: @@ -138,5 +152,6 @@ def __init__( label=label, show_value=show_value, color=color, + midi=midi, **kwargs, ) diff --git a/wigglystuff/static/knob.css b/wigglystuff/static/knob.css index c8c8d979..1cbabe7f 100644 --- a/wigglystuff/static/knob.css +++ b/wigglystuff/static/knob.css @@ -93,3 +93,52 @@ 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 index 74b60ade..9cd2b203 100644 --- a/wigglystuff/static/knob.js +++ b/wigglystuff/static/knob.js @@ -1,6 +1,14 @@ 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("."); @@ -94,6 +102,11 @@ function render({ model, el }) { 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; @@ -292,6 +305,125 @@ function render({ model, el }) { } } + // --- MIDI (Ableton-style learn) ----------------------------------------- + let midiInputs = []; + let midiStateBound = false; + + 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(); + 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(); + }); + + 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(); + } + // If a binding was restored from Python, start listening immediately. + 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); @@ -304,16 +436,23 @@ function render({ model, el }) { 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(); }; } From 4b0000adeb026ac83da7a04e90486d77575bd50c Mon Sep 17 00:00:00 2001 From: koaning Date: Wed, 12 Aug 2026 10:21:30 +0200 Subject: [PATCH 3/3] Add Knob and Fader console widgets (0.5.25) New audio-console controls: - Knob: partial-arc rotary (configurable sweep up to a full circle), pointer, configurable tick scale, discrete `steps` detents, and Web MIDI learn. - Fader: vertical/horizontal mixing-console fader with a tick scale and `steps`. - MIDI learn (opt-in) with upfront `midi_cc`/`midi_channel` and localStorage persistence namespaced by the browser URL path. - Demos, reference docs, gallery entries, llms.txt, changelog, quick-ref table. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + CHANGELOG.md | 9 ++ README.md | 5 + demos/fader.py | 35 ++++- demos/knob.py | 10 +- demos/mix_panel.py | 72 ---------- docs/assets/gallery/fader.webp | Bin 0 -> 5944 bytes docs/assets/gallery/knob.webp | Bin 0 -> 7152 bytes docs/index.md | 10 ++ docs/llms.txt | 2 + docs/reference/fader.md | 59 +++++++++ docs/reference/index.md | 2 + docs/reference/knob.md | 61 +++++++++ pyproject.toml | 4 +- uv.lock | 4 +- wigglystuff/__init__.py | 2 - wigglystuff/_controls.py | 18 +-- wigglystuff/fader.py | 39 ++++++ wigglystuff/knob.py | 24 ++++ wigglystuff/mix_panel.py | 120 ----------------- wigglystuff/static/fader.css | 49 +++++++ wigglystuff/static/fader.js | 219 ++++++++++++++++++++++++++++++- wigglystuff/static/knob.js | 75 ++++++++++- wigglystuff/static/mix-panel.css | 82 ------------ wigglystuff/static/mix-panel.js | 89 ------------- zensical.toml | 2 + 26 files changed, 600 insertions(+), 394 deletions(-) delete mode 100644 demos/mix_panel.py create mode 100644 docs/assets/gallery/fader.webp create mode 100644 docs/assets/gallery/knob.webp create mode 100644 docs/reference/fader.md create mode 100644 docs/reference/knob.md delete mode 100644 wigglystuff/mix_panel.py delete mode 100644 wigglystuff/static/mix-panel.css delete mode 100644 wigglystuff/static/mix-panel.js diff --git a/AGENTS.md b/AGENTS.md index d58bdd38..cb45ddae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ syncs back to Python. | AnnotationWidget | `wigglystuff.annotation.AnnotationWidget` | `action`, `action_timestamp`, `note`, `listening`, `actions`, `keyboard_mapping`, `gamepad_mapping`, `debounce_ms`, `width` | Annotation input surface with buttons, keyboard, gamepad, and speech-to-text | | ApiDoc | `wigglystuff.api_doc.ApiDoc` | `doc`, `width`, `show_private` | Renders API docs for Python classes/functions | | Slider2D | `wigglystuff.slider2d.Slider2D` | `x`, `y`, `x_bounds`, `y_bounds`, `width`, `height` | 2D pointer for coupled parameters | +| Knob | `wigglystuff.knob.Knob` | `value`, `min_value`, `max_value`, `step`, `start_angle`, `end_angle`, `ticks`, `steps`, `size`, `label`, `show_value`, `color`, `midi`, `midi_cc`, `midi_channel`, `midi_device`, `midi_key`, `midi_scope` | Audio-panel rotary knob with configurable sweep, detents, and Web MIDI learn | +| Fader | `wigglystuff.fader.Fader` | `value`, `min_value`, `max_value`, `step`, `ticks`, `steps`, `orientation`, `length`, `label`, `show_value`, `color`, `midi`, `midi_cc`, `midi_channel`, `midi_device`, `midi_key`, `midi_scope` | Mixing-console fader with a configurable tick scale, detents, and Web MIDI learn | | BezierCurve | `wigglystuff.bezier_curve.BezierCurve` | `points`, `samples`, `x`, `y`, `t`, `closed`, `playing`, `loop`, `interval_ms`, `duration_ms`, `sync_throttle_ms`, `show_axes`, `n_samples`, `x_bounds`, `y_bounds`, `width`, `height` | Arbitrary-degree Bezier curve editor with draggable control points, playback, and optional axis ticks | | CurveEditor | `wigglystuff.curve_editor.CurveEditor` | `points`, `samples`, `x`, `y`, `t`, `curve`, `closed`, `playing`, `loop`, `tension`, `alpha`, `selected_index`, `show_axes`, `n_samples`, `x_bounds`, `y_bounds`, `width`, `height` | Chart-space curve editor with D3 line interpolators, path progress, and optional axis ticks | | ChartPuck | `wigglystuff.chart_puck.ChartPuck` | `x`, `y`, `x_bounds`, `y_bounds`, `axes_pixel_bounds`, `width`, `height`, `chart_base64`, `puck_radius`, `puck_color`, `throttle` | Draggable puck overlay for matplotlib charts | diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ac9e71..e2cf1db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [0.5.25] - 2026-08-12 + +### Added + +- New `Knob` widget: an audio-panel rotary knob. Partial-arc by default (configurable `start_angle`/`end_angle`, up to a gapless full 360° circle), with a pointer, a configurable tick scale (`ticks`), and discrete detents (`steps`) for a rotary selector. +- New `Fader` widget: a mixing-console fader (vertical or horizontal) with a configurable tick scale and the same `steps` detents. +- Both support optional Web MIDI "learn" (`midi=True`): click the button, move a hardware control, and the next control-change binds to the widget and drives its value. Bindings can be set upfront (`midi_cc`/`midi_channel`) and persist across restarts in browser localStorage, namespaced by the notebook URL path (override with `midi_key`/`midi_scope`). Web MIDI is Chromium-only and needs a secure context. +- Demos at `demos/knob.py` and `demos/fader.py`. + ## [0.5.24] - 2026-08-10 ### Fixed diff --git a/README.md b/README.md index fd4c9c5b..905df932 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ uv pip install wigglystuff Excalidraw

molab · API · MD +Knob

molab · API · MD +Fader

molab · API · MD + + + LiveEdit

molab · API · MD FramePlayer

molab · API · MD AsyncFlow

molab · API · MD diff --git a/demos/fader.py b/demos/fader.py index 4b56d01b..bf43c00f 100644 --- a/demos/fader.py +++ b/demos/fader.py @@ -2,7 +2,7 @@ # requires-python = ">=3.11" # dependencies = [ # "marimo", -# "wigglystuff==0.5.24", +# "wigglystuff==0.5.25", # ] # /// @@ -72,6 +72,39 @@ def _(crossfade, level, mo, send): return +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## MIDI learn + + Click the **MIDI** button, then move a control on your hardware — the next + control-change message binds to the fader (like Ableton). The binding is + remembered in your browser (keyed by the fader's `label`), so it survives a + kernel restart or cell re-run. Needs a Chromium browser and a connected MIDI + device; right-click the button to clear. + """) + return + + +@app.cell +def _(Fader, mo): + midi_fader = mo.ui.anywidget( + Fader(min_value=0, max_value=127, value=64, midi=True, label="MIDI") + ) + midi_fader + return (midi_fader,) + + +@app.cell(hide_code=True) +def _(midi_fader, mo): + mo.md(f""" + **Value:** `{midi_fader.value['value']:.0f}`   + **Bound CC:** `{midi_fader.value['midi_cc']}`   + **Device:** `{midi_fader.value['midi_device'] or '—'}` + """) + return + + @app.cell def _(): import marimo as mo diff --git a/demos/knob.py b/demos/knob.py index 7f9a9b7e..de2ae229 100644 --- a/demos/knob.py +++ b/demos/knob.py @@ -2,14 +2,14 @@ # requires-python = ">=3.11" # dependencies = [ # "marimo", -# "wigglystuff==0.5.24", +# "wigglystuff==0.5.25", # ] # /// import marimo __generated_with = "0.23.16" -app = marimo.App(width="medium") +app = marimo.App() @app.cell @@ -93,8 +93,10 @@ def _(mo): ## MIDI learn Click the **MIDI** button, then move a control on your hardware — the next - control-change message binds to the knob (like Ableton). Needs a Chromium - browser and a connected MIDI device; right-click the button to clear. + control-change message binds to the knob (like Ableton). The binding is + remembered in your browser (keyed by the knob's `label`), so it survives a + kernel restart or cell re-run. Needs a Chromium browser and a connected MIDI + device; right-click the button to clear. """) return diff --git a/demos/mix_panel.py b/demos/mix_panel.py deleted file mode 100644 index 8027b03f..00000000 --- a/demos/mix_panel.py +++ /dev/null @@ -1,72 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "marimo", -# "wigglystuff==0.5.24", -# ] -# /// - -import marimo - -__generated_with = "0.23.16" -app = marimo.App(width="medium") - - -@app.cell -def _(mo): - mo.md(""" - # MixPanel - - A rack of nested `Knob`/`Fader` widgets. The children are mounted *inside* - the panel via anywidget's widget-composition host (needs `anywidget>=0.11`). - Each child still syncs its own `value`, and the panel aggregates them into a - combined `values` dict. - """) - return - - -@app.cell -def _(mo): - from wigglystuff import MixPanel, Knob, Fader, Slider2D - - panel = mo.ui.anywidget( - MixPanel( - { - "gain": Knob(min_value=0, max_value=11, value=5, ticks=6, label="Gain"), - # A stepped rotary selector (discrete detents). - "mode": Knob( - steps=[(0, "Off"), (1, "Low"), (2, "Mid"), (3, "Hi")], - value=1, label="Mode", - ), - "level": Fader( - min_value=-60, max_value=6, value=0, - ticks=[(-60, "-60"), (-20, "-20"), (0, "0"), (6, "+6")], - label="Level", - ), - # A nested 2D slider — MixPanel aggregates its (x, y). - "xy": Slider2D(x=0.3, y=-0.2, width=90, height=90), - }, - title="Channel 1", - ) - ) - panel - return (panel,) - - -@app.cell(hide_code=True) -def _(mo, panel): - mo.md(f""" - **Combined values:** `{panel.value['values']}` - """) - return - - -@app.cell -def _(): - import marimo as mo - - return (mo,) - - -if __name__ == "__main__": - app.run() diff --git a/docs/assets/gallery/fader.webp b/docs/assets/gallery/fader.webp new file mode 100644 index 0000000000000000000000000000000000000000..db1f5f97b7310cad4f8324e25f41bd13d9fef384 GIT binary patch literal 5944 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 E14#3mB>(^b literal 0 HcmV?d00001 diff --git a/docs/assets/gallery/knob.webp b/docs/assets/gallery/knob.webp new file mode 100644 index 0000000000000000000000000000000000000000..99372cb2caac96735edab3e4c16acdda4957e695 GIT binary patch literal 7152 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 + +