├── imgs ├── matix.gif ├── stuff.png ├── probabl.png ├── slider2d.gif ├── tanglechoice.gif ├── tangleslider.gif └── calmcode-logo.webp ├── conductor.json ├── js ├── paint │ └── styles.css ├── copybutton │ └── widget.tsx ├── driver-tour │ ├── styles.css │ └── widget.js ├── talk │ └── widget.js ├── keystroke │ └── widget.js └── edgedraw.js ├── mkdocs ├── assets │ ├── logo.png │ ├── gallery │ │ ├── paint.png │ │ ├── celltour.png │ │ ├── edgedraw.png │ │ ├── gamepad.png │ │ ├── matrix.png │ │ ├── slider2d.png │ │ ├── keystroke.png │ │ ├── colorpicker.png │ │ ├── sortablelist.png │ │ ├── speechtotext.png │ │ ├── copytoclipboard.png │ │ └── webcam-capture.png │ └── stylesheets │ │ └── extra.css ├── reference │ ├── color-picker.md │ ├── copy-to-clipboard.md │ ├── paint.md │ ├── talk.md │ ├── sortable-list.md │ ├── edge-draw.md │ ├── slider2d.md │ ├── cell-tour.md │ ├── webcam-capture.md │ ├── index.md │ ├── gamepad.md │ ├── keystroke.md │ ├── matrix.md │ └── tangle.md └── index.md ├── tailwind.config.js ├── wigglystuff ├── static │ ├── colorpicker.js │ ├── edgedraw.css │ ├── talk-widget.css │ ├── tangle-select.js │ ├── tangle-choice.js │ ├── driver-tour.css │ ├── copybutton.css │ ├── tangle-slider.js │ ├── keystroke.css │ ├── keystroke-widget.js │ ├── 2dslider.js │ ├── talk-widget.js │ ├── matrix.css │ ├── webcam-capture.css │ ├── sortable-list.css │ ├── matrix.js │ ├── webcam-capture.js │ └── sortable-list.js ├── talk.py ├── keystroke.py ├── copy_to_clipboard.py ├── __init__.py ├── color_picker.py ├── gamepad.py ├── driver_tour.py ├── sortable_list.py ├── webcam_capture.py ├── slider2d.py ├── cell_tour.py ├── tangle.py ├── matrix.py ├── edge_draw.py └── paint.py ├── .github └── workflows │ └── tests.yml ├── package.json ├── demos ├── slider2d.py ├── shortcut.py ├── webcam_capture.py ├── sortlist.py ├── copytoclipboard.py ├── colorpicker.py ├── talk.py ├── keystroke.py ├── paint.py ├── matrix.py ├── edgedraw.py ├── celltour.py ├── drivertour.py └── gamepad.py ├── LICENSE ├── pyproject.toml ├── tests ├── test_imports.py └── test_edgedraw.py ├── Makefile ├── scripts └── export_marimo_demos.py ├── mkdocs.yml ├── README.md ├── agents.md ├── .gitignore ├── notebook.ipynb └── notebook.py /imgs/matix.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/imgs/matix.gif -------------------------------------------------------------------------------- /imgs/stuff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/imgs/stuff.png -------------------------------------------------------------------------------- /conductor.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "setup": "make install" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /imgs/probabl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/imgs/probabl.png -------------------------------------------------------------------------------- /js/paint/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /imgs/slider2d.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/imgs/slider2d.gif -------------------------------------------------------------------------------- /imgs/tanglechoice.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/imgs/tanglechoice.gif -------------------------------------------------------------------------------- /imgs/tangleslider.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/imgs/tangleslider.gif -------------------------------------------------------------------------------- /imgs/calmcode-logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/imgs/calmcode-logo.webp -------------------------------------------------------------------------------- /mkdocs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/logo.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/paint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/paint.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/celltour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/celltour.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/edgedraw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/edgedraw.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/gamepad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/gamepad.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/matrix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/matrix.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/slider2d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/slider2d.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/keystroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/keystroke.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/colorpicker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/colorpicker.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/sortablelist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/sortablelist.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/speechtotext.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/speechtotext.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/copytoclipboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/copytoclipboard.png -------------------------------------------------------------------------------- /mkdocs/assets/gallery/webcam-capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/wigglystuff/HEAD/mkdocs/assets/gallery/webcam-capture.png -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./js/paint/**/*.{js,jsx,ts,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [require("@tailwindcss/forms")], 7 | }; 8 | -------------------------------------------------------------------------------- /mkdocs/reference/color-picker.md: -------------------------------------------------------------------------------- 1 | # ColorPicker API 2 | 3 | ::: wigglystuff.color_picker.ColorPicker 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `color` | `str` | Hex color string (e.g., `#ff00aa`). | 10 | 11 | -------------------------------------------------------------------------------- /mkdocs/reference/copy-to-clipboard.md: -------------------------------------------------------------------------------- 1 | # CopyToClipboard API 2 | 3 | ::: wigglystuff.copy_to_clipboard.CopyToClipboard 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `text_to_copy` | `str` | Payload copied when the button is pressed. | 10 | 11 | -------------------------------------------------------------------------------- /mkdocs/reference/paint.md: -------------------------------------------------------------------------------- 1 | # Paint API 2 | 3 | ::: wigglystuff.paint.Paint 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `base64` | `str` | PNG data URL or raw base64 payload. | 10 | | `width` | `int` | Canvas width in pixels. | 11 | | `height` | `int` | Canvas height in pixels. | 12 | | `store_background` | `bool` | Persist strokes when background changes. | 13 | 14 | -------------------------------------------------------------------------------- /mkdocs/reference/talk.md: -------------------------------------------------------------------------------- 1 | # WebkitSpeechToTextWidget API 2 | 3 | ::: wigglystuff.talk.WebkitSpeechToTextWidget 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `transcript` | `str` | Latest transcript from the browser. | 10 | | `listening` | `bool` | Whether speech recognition is active. | 11 | | `trigger_listen` | `bool` | Toggle listening when set to true (auto-resets). | 12 | 13 | -------------------------------------------------------------------------------- /mkdocs/reference/sortable-list.md: -------------------------------------------------------------------------------- 1 | # SortableList API 2 | 3 | ::: wigglystuff.sortable_list.SortableList 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `value` | `list[str]` | Ordered list items. | 10 | | `addable` | `bool` | Allow inserting new items. | 11 | | `removable` | `bool` | Allow deleting items. | 12 | | `editable` | `bool` | Allow inline edits. | 13 | | `label` | `str` | Optional heading above the list. | 14 | 15 | -------------------------------------------------------------------------------- /wigglystuff/static/colorpicker.js: -------------------------------------------------------------------------------- 1 | function render({model, el}) { 2 | const ip = document.createElement('input'); 3 | ip.type = 'color'; 4 | ip.value = model.get('color'); 5 | 6 | ip.addEventListener('input', () => { 7 | model.set('color', ip.value); 8 | model.save_changes(); 9 | }); 10 | 11 | model.on('change:color', () => { 12 | ip.value = model.get('color'); 13 | }); 14 | 15 | el.appendChild(ip); 16 | } 17 | 18 | export default { render }; 19 | -------------------------------------------------------------------------------- /mkdocs/reference/edge-draw.md: -------------------------------------------------------------------------------- 1 | # EdgeDraw API 2 | 3 | ::: wigglystuff.edge_draw.EdgeDraw 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `names` | `list[str]` | Ordered node labels. | 10 | | `links` | `list[dict]` | Link dicts with `source` and `target` keys. | 11 | | `directed` | `bool` | Draw directed edges when true. | 12 | | `width` | `int` | Canvas width in pixels. | 13 | | `height` | `int` | Canvas height in pixels. | 14 | 15 | -------------------------------------------------------------------------------- /mkdocs/reference/slider2d.md: -------------------------------------------------------------------------------- 1 | # Slider2D API 2 | 3 | ::: wigglystuff.slider2d.Slider2D 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `x` | `float` | Current x position. | 10 | | `y` | `float` | Current y position. | 11 | | `x_bounds` | `tuple[float, float]` | Min/max x bounds. | 12 | | `y_bounds` | `tuple[float, float]` | Min/max y bounds. | 13 | | `width` | `int` | Canvas width in pixels. | 14 | | `height` | `int` | Canvas height in pixels. | 15 | 16 | -------------------------------------------------------------------------------- /mkdocs/reference/cell-tour.md: -------------------------------------------------------------------------------- 1 | # CellTour API 2 | 3 | ::: wigglystuff.cell_tour.CellTour 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `steps` | `list[dict]` | DriverTour-style steps (CellTour inputs are normalized). | 10 | | `auto_start` | `bool` | Start tour automatically on render. | 11 | | `show_progress` | `bool` | Show progress indicator when true. | 12 | | `active` | `bool` | Whether the tour is currently running. | 13 | | `current_step` | `int` | Index of the active step. | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.11" 20 | - name: Pytest 21 | run: | 22 | python -m pip install --upgrade uv 23 | uv venv 24 | uv pip install -e ".[test]" 25 | uv run pytest 26 | -------------------------------------------------------------------------------- /mkdocs/reference/webcam-capture.md: -------------------------------------------------------------------------------- 1 | # WebcamCapture API 2 | 3 | ::: wigglystuff.webcam_capture.WebcamCapture 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `image_base64` | `str` | PNG data URL for the latest frame. | 10 | | `capturing` | `bool` | Enable auto-capture mode. | 11 | | `interval_ms` | `int` | Auto-capture interval in milliseconds. | 12 | | `facing_mode` | `str` | Camera facing mode ("user" or "environment"). | 13 | | `ready` | `bool` | True when the preview stream is ready. | 14 | | `error` | `str` | Error message when webcam access fails. | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wigglystuff", 3 | "scripts": { 4 | "dev-copy-btn": "./node_modules/.bin/esbuild js/copybutton/widget.tsx --bundle --outfile=wigglystuff/static/copybutton.js --format=esm --watch --minify" 5 | }, 6 | "dependencies": { 7 | "@anywidget/react": "^0.1.0", 8 | "@radix-ui/react-icons": "^1.3.2", 9 | "@radix-ui/themes": "^3.2.1", 10 | "driver.js": "^1.3.1", 11 | "esbuild": "^0.25.1", 12 | "radix-ui": "^1.1.3", 13 | "react": "^19.0.0" 14 | }, 15 | "devDependencies": { 16 | "@tailwindcss/forms": "^0.5.10", 17 | "autoprefixer": "^10.4.22", 18 | "postcss": "^8.5.6", 19 | "tailwindcss": "^3.4.18" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /mkdocs/reference/index.md: -------------------------------------------------------------------------------- 1 | # API Overview 2 | 3 | Browse widget-specific reference pages below. Each page is generated automatically via mkdocstrings, so docstrings and trait metadata from the source stay in sync with every release. 4 | 5 | - [Slider2D](slider2d.md) 6 | - [Matrix](matrix.md) 7 | - [SortableList](sortable-list.md) 8 | - [Paint](paint.md) 9 | - [EdgeDraw](edge-draw.md) 10 | - [KeystrokeWidget](keystroke.md) 11 | - [WebkitSpeechToTextWidget](talk.md) 12 | - [ColorPicker](color-picker.md) 13 | - [CopyToClipboard](copy-to-clipboard.md) 14 | - [Tangle widgets](tangle.md) 15 | - [GamepadWidget](gamepad.md) 16 | - [CellTour](cell-tour.md) 17 | - [WebcamCapture](webcam-capture.md) 18 | -------------------------------------------------------------------------------- /mkdocs/reference/gamepad.md: -------------------------------------------------------------------------------- 1 | # GamepadWidget API 2 | 3 | ::: wigglystuff.gamepad.GamepadWidget 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `current_button_press` | `int` | Index of the most recently pressed button. | 10 | | `current_timestamp` | `float` | Timestamp (ms since epoch) of the latest press. | 11 | | `previous_timestamp` | `float` | Timestamp of the previous press. | 12 | | `axes` | `list[float]` | Analog stick positions (4 values). | 13 | | `dpad_up` | `bool` | D-pad up state. | 14 | | `dpad_down` | `bool` | D-pad down state. | 15 | | `dpad_left` | `bool` | D-pad left state. | 16 | | `dpad_right` | `bool` | D-pad right state. | 17 | | `button_id` | `int` | Reserved for custom mappings (not set by the default UI). | 18 | 19 | -------------------------------------------------------------------------------- /mkdocs/reference/keystroke.md: -------------------------------------------------------------------------------- 1 | # KeystrokeWidget API 2 | 3 | ::: wigglystuff.keystroke.KeystrokeWidget 4 | 5 | ## Synced traitlets 6 | 7 | `last_key` is a dictionary synced from the browser after each keypress. When no 8 | keypress has been captured yet, it is an empty dict. 9 | 10 | | Key | Type | Notes | 11 | | --- | --- | --- | 12 | | `key` | `str` | Display value for the key (e.g., `a`, `Enter`). | 13 | | `code` | `str` | Physical key code (e.g., `KeyA`, `Enter`). | 14 | | `ctrlKey` | `bool` | `True` when Control is held. | 15 | | `shiftKey` | `bool` | `True` when Shift is held. | 16 | | `altKey` | `bool` | `True` when Alt/Option is held. | 17 | | `metaKey` | `bool` | `True` when Command/Meta is held. | 18 | | `timestamp` | `int` | Milliseconds since epoch at capture time. | 19 | 20 | -------------------------------------------------------------------------------- /mkdocs/reference/matrix.md: -------------------------------------------------------------------------------- 1 | # Matrix API 2 | 3 | ::: wigglystuff.matrix.Matrix 4 | 5 | ## Synced traitlets 6 | 7 | | Traitlet | Type | Notes | 8 | | --- | --- | --- | 9 | | `matrix` | `list[list[float]]` | Cell values. | 10 | | `rows` | `int` | Row count. | 11 | | `cols` | `int` | Column count. | 12 | | `min_value` | `float` | Minimum allowed value. | 13 | | `max_value` | `float` | Maximum allowed value. | 14 | | `mirror` | `bool` | Mirror edits across the diagonal when enabled. | 15 | | `step` | `float` | Step size for edits. | 16 | | `digits` | `int` | Decimal precision for display. | 17 | | `row_names` | `list[str]` | Optional row labels. | 18 | | `col_names` | `list[str]` | Optional column labels. | 19 | | `static` | `bool` | Disable editing when true. | 20 | | `flexible_cols` | `bool` | Allow column count changes interactively. | 21 | 22 | -------------------------------------------------------------------------------- /demos/slider2d.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.2" 4 | app = marimo.App(width="medium") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | from wigglystuff import Slider2D 11 | 12 | widget = mo.ui.anywidget( 13 | Slider2D( 14 | width=320, 15 | height=320, 16 | x_bounds=(-2.0, 2.0), 17 | y_bounds=(-1.0, 1.5), 18 | ) 19 | ) 20 | return mo, widget 21 | 22 | 23 | @app.cell 24 | def _(widget): 25 | widget 26 | return 27 | 28 | 29 | @app.cell 30 | def _(mo, widget): 31 | mo.callout( 32 | f"x = {widget.x:.3f}, y = {widget.y:.3f}; bounds {widget.x_bounds} / {widget.y_bounds}" 33 | ) 34 | return 35 | 36 | 37 | @app.cell 38 | def _(): 39 | return 40 | 41 | 42 | if __name__ == "__main__": 43 | app.run() 44 | -------------------------------------------------------------------------------- /js/copybutton/widget.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createRender, useModelState } from "@anywidget/react"; 3 | import { CopyIcon } from "@radix-ui/react-icons"; 4 | 5 | function copyToClipboard(text: string) { 6 | navigator.clipboard.writeText(text); 7 | } 8 | 9 | function CopyToClipboardButton() { 10 | let [text_to_copy, setTextToCopy] = useModelState("text_to_copy"); 11 | 12 | return
13 | 21 |
22 | } 23 | 24 | const render = createRender(CopyToClipboardButton); 25 | 26 | export default { render }; -------------------------------------------------------------------------------- /wigglystuff/talk.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import anywidget 4 | import traitlets 5 | 6 | 7 | class WebkitSpeechToTextWidget(anywidget.AnyWidget): 8 | """Speech-to-text widget backed by the browser's Webkit Speech API. 9 | 10 | The widget exposes the ``transcript`` text along with the ``listening`` and 11 | ``trigger_listen`` booleans; it does not require initialization arguments. 12 | 13 | Examples: 14 | ```python 15 | speech = WebkitSpeechToTextWidget() 16 | speech 17 | ``` 18 | """ 19 | 20 | transcript = traitlets.Unicode("").tag(sync=True) 21 | listening = traitlets.Bool(False).tag(sync=True) 22 | trigger_listen = traitlets.Bool(False).tag(sync=True) 23 | _esm = Path(__file__).parent / "static" / "talk-widget.js" 24 | _css = Path(__file__).parent / "static" / "talk-widget.css" 25 | -------------------------------------------------------------------------------- /demos/shortcut.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.12" 3 | # dependencies = [ 4 | # "altair==5.5.0", 5 | # "marimo", 6 | # "matplotlib==3.10.1", 7 | # "moboard==0.1.0", 8 | # "mohtml==0.1.7", 9 | # "numpy==2.2.5", 10 | # "polars==1.29.0", 11 | # ] 12 | # /// 13 | 14 | import marimo 15 | 16 | __generated_with = "0.17.8" 17 | app = marimo.App() 18 | 19 | 20 | @app.cell 21 | def _(): 22 | import marimo as mo 23 | from wigglystuff import KeystrokeWidget 24 | return KeystrokeWidget, mo 25 | 26 | 27 | @app.cell 28 | def _(KeystrokeWidget, mo): 29 | widget = mo.ui.anywidget(KeystrokeWidget()) 30 | widget 31 | return (widget,) 32 | 33 | 34 | @app.cell 35 | def _(widget): 36 | widget.value 37 | return 38 | 39 | 40 | @app.cell 41 | def _(): 42 | return 43 | 44 | 45 | if __name__ == "__main__": 46 | app.run() 47 | -------------------------------------------------------------------------------- /wigglystuff/keystroke.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import anywidget 4 | import traitlets 5 | 6 | 7 | class KeystrokeWidget(anywidget.AnyWidget): 8 | """Capture the latest keyboard shortcut pressed inside the widget. 9 | 10 | No initialization arguments are required; the widget simply records 11 | keystrokes into the ``last_key`` trait. 12 | 13 | The ``last_key`` payload mirrors browser ``KeyboardEvent`` data with: 14 | ``key``, ``code``, modifier booleans (``ctrlKey``, ``shiftKey``, 15 | ``altKey``, ``metaKey``), and a ``timestamp`` in milliseconds since epoch. 16 | 17 | Examples: 18 | ```python 19 | keystroke = KeystrokeWidget() 20 | keystroke 21 | ``` 22 | """ 23 | 24 | _esm = Path(__file__).parent / "static" / "keystroke-widget.js" 25 | _css = Path(__file__).parent / "static" / "keystroke.css" 26 | last_key = traitlets.Dict(default_value={}).tag(sync=True) 27 | -------------------------------------------------------------------------------- /demos/webcam_capture.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.17.8" 4 | app = marimo.App(width="medium", sql_output="polars") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | from mohtml import div, img, tailwind_css 11 | from wigglystuff import WebcamCapture 12 | 13 | tailwind_css() 14 | return WebcamCapture, div, img, mo 15 | 16 | 17 | @app.cell 18 | def _(WebcamCapture, mo): 19 | widget = mo.ui.anywidget(WebcamCapture(interval_ms=1000)) 20 | return (widget,) 21 | 22 | 23 | @app.cell 24 | def _(widget): 25 | widget 26 | return 27 | 28 | 29 | @app.cell 30 | def _(div, img, widget): 31 | div( 32 | img(src=widget.image_base64), 33 | klass="bg-slate-100 border border-slate-200 rounded-2xl p-4", 34 | ) 35 | return 36 | 37 | 38 | @app.cell 39 | def _(widget): 40 | widget.get_pil() 41 | return 42 | 43 | 44 | if __name__ == "__main__": 45 | app.run() 46 | -------------------------------------------------------------------------------- /wigglystuff/copy_to_clipboard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | import anywidget 5 | import traitlets 6 | 7 | 8 | class CopyToClipboard(anywidget.AnyWidget): 9 | """Button widget that copies the provided ``text_to_copy`` payload. 10 | 11 | Examples: 12 | ```python 13 | button = CopyToClipboard(text_to_copy="Hello, world!") 14 | button 15 | ``` 16 | """ 17 | 18 | text_to_copy = traitlets.Unicode("").tag(sync=True) 19 | _esm = Path(__file__).parent / "static" / "copybutton.js" 20 | _css = Path(__file__).parent / "static" / "copybutton.css" 21 | 22 | def __init__(self, text_to_copy: str = "", **kwargs: Any): 23 | """Create a CopyToClipboard button. 24 | 25 | Args: 26 | text_to_copy: Initial string placed on the clipboard when clicked. 27 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 28 | """ 29 | super().__init__(**kwargs) 30 | self.text_to_copy = text_to_copy 31 | -------------------------------------------------------------------------------- /demos/sortlist.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.1" 4 | app = marimo.App(width="columns", sql_output="polars") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | from wigglystuff import SortableList 11 | return SortableList, mo 12 | 13 | 14 | @app.cell(hide_code=True) 15 | def _(mo): 16 | mo.md(r""" 17 | ## `SortableList` 18 | 19 | This widget lets you maintain a list that you can sort around. 20 | """) 21 | return 22 | 23 | 24 | @app.cell 25 | def _(SortableList, mo): 26 | widget = mo.ui.anywidget( 27 | SortableList( 28 | ["a", "b", "c"], 29 | editable=True, 30 | addable=True, 31 | removable=True, 32 | label="My Sortable List" 33 | ) 34 | 35 | ) 36 | widget 37 | return (widget,) 38 | 39 | 40 | @app.cell 41 | def _(widget): 42 | widget.value.get("value") 43 | return 44 | 45 | 46 | @app.cell 47 | def _(): 48 | return 49 | 50 | 51 | if __name__ == "__main__": 52 | app.run() 53 | -------------------------------------------------------------------------------- /wigglystuff/__init__.py: -------------------------------------------------------------------------------- 1 | """Public widget exports for the wigglystuff package.""" 2 | 3 | from .cell_tour import CellTour 4 | from .color_picker import ColorPicker 5 | from .copy_to_clipboard import CopyToClipboard 6 | from .driver_tour import DriverTour 7 | from .edge_draw import EdgeDraw 8 | from .gamepad import GamepadWidget 9 | from .keystroke import KeystrokeWidget 10 | from .matrix import Matrix 11 | from .paint import Paint 12 | from .slider2d import Slider2D 13 | from .sortable_list import SortableList 14 | from .tangle import TangleChoice, TangleSelect, TangleSlider 15 | from .talk import WebkitSpeechToTextWidget 16 | from .webcam_capture import WebcamCapture 17 | 18 | __all__ = [ 19 | "CellTour", 20 | "ColorPicker", 21 | "CopyToClipboard", 22 | "DriverTour", 23 | "EdgeDraw", 24 | "GamepadWidget", 25 | "KeystrokeWidget", 26 | "Matrix", 27 | "Paint", 28 | "Slider2D", 29 | "SortableList", 30 | "TangleChoice", 31 | "TangleSelect", 32 | "TangleSlider", 33 | "WebkitSpeechToTextWidget", 34 | "WebcamCapture", 35 | ] 36 | -------------------------------------------------------------------------------- /wigglystuff/color_picker.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Optional 3 | 4 | import anywidget 5 | import traitlets 6 | 7 | 8 | class ColorPicker(anywidget.AnyWidget): 9 | """Simple color picker syncing a ``#RRGGBB`` hex value back to Python. 10 | 11 | Examples: 12 | ```python 13 | picker = ColorPicker(color="#ff5733") 14 | picker 15 | ``` 16 | """ 17 | 18 | _esm = Path(__file__).parent / "static" / "colorpicker.js" 19 | color = traitlets.Unicode("#000000").tag(sync=True) 20 | 21 | def __init__(self, *, color: Optional[str] = None, **kwargs: Any): 22 | """Create a ColorPicker widget. 23 | 24 | Args: 25 | color: Optional starting hex color (e.g. ``"#ff00aa"``). 26 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 27 | """ 28 | if color is not None: 29 | kwargs["color"] = color 30 | super().__init__(**kwargs) 31 | 32 | @property 33 | def rgb(self): 34 | return tuple(int(self.color[i : i + 2], 16) for i in (1, 3, 5)) 35 | -------------------------------------------------------------------------------- /demos/copytoclipboard.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.1" 4 | app = marimo.App(width="medium") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | from wigglystuff import CopyToClipboard 11 | 12 | default_snippet = "pip install wigglystuff" 13 | widget = mo.ui.anywidget(CopyToClipboard(text_to_copy=default_snippet)) 14 | editor = mo.ui.text_area(label="Text to copy", value=default_snippet) 15 | return editor, mo, widget 16 | 17 | 18 | @app.cell 19 | def _(widget): 20 | widget 21 | return 22 | 23 | 24 | @app.cell 25 | def _(editor): 26 | editor 27 | return 28 | 29 | 30 | @app.cell 31 | def _(editor, widget): 32 | widget.text_to_copy = editor.value 33 | return 34 | 35 | 36 | @app.cell 37 | def _(mo, widget): 38 | preview = widget.text_to_copy 39 | truncated = preview if len(preview) < 80 else preview[:77] + "..." 40 | 41 | mo.callout("Click the button to copy the payload below:") 42 | mo.md(f"```text\n{truncated}\n```") 43 | return 44 | 45 | 46 | if __name__ == "__main__": 47 | app.run() 48 | -------------------------------------------------------------------------------- /mkdocs/reference/tangle.md: -------------------------------------------------------------------------------- 1 | # Tangle Widgets API 2 | 3 | ## TangleSlider 4 | 5 | ::: wigglystuff.tangle.TangleSlider 6 | 7 | ### Synced traitlets 8 | 9 | | Traitlet | Type | Notes | 10 | | --- | --- | --- | 11 | | `amount` | `float` | Current value. | 12 | | `min_value` | `float` | Lower bound. | 13 | | `max_value` | `float` | Upper bound. | 14 | | `step` | `float` | Step size. | 15 | | `pixels_per_step` | `int` | Drag distance per step. | 16 | | `prefix` | `str` | Text before the value. | 17 | | `suffix` | `str` | Text after the value. | 18 | | `digits` | `int` | Decimal precision for display. | 19 | 20 | 21 | ## TangleChoice 22 | 23 | ::: wigglystuff.tangle.TangleChoice 24 | 25 | ### Synced traitlets 26 | 27 | | Traitlet | Type | Notes | 28 | | --- | --- | --- | 29 | | `choice` | `str` | Current selection. | 30 | | `choices` | `list[str]` | Available options. | 31 | 32 | 33 | ## TangleSelect 34 | 35 | ::: wigglystuff.tangle.TangleSelect 36 | 37 | ### Synced traitlets 38 | 39 | | Traitlet | Type | Notes | 40 | | --- | --- | --- | 41 | | `choice` | `str` | Current selection. | 42 | | `choices` | `list[str]` | Available options. | 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vincent D. Warmerdam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /wigglystuff/static/edgedraw.css: -------------------------------------------------------------------------------- 1 | .matrix-container.edgedraw { 2 | --edgedraw-node-fill: #69b3a2; 3 | --edgedraw-link-color: #4a4a4a; 4 | --edgedraw-label-color: #1d1d1d; 5 | color: #1d1d1d; 6 | color-scheme: light dark; 7 | } 8 | 9 | :where(.dark, .dark-theme, [data-theme="dark"], body.dark, body[data-theme="dark"]) .matrix-container.edgedraw { 10 | --edgedraw-node-fill: #6dddb9; 11 | --edgedraw-link-color: #e0e0e0; 12 | --edgedraw-label-color: #fefefe; 13 | color: #fefefe; 14 | } 15 | 16 | .node { 17 | cursor: pointer; 18 | fill: var(--edgedraw-node-fill); 19 | } 20 | 21 | .link { 22 | stroke: var(--edgedraw-link-color); 23 | stroke-opacity: 0.8; 24 | stroke-width: 3px; 25 | cursor: pointer; 26 | } 27 | 28 | .arrow { 29 | fill: var(--edgedraw-link-color); 30 | } 31 | 32 | .label { 33 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 34 | font-weight: 500; 35 | dominant-baseline: middle; 36 | fill: var(--edgedraw-label-color, currentColor); 37 | } 38 | 39 | .selected { 40 | stroke: #ff6b6b; 41 | stroke-width: 2px; 42 | } 43 | -------------------------------------------------------------------------------- /wigglystuff/gamepad.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import anywidget 4 | import traitlets 5 | 6 | 7 | class GamepadWidget(anywidget.AnyWidget): 8 | """Listen to browser gamepad events and sync state back to Python. 9 | 10 | This widget does not require any initialization arguments; all state is 11 | mirrored through traitlets such as ``axes`` and ``current_button_press``. 12 | 13 | Examples: 14 | ```python 15 | gamepad = GamepadWidget() 16 | gamepad 17 | ``` 18 | """ 19 | 20 | _esm = Path(__file__).parent / "static" / "gamepad-widget.js" 21 | current_button_press = traitlets.Int(-1).tag(sync=True) 22 | current_timestamp = traitlets.Float(0.0).tag(sync=True) 23 | previous_timestamp = traitlets.Float(0.0).tag(sync=True) 24 | axes = traitlets.List(trait=traitlets.Float(), default_value=[0.0, 0.0, 0.0, 0.0]).tag( 25 | sync=True 26 | ) 27 | dpad_up = traitlets.Bool(False).tag(sync=True) 28 | dpad_down = traitlets.Bool(False).tag(sync=True) 29 | dpad_left = traitlets.Bool(False).tag(sync=True) 30 | dpad_right = traitlets.Bool(False).tag(sync=True) 31 | button_id = traitlets.Int(0).tag(sync=True) 32 | -------------------------------------------------------------------------------- /demos/colorpicker.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.2" 4 | app = marimo.App(width="medium") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | from wigglystuff import ColorPicker 11 | 12 | picker = mo.ui.anywidget(ColorPicker(color="#0ea5e9")) 13 | return mo, picker 14 | 15 | 16 | @app.cell 17 | def _(picker): 18 | picker 19 | return 20 | 21 | 22 | @app.cell 23 | def _(mo, picker): 24 | r, g, b = picker.rgb 25 | 26 | mo.vstack( 27 | [ 28 | mo.md(f"You picked **{picker.color}** which is **RGB {r}, {g}, {b}**."), 29 | mo.md( 30 | f"
" 32 | ), 33 | ] 34 | ) 35 | return 36 | 37 | 38 | @app.cell 39 | def _(mo, picker): 40 | import random 41 | 42 | 43 | def randomize(_): 44 | picker.color = f"#{random.randint(0, 0xFFFFFF):06x}" 45 | 46 | 47 | mo.ui.button(label="Surprise me", on_click=randomize) 48 | return 49 | 50 | 51 | @app.cell 52 | def _(): 53 | return 54 | 55 | 56 | if __name__ == "__main__": 57 | app.run() 58 | -------------------------------------------------------------------------------- /wigglystuff/static/talk-widget.css: -------------------------------------------------------------------------------- 1 | .speech-container { 2 | padding: 16px; 3 | border: 1px solid #ccc; 4 | border-radius: 8px; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; 6 | background: #f8f9fa; 7 | max-width: 500px; 8 | text-align: center; 9 | } 10 | 11 | .transcript-display { 12 | font-size: 16px; 13 | margin: 16px 0; 14 | padding: 12px; 15 | min-height: 100px; 16 | border: 1px solid #ddd; 17 | border-radius: 4px; 18 | background: #fff; 19 | text-align: left; 20 | overflow-y: auto; 21 | color: #212529; 22 | } 23 | 24 | .speech-button { 25 | padding: 10px 20px; 26 | background: #4285f4; 27 | color: #fff; 28 | border: none; 29 | border-radius: 4px; 30 | font-size: 16px; 31 | cursor: pointer; 32 | transition: background 0.2s ease; 33 | } 34 | 35 | .speech-button:hover { 36 | background: #2b6cb0; 37 | } 38 | 39 | .speech-button:active { 40 | transform: scale(0.98); 41 | } 42 | 43 | .speech-button:disabled { 44 | background: #cccccc; 45 | cursor: not-allowed; 46 | } 47 | 48 | .status-indicator { 49 | font-size: 14px; 50 | color: #666; 51 | margin-bottom: 8px; 52 | } 53 | 54 | .status-indicator.active { 55 | color: #d23; 56 | font-weight: 600; 57 | } 58 | -------------------------------------------------------------------------------- /wigglystuff/static/tangle-select.js: -------------------------------------------------------------------------------- 1 | function render({model, el}) { 2 | const choices = model.get("choices"); 3 | let currentIndex = choices.indexOf(model.get("choice")); 4 | if (currentIndex === -1) currentIndex = 0; 5 | 6 | const container = document.createElement('div'); 7 | container.classList.add("tangle-container"); 8 | el.style.display = "inline-flex"; 9 | 10 | el.appendChild(container); 11 | 12 | // Create a basic select element - keep it simple 13 | const select = document.createElement('select'); 14 | select.style.color = '#0066cc'; 15 | 16 | // Add options to the select element 17 | choices.forEach((choice, index) => { 18 | const option = document.createElement('option'); 19 | option.value = choice; 20 | option.textContent = choice; 21 | option.selected = index === currentIndex; 22 | select.appendChild(option); 23 | }); 24 | 25 | // Add event listener for selection change 26 | select.addEventListener('change', function() { 27 | currentIndex = this.selectedIndex; 28 | model.set("choice", choices[currentIndex]); 29 | model.save_changes(); 30 | }); 31 | 32 | container.appendChild(select); 33 | 34 | } 35 | 36 | export default { render }; 37 | -------------------------------------------------------------------------------- /wigglystuff/static/tangle-choice.js: -------------------------------------------------------------------------------- 1 | function render({model, el}) { 2 | const choices = model.get("choices"); 3 | let currentIndex = choices.indexOf(model.get("value")); 4 | if (currentIndex === -1) currentIndex = 0; 5 | 6 | const container = document.createElement('div'); 7 | container.classList.add("tangle-container"); 8 | el.style.display = "inline-flex"; 9 | 10 | el.appendChild(container); 11 | 12 | function renderValue() { 13 | container.innerHTML = ''; 14 | const element = document.createElement('span'); 15 | element.className = 'tangle-value'; 16 | element.style.color = '#0066cc'; 17 | element.style.textDecoration = 'underline'; 18 | element.style.cursor = 'pointer'; 19 | element.textContent = choices[currentIndex]; 20 | element.addEventListener('click', cycleChoice); 21 | container.appendChild(element); 22 | } 23 | 24 | function cycleChoice() { 25 | currentIndex = (currentIndex + 1) % choices.length; 26 | renderValue(); 27 | updateModel(); 28 | } 29 | 30 | function updateModel() { 31 | model.set("choice", choices[currentIndex]); 32 | model.save_changes(); 33 | } 34 | 35 | renderValue(); 36 | updateModel(); 37 | } 38 | 39 | export default { render }; 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wigglystuff" 3 | version = "0.2.6" 4 | description = "Collection of Anywidget Widgets" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | license = { file = "LICENSE" } 8 | authors = [{ name = "Vincent D. Warmerdam" }] 9 | classifiers = [ 10 | "Intended Audience :: Developers", 11 | "Intended Audience :: Science/Research", 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python :: 3", 14 | "Topic :: Scientific/Engineering", 15 | ] 16 | dependencies = [ 17 | "anywidget>=0.9.2", 18 | "numpy", 19 | "pillow", 20 | ] 21 | 22 | [project.optional-dependencies] 23 | test = [ 24 | "pytest>=8.3.3", 25 | ] 26 | docs = [ 27 | "altair>=6.0.0", 28 | "black>=24.8.0", 29 | "marimo>=0.18.0", 30 | "mike>=2.1.0", 31 | "mkdocs-git-revision-date-localized-plugin>=1.2.6", 32 | "mkdocs-include-markdown-plugin>=6.2.1", 33 | "mkdocs-jupyter>=0.25.0", 34 | "mkdocs-material>=9.5.0", 35 | "mkdocs-section-index>=0.3.6", 36 | "mkdocstrings[python]>=0.25.1", 37 | "mohtml>=0.1.11", 38 | "pandas>=2.3.3", 39 | "polars>=1.36.1", 40 | ] 41 | 42 | [build-system] 43 | requires = ["hatchling"] 44 | build-backend = "hatchling.build" 45 | 46 | [tool.hatch.build] 47 | only-packages = true 48 | artifacts = ["wigglystuff/static/*"] 49 | -------------------------------------------------------------------------------- /demos/talk.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.17.8" 4 | app = marimo.App(width="columns") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | return (mo,) 11 | 12 | 13 | @app.cell(hide_code=True) 14 | def _(mo): 15 | mo.md(r""" 16 | ## Speech to Text 17 | 18 | You can use the free webkit API in your browser to transcribe audio live! Not every browser may support this but it is a very easy way to talk and pass the transcription to Python. 19 | """) 20 | return 21 | 22 | 23 | @app.cell 24 | def _(mo): 25 | from wigglystuff import WebkitSpeechToTextWidget 26 | 27 | speech_widget = mo.ui.anywidget(WebkitSpeechToTextWidget()) 28 | speech_widget 29 | return (speech_widget,) 30 | 31 | 32 | @app.cell 33 | def _(speech_widget): 34 | speech_widget.transcript 35 | return 36 | 37 | 38 | @app.cell(hide_code=True) 39 | def _(mo): 40 | mo.md(r""" 41 | You can also choose to trigger the recording from Python. Use the following two buttons. 42 | """) 43 | return 44 | 45 | 46 | @app.cell 47 | def _(mo, speech_widget): 48 | def record(boolean): 49 | def inner(_): 50 | speech_widget.listening = boolean 51 | 52 | return inner 53 | 54 | btn_start = mo.ui.button(label="Start Recording", on_click=record(True)) 55 | btn_stop = mo.ui.button(label="End Recording", on_click=record(False)) 56 | 57 | mo.hstack([btn_start, btn_stop]) 58 | return 59 | 60 | 61 | if __name__ == "__main__": 62 | app.run() 63 | -------------------------------------------------------------------------------- /demos/keystroke.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.1" 4 | app = marimo.App(width="medium") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | 11 | from wigglystuff import KeystrokeWidget 12 | 13 | listener = mo.ui.anywidget(KeystrokeWidget()) 14 | return listener, mo 15 | 16 | 17 | @app.cell 18 | def _(mo): 19 | mo.md(r""" 20 | ## Capture shortcuts from the browser 21 | 22 | Click the widget, then press any key combination (e.g. `Ctrl + Shift + P`). 23 | The latest shortcut is synced to Python through the `last_key` trait. 24 | """) 25 | return 26 | 27 | 28 | @app.cell 29 | def _(listener): 30 | listener 31 | return 32 | 33 | 34 | @app.cell 35 | def _(listener, mo): 36 | info = listener.last_key or {} 37 | 38 | modifiers = [ 39 | label 40 | for key, label in [ 41 | ("ctrlKey", "Ctrl"), 42 | ("shiftKey", "Shift"), 43 | ("altKey", "Alt"), 44 | ("metaKey", "Meta"), 45 | ] 46 | if info.get(key) 47 | ] 48 | 49 | shortcut = " + ".join(modifiers + [info.get("key", "—")]).strip(" + ") 50 | rows = { 51 | "Shortcut": shortcut or "—", 52 | "Code": info.get("code", "—"), 53 | "Timestamp": info.get("timestamp", "—"), 54 | } 55 | 56 | mo.callout("Latest keyboard event from the browser:") 57 | mo.md("\n".join(f"- **{label}:** `{value}`" for label, value in rows.items())) 58 | return 59 | 60 | 61 | if __name__ == "__main__": 62 | app.run() 63 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | """Test that all widgets can be imported.""" 2 | 3 | 4 | def test_import_cell_tour(): 5 | from wigglystuff.cell_tour import CellTour 6 | 7 | 8 | def test_import_color_picker(): 9 | from wigglystuff.color_picker import ColorPicker 10 | 11 | 12 | def test_import_copy_to_clipboard(): 13 | from wigglystuff.copy_to_clipboard import CopyToClipboard 14 | 15 | 16 | def test_import_driver_tour(): 17 | from wigglystuff.driver_tour import DriverTour 18 | 19 | 20 | def test_import_edge_draw(): 21 | from wigglystuff.edge_draw import EdgeDraw 22 | 23 | 24 | def test_import_gamepad(): 25 | from wigglystuff.gamepad import GamepadWidget 26 | 27 | 28 | def test_import_keystroke(): 29 | from wigglystuff.keystroke import KeystrokeWidget 30 | 31 | 32 | def test_import_matrix(): 33 | from wigglystuff.matrix import Matrix 34 | 35 | 36 | def test_import_paint(): 37 | from wigglystuff.paint import Paint 38 | 39 | 40 | def test_import_slider2d(): 41 | from wigglystuff.slider2d import Slider2D 42 | 43 | 44 | def test_import_sortable_list(): 45 | from wigglystuff.sortable_list import SortableList 46 | 47 | 48 | def test_import_tangle_choice(): 49 | from wigglystuff.tangle import TangleChoice 50 | 51 | 52 | def test_import_tangle_select(): 53 | from wigglystuff.tangle import TangleSelect 54 | 55 | 56 | def test_import_tangle_slider(): 57 | from wigglystuff.tangle import TangleSlider 58 | 59 | 60 | def test_import_talk(): 61 | from wigglystuff.talk import WebkitSpeechToTextWidget -------------------------------------------------------------------------------- /demos/paint.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.17.8" 4 | app = marimo.App(width="medium", sql_output="polars") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | from mohtml import div, img, tailwind_css 11 | from wigglystuff import Paint 12 | 13 | tailwind_css() 14 | return Paint, div, img, mo 15 | 16 | 17 | @app.cell 18 | def _(Paint, mo): 19 | widget = mo.ui.anywidget(Paint(height=550)) 20 | return (widget,) 21 | 22 | 23 | @app.cell 24 | def _(widget): 25 | widget 26 | return 27 | 28 | 29 | @app.cell 30 | def _(div, img, widget): 31 | div(img(src=widget.get_base64()), klass="bg-gray-200 p-4") 32 | return 33 | 34 | 35 | @app.cell 36 | def _(widget): 37 | widget.get_pil() 38 | return 39 | 40 | 41 | @app.cell(hide_code=True) 42 | def _(mo): 43 | mo.md(r""" 44 | You can also draw over existing images with this library, this can be useful when interacting with multimodal LLMs. 45 | """) 46 | return 47 | 48 | 49 | @app.cell 50 | def _(Paint, mo): 51 | redraw_widget = mo.ui.anywidget( 52 | Paint( 53 | init_image="https://marimo.io/_next/image?url=%2Fimages%2Fblog%2F8%2Fthumbnail.png&w=1920&q=75" 54 | ) 55 | ) 56 | return (redraw_widget,) 57 | 58 | 59 | @app.cell 60 | def _(redraw_widget): 61 | redraw_widget 62 | return 63 | 64 | 65 | @app.cell 66 | def _(redraw_widget): 67 | redraw_widget.get_pil() 68 | return 69 | 70 | 71 | @app.cell 72 | def _(): 73 | return 74 | 75 | 76 | if __name__ == "__main__": 77 | app.run() 78 | -------------------------------------------------------------------------------- /demos/matrix.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.2" 4 | app = marimo.App(width="full") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | import numpy as np 11 | import pandas as pd 12 | import altair as alt 13 | 14 | from wigglystuff import Matrix 15 | return Matrix, alt, mo, np, pd 16 | 17 | 18 | @app.cell 19 | def _(Matrix, mo, np, pd): 20 | pca_mat = mo.ui.anywidget(Matrix(np.random.normal(0, 1, size=(3, 2)), step=0.1)) 21 | rgb_mat = np.random.randint(0, 255, size=(1000, 3)) 22 | color = ["#{0:02x}{1:02x}{2:02x}".format(r, g, b) for r, g, b in rgb_mat] 23 | 24 | rgb_df = pd.DataFrame( 25 | {"r": rgb_mat[:, 0], "g": rgb_mat[:, 1], "b": rgb_mat[:, 2], "color": color} 26 | ) 27 | return color, pca_mat, rgb_mat 28 | 29 | 30 | @app.cell 31 | def _(alt, color, mo, pca_mat, pd, rgb_mat): 32 | X_tfm = rgb_mat @ pca_mat.matrix 33 | df_pca = pd.DataFrame({"x": X_tfm[:, 0], "y": X_tfm[:, 1], "c": color}) 34 | pca_chart = ( 35 | alt.Chart(df_pca) 36 | .mark_point() 37 | .encode(x="x", y="y", color=alt.Color("c:N", scale=None)) 38 | .properties(width=400, height=400) 39 | ) 40 | 41 | mo.vstack( 42 | [ 43 | mo.md(""" 44 | ### PCA demo with `Matrix` 45 | 46 | Ever want to do your own PCA? Try to figure out a mapping from a 3d color map to a 2d representation with the transformation matrix below."""), 47 | mo.hstack([pca_mat, pca_chart]), 48 | ] 49 | ) 50 | return 51 | 52 | 53 | if __name__ == "__main__": 54 | app.run() 55 | -------------------------------------------------------------------------------- /wigglystuff/driver_tour.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Sequence 3 | 4 | import anywidget 5 | import traitlets 6 | 7 | 8 | class DriverTour(anywidget.AnyWidget): 9 | """Interactive guided tour widget powered by Driver.js overlays. 10 | 11 | Define CSS selector-based steps with popovers to guide notebook readers, 12 | optionally auto-starting the experience and surfacing progress indicators. 13 | """ 14 | 15 | _esm = Path(__file__).parent / "static" / "driver-tour.js" 16 | _css = Path(__file__).parent / "static" / "driver-tour.css" 17 | 18 | steps = traitlets.List(traitlets.Dict()).tag(sync=True) 19 | auto_start = traitlets.Bool(False).tag(sync=True) 20 | show_progress = traitlets.Bool(True).tag(sync=True) 21 | active = traitlets.Bool(False).tag(sync=True) 22 | current_step = traitlets.Int(0).tag(sync=True) 23 | 24 | def __init__( 25 | self, 26 | steps: Sequence[dict] = (), 27 | *, 28 | auto_start: bool = False, 29 | show_progress: bool = True, 30 | **kwargs: Any, 31 | ) -> None: 32 | """Create a DriverTour widget. 33 | 34 | Args: 35 | steps: List of tour step dictionaries with element and popover keys. 36 | auto_start: Start tour automatically on render. 37 | show_progress: Show step progress indicator. 38 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 39 | """ 40 | super().__init__( 41 | steps=list(steps), 42 | auto_start=auto_start, 43 | show_progress=show_progress, 44 | **kwargs, 45 | ) 46 | -------------------------------------------------------------------------------- /demos/edgedraw.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.4" 4 | app = marimo.App() 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | return (mo,) 11 | 12 | 13 | @app.cell(hide_code=True) 14 | def _(mo): 15 | mo.md(r""" 16 | ## EdgeDraw 17 | 18 | We created this widget to make it easy to dynamically draw a graph. 19 | """) 20 | return 21 | 22 | 23 | @app.cell 24 | def _(mo): 25 | from wigglystuff import EdgeDraw 26 | 27 | widget = mo.ui.anywidget(EdgeDraw(["a", "b", "c", "d"], directed=True)) 28 | widget 29 | return (widget,) 30 | 31 | 32 | @app.cell(hide_code=True) 33 | def _(mo): 34 | mo.md(r""" 35 | The widget has all sorts of useful attributes and properties that you can retreive. These update as you interact with the widget. 36 | """) 37 | return 38 | 39 | 40 | @app.cell 41 | def _(widget): 42 | widget.names 43 | return 44 | 45 | 46 | @app.cell 47 | def _(widget): 48 | widget.links 49 | return 50 | 51 | 52 | @app.cell 53 | def _(widget): 54 | widget.get_adjacency_matrix() 55 | return 56 | 57 | 58 | @app.cell 59 | def _(widget): 60 | widget.get_neighbors("c") 61 | return 62 | 63 | 64 | @app.cell(hide_code=True) 65 | def _(mo): 66 | mo.md(r""" 67 | ## Cycle Detection 68 | 69 | The widget can detect cycles in the graph. You can specify whether to treat the graph as directed or undirected. 70 | """) 71 | return 72 | 73 | 74 | @app.cell 75 | def _(widget): 76 | widget.has_cycle(directed=False), widget.has_cycle(directed=True) 77 | return 78 | 79 | 80 | @app.cell 81 | def _(): 82 | return 83 | 84 | 85 | if __name__ == "__main__": 86 | app.run() 87 | -------------------------------------------------------------------------------- /demos/celltour.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.12" 3 | # dependencies = [ 4 | # "anywidget==0.9.21", 5 | # "numpy==2.3.5", 6 | # ] 7 | # /// 8 | 9 | import marimo 10 | 11 | __generated_with = "0.18.4" 12 | app = marimo.App(width="medium", sql_output="polars") 13 | 14 | 15 | @app.cell 16 | def foobar(): 17 | # Notice how this cell has a name? 18 | import marimo as mo 19 | from wigglystuff import CellTour 20 | return CellTour, mo 21 | 22 | 23 | @app.cell 24 | def _(CellTour, mo): 25 | # CellTour provides a simpler API than DriverTour 26 | # You can use cell indices OR cell names (data-cell-name attribute) 27 | tour = mo.ui.anywidget( 28 | CellTour( 29 | steps=[ 30 | { 31 | # Use cell_name to target cells by their function name 32 | "cell_name": "foobar", 33 | "title": "Imports", 34 | "description": "First we import marimo and CellTour.", 35 | }, 36 | { 37 | "cell": 1, 38 | "title": "Tour Definition", 39 | "description": "This cell defines the tour using the simplified API.", 40 | }, 41 | { 42 | # Use cell_name for the example cell 43 | "cell": 2, 44 | "title": "Example Code", 45 | "description": "This cell shows some example code.", 46 | }, 47 | ] 48 | ) 49 | ) 50 | tour 51 | return 52 | 53 | 54 | @app.cell 55 | def _(): 56 | # Example code cell 57 | x = 1 58 | y = 2 59 | z = x + y 60 | return 61 | 62 | 63 | if __name__ == "__main__": 64 | app.run() 65 | -------------------------------------------------------------------------------- /demos/drivertour.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.12" 3 | # dependencies = [ 4 | # "anywidget==0.9.21", 5 | # "numpy==2.3.5", 6 | # ] 7 | # /// 8 | 9 | import marimo 10 | 11 | __generated_with = "0.18.4" 12 | app = marimo.App(width="columns", sql_output="polars") 13 | 14 | 15 | @app.cell 16 | def _(): 17 | import marimo as mo 18 | return (mo,) 19 | 20 | 21 | @app.cell 22 | def _(): 23 | from wigglystuff import DriverTour 24 | return (DriverTour,) 25 | 26 | 27 | @app.cell 28 | def _(tour): 29 | tour.steps 30 | return 31 | 32 | 33 | @app.cell(hide_code=True) 34 | def _(DriverTour, mo): 35 | tour = mo.ui.anywidget( 36 | DriverTour(steps=[ 37 | { 38 | "element": ".marimo-cell", 39 | "index": 0, 40 | "popover": { 41 | "title": "Welcome!", 42 | "description": "In this first cell we do a bunch of imports.", 43 | "position": "center" 44 | } 45 | }, 46 | { 47 | "element": ".marimo-cell", 48 | "index": 1, 49 | "popover": { 50 | "title": "Fancy!", 51 | "description": "But in this cell we define a tour! Fancy that!", 52 | "position": "center" 53 | } 54 | }, 55 | { 56 | "element": ".marimo-cell", 57 | "index": 2, 58 | "popover": { 59 | "title": "Steps", 60 | "description": "You can also inspect the steps at the end here.", 61 | "position": "center" 62 | } 63 | }, 64 | 65 | ]) 66 | ) 67 | tour 68 | return (tour,) 69 | 70 | 71 | if __name__ == "__main__": 72 | app.run() 73 | -------------------------------------------------------------------------------- /wigglystuff/sortable_list.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Sequence 3 | 4 | import anywidget 5 | import traitlets 6 | 7 | 8 | class SortableList(anywidget.AnyWidget): 9 | """Drag-and-drop list widget with optional add/remove/edit affordances. 10 | 11 | Examples: 12 | ```python 13 | sortable = SortableList(value=["apple", "banana", "cherry"], removable=True) 14 | sortable 15 | ``` 16 | """ 17 | 18 | _esm = Path(__file__).parent / "static" / "sortable-list.js" 19 | _css = Path(__file__).parent / "static" / "sortable-list.css" 20 | value = traitlets.List(traitlets.Unicode()).tag(sync=True) 21 | addable = traitlets.Bool(default_value=False).tag(sync=True) 22 | removable = traitlets.Bool(default_value=False).tag(sync=True) 23 | editable = traitlets.Bool(default_value=False).tag(sync=True) 24 | label = traitlets.Unicode("").tag(sync=True) 25 | 26 | def __init__( 27 | self, 28 | value: Sequence[str], 29 | *, 30 | addable: bool = False, 31 | removable: bool = False, 32 | editable: bool = False, 33 | label: str = "", 34 | **kwargs: Any, 35 | ) -> None: 36 | """Create a sortable list widget. 37 | 38 | Args: 39 | value: Initial sequence of string items. 40 | addable: Allow inserting new entries. 41 | removable: Allow deleting entries. 42 | editable: Enable inline text editing. 43 | label: Optional heading shown above the list. 44 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 45 | """ 46 | super().__init__( 47 | value=list(value), 48 | addable=addable, 49 | removable=removable, 50 | editable=editable, 51 | label=label, 52 | **kwargs, 53 | ) 54 | -------------------------------------------------------------------------------- /js/driver-tour/styles.css: -------------------------------------------------------------------------------- 1 | .driver-tour-wrapper { 2 | color-scheme: light dark; 3 | --dt-btn-bg: #e0e0e0; 4 | --dt-btn-bg-hover: #d0d0d0; 5 | --dt-btn-text: #1a1a1a; 6 | --dt-btn-shadow: rgba(0, 0, 0, 0.1); 7 | --dt-btn-shadow-hover: rgba(0, 0, 0, 0.15); 8 | --dt-btn-focus: rgba(0, 0, 0, 0.15); 9 | --dt-empty-bg: #f5f5f5; 10 | --dt-empty-text: #666; 11 | --dt-empty-border: #ddd; 12 | } 13 | 14 | .dark .driver-tour-wrapper, 15 | .dark-theme .driver-tour-wrapper, 16 | [data-theme="dark"] .driver-tour-wrapper { 17 | --dt-btn-bg: #333; 18 | --dt-btn-bg-hover: #444; 19 | --dt-btn-text: white; 20 | --dt-btn-shadow: rgba(255, 255, 255, 0.1); 21 | --dt-btn-shadow-hover: rgba(255, 255, 255, 0.15); 22 | --dt-btn-focus: rgba(255, 255, 255, 0.2); 23 | --dt-empty-bg: #2a2a2a; 24 | --dt-empty-text: #aaa; 25 | --dt-empty-border: #444; 26 | } 27 | 28 | .driver-tour-start-button { 29 | display: inline-flex; 30 | align-items: center; 31 | justify-content: center; 32 | padding: 10px 20px; 33 | font-size: 14px; 34 | font-weight: 500; 35 | color: var(--dt-btn-text); 36 | background: var(--dt-btn-bg); 37 | border: none; 38 | border-radius: 8px; 39 | cursor: pointer; 40 | transition: all 0.2s ease; 41 | box-shadow: 0 2px 8px var(--dt-btn-shadow); 42 | } 43 | 44 | .driver-tour-start-button:hover { 45 | transform: translateY(-2px); 46 | background: var(--dt-btn-bg-hover); 47 | box-shadow: 0 4px 12px var(--dt-btn-shadow-hover); 48 | } 49 | 50 | .driver-tour-start-button:active { 51 | transform: translateY(0); 52 | box-shadow: 0 2px 6px var(--dt-btn-shadow); 53 | } 54 | 55 | .driver-tour-start-button:focus { 56 | outline: none; 57 | box-shadow: 0 0 0 3px var(--dt-btn-focus); 58 | } 59 | 60 | .driver-tour-empty { 61 | padding: 12px 16px; 62 | color: var(--dt-empty-text); 63 | font-size: 14px; 64 | font-style: italic; 65 | background-color: var(--dt-empty-bg); 66 | border-radius: 6px; 67 | border-left: 4px solid var(--dt-empty-border); 68 | } 69 | -------------------------------------------------------------------------------- /wigglystuff/static/driver-tour.css: -------------------------------------------------------------------------------- 1 | .driver-tour-wrapper { 2 | color-scheme: light dark; 3 | --dt-btn-bg: #e0e0e0; 4 | --dt-btn-bg-hover: #d0d0d0; 5 | --dt-btn-text: #1a1a1a; 6 | --dt-btn-shadow: rgba(0, 0, 0, 0.1); 7 | --dt-btn-shadow-hover: rgba(0, 0, 0, 0.15); 8 | --dt-btn-focus: rgba(0, 0, 0, 0.15); 9 | --dt-empty-bg: #f5f5f5; 10 | --dt-empty-text: #666; 11 | --dt-empty-border: #ddd; 12 | } 13 | 14 | .dark .driver-tour-wrapper, 15 | .dark-theme .driver-tour-wrapper, 16 | [data-theme="dark"] .driver-tour-wrapper { 17 | --dt-btn-bg: #333; 18 | --dt-btn-bg-hover: #444; 19 | --dt-btn-text: white; 20 | --dt-btn-shadow: rgba(255, 255, 255, 0.1); 21 | --dt-btn-shadow-hover: rgba(255, 255, 255, 0.15); 22 | --dt-btn-focus: rgba(255, 255, 255, 0.2); 23 | --dt-empty-bg: #2a2a2a; 24 | --dt-empty-text: #aaa; 25 | --dt-empty-border: #444; 26 | } 27 | 28 | .driver-tour-start-button { 29 | display: inline-flex; 30 | align-items: center; 31 | justify-content: center; 32 | padding: 10px 20px; 33 | font-size: 14px; 34 | font-weight: 500; 35 | color: var(--dt-btn-text); 36 | background: var(--dt-btn-bg); 37 | border: none; 38 | border-radius: 8px; 39 | cursor: pointer; 40 | transition: all 0.2s ease; 41 | box-shadow: 0 2px 8px var(--dt-btn-shadow); 42 | } 43 | 44 | .driver-tour-start-button:hover { 45 | transform: translateY(-2px); 46 | background: var(--dt-btn-bg-hover); 47 | box-shadow: 0 4px 12px var(--dt-btn-shadow-hover); 48 | } 49 | 50 | .driver-tour-start-button:active { 51 | transform: translateY(0); 52 | box-shadow: 0 2px 6px var(--dt-btn-shadow); 53 | } 54 | 55 | .driver-tour-start-button:focus { 56 | outline: none; 57 | box-shadow: 0 0 0 3px var(--dt-btn-focus); 58 | } 59 | 60 | .driver-tour-empty { 61 | padding: 12px 16px; 62 | color: var(--dt-empty-text); 63 | font-size: 14px; 64 | font-style: italic; 65 | background-color: var(--dt-empty-bg); 66 | border-radius: 6px; 67 | border-left: 4px solid var(--dt-empty-border); 68 | } 69 | -------------------------------------------------------------------------------- /wigglystuff/static/copybutton.css: -------------------------------------------------------------------------------- 1 | /* CopyToClipboard widget styles - following agents.md pattern */ 2 | .copy-button-wrapper { 3 | font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; 4 | color-scheme: light dark; 5 | 6 | /* CSS Variables for theming - light mode defaults */ 7 | --copy-btn-bg: #e0e0e0; 8 | --copy-btn-bg-hover: #d0d0d0; 9 | --copy-btn-bg-active: #c0c0c0; 10 | --copy-btn-text: #1a1a1a; 11 | --copy-btn-shadow: rgba(0, 0, 0, 0.1); 12 | --copy-btn-shadow-hover: rgba(0, 0, 0, 0.15); 13 | } 14 | 15 | .copy-button { 16 | display: inline-flex; 17 | align-items: center; 18 | justify-content: center; 19 | gap: 6px; 20 | padding: 8px 16px; 21 | font-size: 14px; 22 | font-weight: 500; 23 | color: var(--copy-btn-text); 24 | background-color: var(--copy-btn-bg); 25 | border: none; 26 | border-radius: 6px; 27 | cursor: pointer; 28 | transition: all 0.2s ease; 29 | box-shadow: 0 2px 4px var(--copy-btn-shadow); 30 | } 31 | 32 | .copy-button:hover { 33 | background-color: var(--copy-btn-bg-hover); 34 | box-shadow: 0 4px 8px var(--copy-btn-shadow-hover); 35 | transform: translateY(-1px); 36 | } 37 | 38 | .copy-button:active { 39 | background-color: var(--copy-btn-bg-active); 40 | transform: translateY(0); 41 | box-shadow: 0 1px 2px var(--copy-btn-shadow); 42 | } 43 | 44 | .copy-button:focus { 45 | outline: 2px solid var(--copy-btn-bg); 46 | outline-offset: 2px; 47 | } 48 | 49 | .copy-button:disabled { 50 | opacity: 0.5; 51 | cursor: not-allowed; 52 | } 53 | 54 | .copy-button-icon { 55 | width: 16px; 56 | height: 16px; 57 | flex-shrink: 0; 58 | } 59 | 60 | /* Dark mode styles */ 61 | .dark .copy-button-wrapper, 62 | .dark-theme .copy-button-wrapper, 63 | [data-theme="dark"] .copy-button-wrapper { 64 | --copy-btn-bg: #4a4a4a; 65 | --copy-btn-bg-hover: #5a5a5a; 66 | --copy-btn-bg-active: #3a3a3a; 67 | --copy-btn-text: #ffffff; 68 | --copy-btn-shadow: rgba(0, 0, 0, 0.3); 69 | --copy-btn-shadow-hover: rgba(0, 0, 0, 0.4); 70 | } 71 | -------------------------------------------------------------------------------- /demos/gamepad.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.18.1" 4 | app = marimo.App(width="columns") 5 | 6 | 7 | @app.cell 8 | def _(): 9 | import marimo as mo 10 | 11 | from wigglystuff import GamepadWidget 12 | 13 | pad = mo.ui.anywidget(GamepadWidget()) 14 | return mo, pad 15 | 16 | 17 | @app.cell 18 | def _(mo): 19 | mo.md(r""" 20 | ## Listen to browser gamepad events 21 | 22 | This widget streams button presses, d-pad status, and analog stick axes. 23 | Plug in a controller (or pair a Bluetooth one), press any button, and you should see data below. 24 | """) 25 | return 26 | 27 | 28 | @app.cell 29 | def _(pad): 30 | pad 31 | return 32 | 33 | 34 | @app.cell 35 | def _(mo, pad): 36 | axes = pad.axes if pad.axes else [0.0, 0.0, 0.0, 0.0] 37 | left_axes = tuple(round(val, 2) for val in axes[:2]) 38 | right_axes = tuple(round(val, 2) for val in axes[2:]) 39 | 40 | dpad_directions = [ 41 | symbol 42 | for flag, symbol in [ 43 | (pad.dpad_up, "↑"), 44 | (pad.dpad_down, "↓"), 45 | (pad.dpad_left, "←"), 46 | (pad.dpad_right, "→"), 47 | ] 48 | if flag 49 | ] 50 | 51 | last_button = pad.current_button_press if pad.current_button_press >= 0 else "—" 52 | current_ts = pad.current_timestamp or "—" 53 | previous_ts = pad.previous_timestamp or "—" 54 | 55 | mo.vstack( 56 | [ 57 | mo.md( 58 | f"**Last button:** `{last_button}`   |   " 59 | f"**Last change (ms):** `{current_ts}`   |   " 60 | f"**Previous:** `{previous_ts}`" 61 | ), 62 | mo.md( 63 | f"**Sticks**   Left: `{left_axes}`   Right: `{right_axes}`" 64 | ), 65 | mo.md( 66 | "**D-pad:** " 67 | + ( 68 | " ".join(dpad_directions) 69 | if dpad_directions 70 | else "`—` (tap the arrows)" 71 | ) 72 | ), 73 | ] 74 | ) 75 | return 76 | 77 | 78 | if __name__ == "__main__": 79 | app.run() 80 | -------------------------------------------------------------------------------- /tests/test_edgedraw.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from wigglystuff import EdgeDraw 4 | 5 | 6 | def test_get_adjacency_matrix_is_symmetric_when_undirected(): 7 | widget = EdgeDraw(names=["A", "B", "C", "D"]) 8 | widget.links = [("A", "B"), ("C", "D")] 9 | 10 | matrix = widget.get_adjacency_matrix() 11 | 12 | expected = np.zeros((4, 4)) 13 | expected[0, 1] = expected[1, 0] = 1 14 | expected[2, 3] = expected[3, 2] = 1 15 | 16 | np.testing.assert_array_equal(matrix, expected) 17 | 18 | 19 | def test_get_adjacency_matrix_respects_direction(): 20 | widget = EdgeDraw(names=["A", "B", "C"]) 21 | widget.links = [("A", "B"), ("B", "C")] 22 | 23 | directed_matrix = widget.get_adjacency_matrix(directed=True) 24 | undirected_matrix = widget.get_adjacency_matrix(directed=False) 25 | 26 | assert directed_matrix[0, 1] == 1 27 | assert directed_matrix[1, 0] == 0 28 | assert directed_matrix[1, 2] == 1 29 | assert directed_matrix[2, 1] == 0 30 | assert undirected_matrix[1, 0] == 1 31 | assert undirected_matrix[2, 1] == 1 32 | 33 | 34 | def test_get_neighbors_respects_directed_flag(): 35 | widget = EdgeDraw(names=["A", "B", "C"]) 36 | widget.links = [("A", "B"), ("C", "A")] 37 | 38 | assert widget.get_neighbors("A", directed=True) == ["B"] 39 | assert set(widget.get_neighbors("A", directed=False)) == {"B", "C"} 40 | 41 | 42 | def test_has_cycle_handles_directed_and_undirected_cases(): 43 | directed_widget = EdgeDraw(names=["A", "B", "C"]) 44 | directed_widget.links = [("A", "B"), ("B", "C"), ("C", "B")] 45 | 46 | undirected_widget = EdgeDraw(names=["A", "B", "C"]) 47 | undirected_widget.links = [("A", "B"), ("B", "C"), ("C", "A")] 48 | 49 | assert directed_widget.has_cycle(directed=True) is True 50 | assert undirected_widget.has_cycle(directed=False) is True 51 | 52 | 53 | def test_init_accepts_links_and_directed(): 54 | links = [("A", "B"), ("B", "C")] 55 | 56 | widget = EdgeDraw(names=["A", "B", "C"], links=links, directed=False) 57 | 58 | assert widget.links == [{"source": "A", "target": "B"}, {"source": "B", "target": "C"}] 59 | assert widget.directed is False 60 | assert widget.get_adjacency_matrix()[0, 1] == 1 61 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: js docs test docs-demos docs-serve docs-build docs-llm docs-gh marimo-notebook 2 | 3 | install: 4 | # install the build tool for JS written in Golang 5 | curl -fsSL https://esbuild.github.io/dl/v0.19.11 | sh 6 | uv venv --allow-existing 7 | uv pip install -e '.[test,docs]' 8 | npm i 9 | 10 | test: 11 | uv pip install -e '.[test]' 12 | uv run pytest 13 | 14 | pypi: clean 15 | uv build 16 | uv publish 17 | 18 | js-edgedraw: 19 | ./esbuild --watch=forever --bundle --format=esm --outfile=wigglystuff/static/edgedraw.js js/edgedraw.js 20 | 21 | js-gamepad: 22 | ./esbuild --bundle --format=esm --outfile=wigglystuff/static/gamepad-widget.js js/gamepad/widget.js 23 | 24 | js-keystroke: 25 | # build the keyboard shortcut widget bundle 26 | ./esbuild --bundle --format=esm --outfile=wigglystuff/static/keystroke-widget.js js/keystroke/widget.js 27 | 28 | js-copybutton: 29 | ./esbuild --bundle --format=esm --outfile=wigglystuff/static/copybutton.js js/copybutton/widget.tsx 30 | 31 | js-talk: 32 | ./esbuild --bundle --format=esm --outfile=wigglystuff/static/talk-widget.js js/talk/widget.js 33 | 34 | js-driver-tour: 35 | cp js/driver-tour/styles.css wigglystuff/static/driver-tour.css 36 | ./esbuild --bundle --format=esm --loader:.css=text --outfile=wigglystuff/static/driver-tour.js js/driver-tour/widget.js 37 | 38 | js-paint: 39 | ./node_modules/.bin/tailwindcss -i ./js/paint/styles.css -o ./wigglystuff/static/paint.css 40 | ./node_modules/.bin/esbuild js/paint/widget.tsx --bundle --format=esm --outfile=wigglystuff/static/paint.js --minify 41 | 42 | clean: 43 | rm -rf .ipynb_checkpoints build dist drawdata.egg-info 44 | 45 | docs: docs-demos 46 | mkdocs build -f mkdocs.yml 47 | uv run python scripts/copy_docs_md.py 48 | 49 | docs-demos: 50 | uv run python scripts/export_marimo_demos.py --force 51 | 52 | docs-serve: 53 | uv run python -m http.server --directory site 54 | 55 | docs-build: docs-demos 56 | uv run mkdocs build -f mkdocs.yml 57 | uv run python scripts/copy_docs_md.py 58 | 59 | docs-llm: 60 | uv run python scripts/copy_docs_md.py 61 | 62 | docs-gh: docs-build 63 | uv run mkdocs gh-deploy -f mkdocs.yml --dirty 64 | 65 | marimo-notebook: 66 | uv run marimo -y export html-wasm notebook.py --output docs/index.html --mode edit 67 | uv run python -m http.server 8000 --directory docs 68 | -------------------------------------------------------------------------------- /scripts/export_marimo_demos.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Export every demos/*.py notebook to mkdocs/examples//index.html.""" 3 | 4 | from __future__ import annotations 5 | 6 | import argparse 7 | import subprocess 8 | import sys 9 | from pathlib import Path 10 | 11 | ROOT = Path(__file__).resolve().parents[1] 12 | DEMOS_DIR = ROOT / "demos" 13 | DOCS_EXAMPLES_DIR = ROOT / "mkdocs" / "examples" 14 | 15 | 16 | def parse_args(argv: list[str] | None = None) -> argparse.Namespace: 17 | parser = argparse.ArgumentParser(description=__doc__) 18 | parser.add_argument( 19 | "--force", 20 | action="store_true", 21 | help="Re-export every demo even if the HTML output is up-to-date.", 22 | ) 23 | return parser.parse_args(argv) 24 | 25 | 26 | def needs_export(source: Path, target: Path) -> bool: 27 | if not target.exists(): 28 | return True 29 | return source.stat().st_mtime > target.stat().st_mtime 30 | 31 | 32 | def export_notebook(path: Path, *, force: bool = False) -> None: 33 | slug = path.stem 34 | target_dir = DOCS_EXAMPLES_DIR / slug 35 | target_dir.mkdir(parents=True, exist_ok=True) 36 | output_file = target_dir / "index.html" 37 | 38 | if not force and not needs_export(path, output_file): 39 | print( 40 | f"[docs] skipping {path.relative_to(ROOT)} (up-to-date)", 41 | file=sys.stderr, 42 | ) 43 | return 44 | 45 | cmd = [ 46 | "marimo", 47 | "-y", 48 | "-q", 49 | "export", 50 | "html-wasm", 51 | str(path), 52 | "--output", 53 | str(output_file), 54 | "--mode", 55 | "edit", 56 | ] 57 | print(f"[docs] exporting {path.relative_to(ROOT)} -> {output_file.relative_to(ROOT)}") 58 | subprocess.run(cmd, check=True) 59 | 60 | 61 | def export_all(*, force: bool = False) -> int: 62 | DOCS_EXAMPLES_DIR.mkdir(parents=True, exist_ok=True) 63 | demos = sorted(DEMOS_DIR.glob("*.py")) 64 | if not demos: 65 | print("[docs] no demos/*.py files found", file=sys.stderr) 66 | return 1 67 | 68 | for demo in demos: 69 | export_notebook(demo, force=force) 70 | 71 | return 0 72 | 73 | 74 | if __name__ == "__main__": 75 | args = parse_args() 76 | raise SystemExit(export_all(force=args.force)) 77 | -------------------------------------------------------------------------------- /wigglystuff/webcam_capture.py: -------------------------------------------------------------------------------- 1 | """Webcam capture widget with manual and interval snapshots.""" 2 | 3 | from __future__ import annotations 4 | 5 | import base64 6 | from io import BytesIO 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | import anywidget 11 | import traitlets 12 | 13 | 14 | class WebcamCapture(anywidget.AnyWidget): 15 | """Webcam capture widget with manual and interval snapshots. 16 | 17 | The widget shows a live webcam preview plus a capture button and an 18 | auto-capture toggle. When ``capturing`` is enabled, the browser updates 19 | ``image_base64`` on the cadence specified by ``interval_ms``. 20 | 21 | Examples: 22 | ```python 23 | cam = WebcamCapture(interval_ms=1000) 24 | cam 25 | ``` 26 | """ 27 | 28 | _esm = Path(__file__).parent / "static" / "webcam-capture.js" 29 | _css = Path(__file__).parent / "static" / "webcam-capture.css" 30 | 31 | image_base64 = traitlets.Unicode("").tag(sync=True) 32 | capturing = traitlets.Bool(False).tag(sync=True) 33 | interval_ms = traitlets.Int(1000).tag(sync=True) 34 | facing_mode = traitlets.Unicode("user").tag(sync=True) 35 | ready = traitlets.Bool(False).tag(sync=True) 36 | error = traitlets.Unicode("").tag(sync=True) 37 | 38 | def __init__(self, interval_ms: int = 1000, facing_mode: str = "user") -> None: 39 | """Create a WebcamCapture widget. 40 | 41 | Args: 42 | interval_ms: Capture interval in milliseconds when auto-capture is on. 43 | facing_mode: Camera facing mode ("user" or "environment"). 44 | """ 45 | super().__init__(interval_ms=interval_ms, facing_mode=facing_mode) 46 | 47 | def get_bytes(self) -> bytes: 48 | """Return the captured frame as raw bytes.""" 49 | if not self.image_base64: 50 | return b"" 51 | payload = self.image_base64 52 | if "base64," in payload: 53 | payload = payload.split("base64,", 1)[1] 54 | return base64.b64decode(payload) 55 | 56 | def get_pil(self): 57 | """Return the captured frame as a PIL Image.""" 58 | if not self.image_base64: 59 | return None 60 | try: 61 | from PIL import Image 62 | except ImportError as exc: # pragma: no cover - optional dependency 63 | raise ImportError("PIL is required to use get_pil().") from exc 64 | 65 | return Image.open(BytesIO(self.get_bytes())) 66 | -------------------------------------------------------------------------------- /wigglystuff/slider2d.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | import anywidget 5 | import traitlets 6 | 7 | 8 | class Slider2D(anywidget.AnyWidget): 9 | """Two dimensional slider for simultaneous adjustments. 10 | 11 | Emits synchronized ``x``/``y`` floats that stay within configurable bounds 12 | while rendering to a pixel canvas sized via ``width``/``height``. 13 | 14 | Examples: 15 | ```python 16 | slider = Slider2D(x=0.5, y=0.5, x_bounds=(0.0, 1.0), y_bounds=(0.0, 1.0)) 17 | slider 18 | ``` 19 | """ 20 | 21 | _esm = Path(__file__).parent / "static" / "2dslider.js" 22 | x = traitlets.Float(0.0).tag(sync=True) 23 | y = traitlets.Float(0.0).tag(sync=True) 24 | x_bounds = ( 25 | traitlets.Tuple( 26 | traitlets.Float(), traitlets.Float(), default_value=(-1.0, 1.0) 27 | ).tag(sync=True) 28 | ) 29 | y_bounds = ( 30 | traitlets.Tuple( 31 | traitlets.Float(), traitlets.Float(), default_value=(-1.0, 1.0) 32 | ).tag(sync=True) 33 | ) 34 | width = traitlets.Int(400).tag(sync=True) 35 | height = traitlets.Int(400).tag(sync=True) 36 | 37 | def __init__( 38 | self, 39 | x: float = 0.0, 40 | y: float = 0.0, 41 | width: int = 400, 42 | height: int = 400, 43 | x_bounds: tuple[float, float] = (-1.0, 1.0), 44 | y_bounds: tuple[float, float] = (-1.0, 1.0), 45 | **kwargs: Any, 46 | ) -> None: 47 | """Create a Slider2D widget. 48 | 49 | Args: 50 | x: Initial x coordinate. 51 | y: Initial y coordinate. 52 | width: Canvas width in pixels. 53 | height: Canvas height in pixels. 54 | x_bounds: Min/max tuple for x. 55 | y_bounds: Min/max tuple for y. 56 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 57 | """ 58 | super().__init__( 59 | x=x, 60 | y=y, 61 | width=width, 62 | height=height, 63 | x_bounds=x_bounds, 64 | y_bounds=y_bounds, 65 | **kwargs, 66 | ) 67 | 68 | @traitlets.validate("x_bounds", "y_bounds") 69 | def _valid_bounds(self, proposal: dict[str, Any]) -> tuple[float, float]: 70 | """Ensure min < max for bounds.""" 71 | min_val, max_val = proposal["value"] 72 | if not (isinstance(min_val, float) and isinstance(max_val, float)): 73 | raise traitlets.TraitError("Bounds must be a tuple of two floats.") 74 | if min_val >= max_val: 75 | raise traitlets.TraitError("Min must be less than max in bounds.") 76 | return proposal["value"] 77 | -------------------------------------------------------------------------------- /wigglystuff/static/tangle-slider.js: -------------------------------------------------------------------------------- 1 | function render({model, el}) { 2 | const config = { 3 | minValue: model.get("min_value"), 4 | maxValue: model.get("max_value"), 5 | stepSize: model.get("step"), 6 | prefix: model.get("prefix"), 7 | suffix: model.get("suffix"), 8 | digits: model.get("digits"), 9 | pixelsPerStep: model.get("pixels_per_step") 10 | }; 11 | 12 | let amount = model.get("amount"); 13 | console.log(amount); 14 | 15 | const container = document.createElement('div'); 16 | container.classList.add("tangle-container"); 17 | el.style.display = "inline-flex"; 18 | el.appendChild(container); 19 | 20 | function renderValue() { 21 | container.innerHTML = ''; 22 | const element = document.createElement('span'); 23 | element.className = 'tangle-value'; 24 | element.style.color = '#0066cc'; 25 | element.style.textDecoration = 'underline'; 26 | element.style.cursor = 'ew-resize'; 27 | element.textContent = config.prefix + amount.toFixed(config.digits) + config.suffix; 28 | element.addEventListener('mousedown', startDragging); 29 | container.appendChild(element); 30 | } 31 | 32 | function updateModel() { 33 | model.set("amount", amount); 34 | model.save_changes(); 35 | } 36 | 37 | let updateTimeout; 38 | function debouncedUpdateModel() { 39 | clearTimeout(updateTimeout); 40 | updateTimeout = setTimeout(updateModel, 50); // Debounce for 100ms 41 | } 42 | 43 | function startDragging(e) { 44 | e.preventDefault(); 45 | const element = e.target; 46 | element.style.cursor = 'grabbing'; 47 | const startX = e.clientX; 48 | const startValue = parseFloat(element.textContent.replace(config.prefix, '').replace(config.suffix, '')); 49 | 50 | function onMouseMove(e) { 51 | const deltaX = e.clientX - startX; 52 | const steps = Math.floor(deltaX / config.pixelsPerStep); 53 | amount = Math.max(config.minValue, 54 | Math.min(config.maxValue, 55 | startValue + steps * config.stepSize)); 56 | renderValue(); 57 | debouncedUpdateModel(); 58 | } 59 | 60 | function onMouseUp() { 61 | document.removeEventListener('mousemove', onMouseMove); 62 | document.removeEventListener('mouseup', onMouseUp); 63 | element.style.cursor = 'ew-resize'; 64 | updateModel(); 65 | } 66 | 67 | document.addEventListener('mousemove', onMouseMove); 68 | document.addEventListener('mouseup', onMouseUp); 69 | } 70 | 71 | renderValue(); 72 | } 73 | 74 | export default { render }; 75 | -------------------------------------------------------------------------------- /wigglystuff/static/keystroke.css: -------------------------------------------------------------------------------- 1 | .keystroke-widget { 2 | font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 3 | border: 1px solid var(--keystroke-border, #d1d5db); 4 | border-radius: 10px; 5 | padding: 16px; 6 | max-width: 360px; 7 | background: var(--keystroke-bg, #ffffff); 8 | display: flex; 9 | flex-direction: column; 10 | gap: 8px; 11 | box-shadow: 0 2px 6px var(--keystroke-shadow, rgba(0, 0, 0, 0.05)); 12 | color-scheme: light dark; 13 | --keystroke-title: #111827; 14 | --keystroke-text: #4b5563; 15 | --keystroke-muted: #6b7280; 16 | --keystroke-canvas-border: #d1d5db; 17 | --keystroke-canvas-border-active: #6b7280; 18 | --keystroke-canvas-bg: transparent; 19 | --keystroke-canvas-bg-active: #f3f4f6; 20 | --keystroke-canvas-bg-flash: #e5e7eb; 21 | --keystroke-canvas-text: #111827; 22 | } 23 | 24 | .dark .keystroke-widget, 25 | .dark-theme .keystroke-widget, 26 | [data-theme="dark"] .keystroke-widget { 27 | --keystroke-bg: #0f172a; 28 | --keystroke-border: #334155; 29 | --keystroke-shadow: rgba(15, 23, 42, 0.45); 30 | --keystroke-title: #e2e8f0; 31 | --keystroke-text: #cbd5f5; 32 | --keystroke-muted: #94a3b8; 33 | --keystroke-canvas-border: #475569; 34 | --keystroke-canvas-border-active: #94a3b8; 35 | --keystroke-canvas-bg: rgba(15, 23, 42, 0.25); 36 | --keystroke-canvas-bg-active: rgba(148, 163, 184, 0.2); 37 | --keystroke-canvas-bg-flash: rgba(148, 163, 184, 0.28); 38 | --keystroke-canvas-text: #e2e8f0; 39 | } 40 | 41 | .keystroke-title { 42 | font-weight: 600; 43 | color: var(--keystroke-title, #065f46); 44 | } 45 | 46 | .keystroke-instructions { 47 | font-size: 14px; 48 | color: var(--keystroke-text, #4b5563); 49 | } 50 | 51 | .keystroke-canvas { 52 | border: 2px dashed var(--keystroke-canvas-border, #a7f3d0); 53 | border-radius: 8px; 54 | padding: 20px; 55 | min-height: 80px; 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | font-size: 16px; 60 | font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono"; 61 | cursor: pointer; 62 | transition: border-color 0.2s ease, background-color 0.2s ease; 63 | outline: none; 64 | background-color: var(--keystroke-canvas-bg, transparent); 65 | color: var(--keystroke-canvas-text, #111827); 66 | } 67 | 68 | .keystroke-canvas.is-focused { 69 | border-color: var(--keystroke-canvas-border-active, #34d399); 70 | background-color: var(--keystroke-canvas-bg-active, #f0fdf4); 71 | } 72 | 73 | .keystroke-canvas.is-flash { 74 | border-color: var(--keystroke-canvas-border-active, #34d399); 75 | background-color: var(--keystroke-canvas-bg-flash, #ecfdf5); 76 | } 77 | 78 | .keystroke-meta { 79 | font-size: 13px; 80 | color: var(--keystroke-muted, #6b7280); 81 | font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono"; 82 | } 83 | -------------------------------------------------------------------------------- /wigglystuff/cell_tour.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sequence 2 | 3 | from .driver_tour import DriverTour 4 | 5 | 6 | class CellTour(DriverTour): 7 | """Simplified tour widget for marimo notebooks. 8 | 9 | Wraps ``DriverTour`` with cell-aware step helpers so you can reference 10 | marimo cells by index or `data-cell-name` attributes. 11 | 12 | Examples: 13 | Using cell indices: 14 | 15 | ```python 16 | tour = CellTour(steps=[ 17 | {"cell": 0, "title": "Imports", "description": "Load libraries"}, 18 | {"cell": 2, "title": "Processing", "description": "Data transformation"}, 19 | ]) 20 | tour 21 | ``` 22 | 23 | Using cell names (requires naming cells in marimo): 24 | 25 | ```python 26 | tour = CellTour(steps=[ 27 | {"cell_name": "imports", "title": "Imports", "description": "Load libraries"}, 28 | {"cell_name": "process", "title": "Processing", "description": "Transform data"}, 29 | ]) 30 | tour 31 | ``` 32 | """ 33 | 34 | def __init__( 35 | self, 36 | steps: Sequence[dict] = (), 37 | *, 38 | auto_start: bool = False, 39 | show_progress: bool = True, 40 | **kwargs: Any, 41 | ) -> None: 42 | """Create a CellTour widget. 43 | 44 | Args: 45 | steps: List of step dictionaries with cell, title, description keys. 46 | auto_start: Start tour automatically on render. 47 | show_progress: Show step progress indicator. 48 | **kwargs: Forwarded to ``DriverTour``. 49 | """ 50 | transformed_steps = [self._transform_step(step) for step in steps] 51 | super().__init__( 52 | steps=transformed_steps, 53 | auto_start=auto_start, 54 | show_progress=show_progress, 55 | **kwargs, 56 | ) 57 | 58 | @staticmethod 59 | def _transform_step(step: dict) -> dict: 60 | """Transform a simplified step to DriverTour format.""" 61 | cell_index = step.get("cell") 62 | cell_name = step.get("cell_name") 63 | 64 | if cell_index is None and cell_name is None: 65 | raise ValueError( 66 | "Each step must have either a 'cell' key with a cell index " 67 | "or a 'cell_name' key with a cell name" 68 | ) 69 | 70 | popover = { 71 | "title": step.get("title", ""), 72 | "description": step.get("description", ""), 73 | "position": step.get("position", "bottom"), 74 | } 75 | 76 | # Use cell_name selector if provided, otherwise use index 77 | if cell_name is not None: 78 | return { 79 | "element": f'[data-cell-name="{cell_name}"]', 80 | "popover": popover, 81 | } 82 | else: 83 | return { 84 | "element": ".marimo-cell", 85 | "index": cell_index, 86 | "popover": popover, 87 | } 88 | -------------------------------------------------------------------------------- /wigglystuff/static/keystroke-widget.js: -------------------------------------------------------------------------------- 1 | // js/keystroke/widget.js 2 | function formatShortcut(keyInfo) { 3 | if (!keyInfo || !keyInfo.key) { 4 | return "Click here and press any key combination\u2026"; 5 | } 6 | const modifiers = []; 7 | if (keyInfo.ctrlKey) modifiers.push("Ctrl"); 8 | if (keyInfo.shiftKey) modifiers.push("Shift"); 9 | if (keyInfo.altKey) modifiers.push("Alt"); 10 | if (keyInfo.metaKey) modifiers.push("Meta"); 11 | const modStr = modifiers.length ? `${modifiers.join(" + ")} + ` : ""; 12 | return `${modStr}${keyInfo.key}`; 13 | } 14 | function render({ model, el }) { 15 | const container = document.createElement("div"); 16 | container.className = "keystroke-widget"; 17 | const title = document.createElement("div"); 18 | title.textContent = "Keyboard shortcut listener"; 19 | title.className = "keystroke-title"; 20 | const instructions = document.createElement("div"); 21 | instructions.className = "keystroke-instructions"; 22 | instructions.textContent = "Click the panel below and press any shortcut."; 23 | const keyCanvas = document.createElement("div"); 24 | keyCanvas.setAttribute("role", "button"); 25 | keyCanvas.setAttribute("aria-label", "Capture keyboard shortcut"); 26 | keyCanvas.tabIndex = 0; 27 | keyCanvas.className = "keystroke-canvas"; 28 | keyCanvas.textContent = "Click here and press any key combination\u2026"; 29 | const metadata = document.createElement("div"); 30 | metadata.className = "keystroke-meta"; 31 | container.appendChild(title); 32 | container.appendChild(instructions); 33 | container.appendChild(keyCanvas); 34 | container.appendChild(metadata); 35 | el.appendChild(container); 36 | const flash = () => { 37 | keyCanvas.classList.add("is-flash"); 38 | setTimeout(() => { 39 | keyCanvas.classList.remove("is-flash"); 40 | }, 200); 41 | }; 42 | const updateDisplay = (info) => { 43 | keyCanvas.textContent = formatShortcut(info); 44 | if (info && info.code) { 45 | metadata.innerHTML = ` 46 | Code: ${info.code}
47 | Timestamp: ${info.timestamp || "\u2014"} 48 | `; 49 | } else { 50 | metadata.textContent = "No keystrokes recorded yet."; 51 | } 52 | }; 53 | keyCanvas.addEventListener("click", () => keyCanvas.focus()); 54 | keyCanvas.addEventListener("focus", () => { 55 | keyCanvas.classList.add("is-focused"); 56 | }); 57 | keyCanvas.addEventListener("blur", () => { 58 | keyCanvas.classList.remove("is-focused"); 59 | }); 60 | keyCanvas.addEventListener("keydown", (event) => { 61 | event.preventDefault(); 62 | event.stopPropagation(); 63 | const keyInfo = { 64 | key: event.key, 65 | code: event.code, 66 | ctrlKey: event.ctrlKey, 67 | shiftKey: event.shiftKey, 68 | altKey: event.altKey, 69 | metaKey: event.metaKey, 70 | timestamp: Date.now() 71 | }; 72 | model.set("last_key", keyInfo); 73 | model.save_changes(); 74 | updateDisplay(keyInfo); 75 | flash(); 76 | }); 77 | model.on("change:last_key", () => updateDisplay(model.get("last_key"))); 78 | updateDisplay(model.get("last_key")); 79 | } 80 | var widget_default = { render }; 81 | export { 82 | widget_default as default 83 | }; 84 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: wigglystuff 2 | site_description: Creative AnyWidgets for notebooks 3 | site_url: https://wigglystuff.dev 4 | repo_url: https://github.com/koaning/wigglystuff 5 | repo_name: koaning/wigglystuff 6 | docs_dir: mkdocs 7 | site_dir: site 8 | theme: 9 | name: material 10 | logo: assets/logo.png 11 | favicon: assets/logo.png 12 | icon: 13 | repo: fontawesome/brands/github 14 | features: 15 | - navigation.tabs 16 | - navigation.sections 17 | - navigation.expand 18 | - navigation.path 19 | - toc.follow 20 | - content.tabs.link 21 | - content.code.annotate 22 | - search.suggest 23 | - search.highlight 24 | - navigation.instant 25 | palette: 26 | scheme: default 27 | primary: grey 28 | accent: blue grey 29 | font: 30 | text: "PT Sans" 31 | code: "Fira Mono" 32 | nav: 33 | - Overview: index.md 34 | - Reference: 35 | - Overview: reference/index.md 36 | - Slider2D: reference/slider2d.md 37 | - Matrix: reference/matrix.md 38 | - SortableList: reference/sortable-list.md 39 | - Paint: reference/paint.md 40 | - EdgeDraw: reference/edge-draw.md 41 | - KeystrokeWidget: reference/keystroke.md 42 | - WebkitSpeechToText: reference/talk.md 43 | - ColorPicker: reference/color-picker.md 44 | - CopyToClipboard: reference/copy-to-clipboard.md 45 | - Tangle Widgets: reference/tangle.md 46 | - GamepadWidget: reference/gamepad.md 47 | - WebcamCapture: reference/webcam-capture.md 48 | - CellTour: reference/cell-tour.md 49 | plugins: 50 | - search 51 | - section-index 52 | - mkdocs-jupyter: 53 | execute: false 54 | include_source: true 55 | - mkdocstrings: 56 | handlers: 57 | python: 58 | paths: ["."] 59 | options: 60 | docstring_style: google 61 | merge_init_into_class: true 62 | show_source: true 63 | show_root_heading: false 64 | show_signature: true 65 | show_signature_annotations: true 66 | signature_crossrefs: true 67 | separate_signature: true 68 | heading_level: 2 69 | markdown_extensions: 70 | - admonition 71 | - attr_list 72 | - def_list 73 | - footnotes 74 | - md_in_html 75 | - toc: 76 | permalink: "#" 77 | - pymdownx.details 78 | - pymdownx.snippets 79 | - pymdownx.highlight: 80 | anchor_linenums: true 81 | pygments_lang_class: true 82 | - pymdownx.inlinehilite 83 | - pymdownx.magiclink 84 | - pymdownx.superfences 85 | - pymdownx.tabbed: 86 | alternate_style: true 87 | - pymdownx.tasklist: 88 | custom_checkbox: true 89 | - pymdownx.emoji: 90 | emoji_generator: !!python/name:pymdownx.emoji.to_svg 91 | extra_css: 92 | - assets/stylesheets/extra.css 93 | extra: 94 | social: 95 | - icon: fontawesome/brands/github 96 | link: https://github.com/vdamario/wigglystuff 97 | name: GitHub 98 | - icon: material/notebook 99 | link: https://marimo.io/gallery 100 | name: Marimo gallery 101 | watch: 102 | - mkdocs.yml 103 | - mkdocs 104 | - demos 105 | exclude_docs: examples/**/CLAUDE.md 106 | -------------------------------------------------------------------------------- /wigglystuff/static/2dslider.js: -------------------------------------------------------------------------------- 1 | function render({ model, el }) { 2 | const canvas = document.createElement("canvas"); 3 | canvas.setAttribute("id", "sliderCanvas"); 4 | canvas.setAttribute("width", model.get("width").toString()); 5 | canvas.setAttribute("height", model.get("height").toString()); 6 | canvas.setAttribute("style", "border: 1px solid #ccc; background: #eee;"); 7 | 8 | const sliderValuesDiv = document.createElement("div"); 9 | sliderValuesDiv.setAttribute("id", "sliderValues"); 10 | 11 | el.appendChild(canvas); 12 | el.appendChild(sliderValuesDiv); 13 | 14 | const ctx = canvas.getContext('2d'); 15 | 16 | const centerX = canvas.width / 2; 17 | const centerY = canvas.height / 2; 18 | const radius = Math.min(centerX, centerY) - 20; 19 | 20 | let isDragging = false; 21 | let currentX = 0; 22 | let currentY = 0; 23 | 24 | function drawSlider() { 25 | ctx.clearRect(0, 0, canvas.width, canvas.height); 26 | 27 | ctx.beginPath(); 28 | ctx.arc(centerX + currentX, centerY + currentY, 10, 0, 2 * Math.PI); 29 | ctx.fillStyle = '#eee'; 30 | ctx.strokeStyle = "#666"; 31 | ctx.lineWidth = 2; 32 | ctx.fill(); 33 | ctx.stroke(); 34 | 35 | const [x_min, x_max] = model.get("x_bounds"); 36 | const [y_min, y_max] = model.get("y_bounds"); 37 | 38 | const mappedX = x_min + ((currentX / radius) + 1) / 2 * (x_max - x_min); 39 | const mappedY = y_min + ((-currentY / radius) + 1) / 2 * (y_max - y_min); // Y is inverted 40 | 41 | sliderValuesDiv.textContent = `X: ${mappedX.toFixed(2)}, Y: ${mappedY.toFixed(2)}`; 42 | model.set('x', mappedX); 43 | model.set('y', mappedY); 44 | model.save_changes(); 45 | } 46 | 47 | function syncFromModel() { 48 | const [x_min, x_max] = model.get("x_bounds"); 49 | const [y_min, y_max] = model.get("y_bounds"); 50 | const modelX = model.get('x'); 51 | const modelY = model.get('y'); 52 | 53 | // Inverse mapping from user coordinates to pixel coordinates 54 | currentX = radius * (2 * (modelX - x_min) / (x_max - x_min) - 1); 55 | currentY = -radius * (2 * (modelY - y_min) / (y_max - y_min) - 1); // Y is inverted 56 | 57 | if (!isDragging) { 58 | drawSlider(); 59 | } 60 | } 61 | 62 | function handleMouseDown(event) { 63 | isDragging = true; 64 | handleMouseMove(event); 65 | } 66 | 67 | function handleMouseMove(event) { 68 | if (isDragging) { 69 | currentX = event.offsetX - centerX; 70 | currentY = event.offsetY - centerY; 71 | currentX = Math.max(-radius, Math.min(radius, currentX)); 72 | currentY = Math.max(-radius, Math.min(radius, currentY)); 73 | drawSlider(); 74 | } 75 | } 76 | 77 | function handleMouseUp() { 78 | isDragging = false; 79 | } 80 | 81 | canvas.addEventListener('mousedown', handleMouseDown); 82 | canvas.addEventListener('mousemove', handleMouseMove); 83 | document.addEventListener('mouseup', handleMouseUp); 84 | 85 | model.on("change:x", syncFromModel); 86 | model.on("change:y", syncFromModel); 87 | model.on("change:x_bounds", syncFromModel); 88 | model.on("change:y_bounds", syncFromModel); 89 | 90 | syncFromModel(); 91 | } 92 | 93 | export default { render }; 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wigglystuff 2 | 3 | 4 | 5 | > "A collection of creative AnyWidgets for Python notebook environments." 6 | 7 | The project uses [anywidget](https://anywidget.dev/) under the hood so our tools should work in [marimo](https://marimo.io/), [Jupyter](https://jupyter.org/), [Shiny for Python](https://shiny.posit.co/py/docs/jupyter-widgets.html), [VSCode](https://code.visualstudio.com/docs/datascience/jupyter-notebooks), [Colab](https://colab.google/), [Solara](https://solara.dev/), etc. Because of the anywidget integration you should also be able interact with [ipywidgets](https://ipywidgets.readthedocs.io/en/stable/) natively. 8 | 9 | ## Install 10 | 11 | ``` 12 | uv pip install wigglystuff 13 | ``` 14 | 15 | ## Widget Gallery 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
Slider2D

Demo · Source
Matrix

Demo · Source
Paint

Demo · Source
EdgeDraw

Demo · Source
SortableList

Demo · Source
ColorPicker

Demo · Source
GamepadWidget

Demo · Source
KeystrokeWidget

Demo · Source
SpeechToText

Demo · Source
CopyToClipboard

Demo · Source
CellTour

Demo · Source
WebcamCapture

Demo · Source
39 | -------------------------------------------------------------------------------- /agents.md: -------------------------------------------------------------------------------- 1 | # Agents 2 | 3 | `wigglystuff` ships a small roster of AnyWidget "agents" that surface different 4 | input modalities (sliders, speech, paint, etc.) across notebook runtimes. This 5 | page is a quick lookup so you can see what exists and which traitlets each agent 6 | syncs back to Python. 7 | 8 | ## Quick reference 9 | 10 | | Agent | Module/Class | Core traitlets | One-liner | 11 | | --- | --- | --- | --- | 12 | | Slider2D | `wigglystuff.slider2d.Slider2D` | `x`, `y`, `x_bounds`, `y_bounds`, `width`, `height` | 2D pointer for coupled parameters | 13 | | Matrix | `wigglystuff.matrix.Matrix` | `matrix`, `rows`, `cols`, `min_value`, `max_value`, `step`, `mirror` | Spreadsheet-like numeric editor | 14 | | TangleSlider | `wigglystuff.tangle.TangleSlider` | `amount`, `min_value`, `max_value`, `step`, `pixels_per_step` | Inline slider ala Bret Victor | 15 | | TangleChoice | `wigglystuff.tangle.TangleChoice` | `choice`, `choices` | Inline toggle among labels | 16 | | TangleSelect | `wigglystuff.tangle.TangleSelect` | `choice`, `choices` | Dropdown version of the above | 17 | | SortableList | `wigglystuff.sortable_list.SortableList` | `value`, `addable`, `removable`, `editable`, `label` | Drag-and-drop ordering with optional CRUD | 18 | | CopyToClipboard | `wigglystuff.copy_to_clipboard.CopyToClipboard` | `text_to_copy` | Copies the payload into the OS clipboard | 19 | | ColorPicker | `wigglystuff.color_picker.ColorPicker` | `color` | Native color input with `rgb` helper | 20 | | EdgeDraw | `wigglystuff.edge_draw.EdgeDraw` | `names`, `links`, `directed`, `width`, `height` | Sketch node/link diagrams and query adjacency | 21 | | Paint | `wigglystuff.paint.Paint` | `base64`, `width`, `height`, `store_background` | MS-Paint-style canvas with PIL helpers | 22 | | WebcamCapture | `wigglystuff.webcam_capture.WebcamCapture` | `image_base64`, `capturing`, `interval_ms`, `facing_mode` | Webcam preview with snapshot capture | 23 | | GamepadWidget | `wigglystuff.gamepad.GamepadWidget` | `axes`, `current_button_press`, `dpad_*`, `current_timestamp` | Streams browser Gamepad API events | 24 | | KeystrokeWidget | `wigglystuff.keystroke.KeystrokeWidget` | `last_key` | Captures the latest keypress w/ modifiers | 25 | | WebkitSpeechToTextWidget | `wigglystuff.talk.WebkitSpeechToTextWidget` | `transcript`, `listening`, `trigger_listen` | WebKit speech recognition bridge | 26 | | DriverTour | `wigglystuff.driver_tour.DriverTour` | `steps`, `auto_start`, `show_progress`, `active`, `current_step` | Guided product tours via Driver.js | 27 | | CellTour | `wigglystuff.cell_tour.CellTour` | `steps`, `auto_start`, `show_progress`, `active`, `current_step` | Simplified cell-based tours for marimo | 28 | 29 | ## Patterns to remember 30 | 31 | - All agents inherit from `anywidget.AnyWidget`, so `widget.observe(handler)` 32 | remains the standard way to react to state changes. 33 | - Constructors tend to validate bounds, lengths, or choice counts; let the 34 | raised `ValueError/TraitError` guide you instead of duplicating the logic. 35 | - Several widgets expose helper methods (e.g., `Paint.get_pil()`, 36 | `EdgeDraw.get_adjacency_matrix()`)—lean on those rather than re-implementing 37 | conversions. 38 | - Check `wigglystuff/__init__.py` for the names that are re-exported at the 39 | package root so you can keep imports consistent. 40 | - The repo standardizes on [`uv`](https://github.com/astral-sh/uv) for Python 41 | workflows (`uv pip install -e .` etc.) and the standard library's `pathlib` 42 | for filesystem paths—mirror those choices in new agents to keep the codebase 43 | consistent. 44 | - When styling widgets, support both light and dark themes by defining 45 | component-specific CSS variables (see Matrix/SortableList). Scope your 46 | defaults to the widget root, mark `color-scheme: light dark`, and provide 47 | overrides that respond to `.dark`, `.dark-theme`, or `[data-theme="dark"]` 48 | ancestors so notebook-level theme toggles work instantly. 49 | - When adding a new widget, remember to update **both** the docs gallery 50 | (`mkdocs/index.md`) and the README gallery (`readme.md`). Add a screenshot 51 | to `mkdocs/assets/gallery/` and reference it from both locations to keep 52 | them in sync. 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | node_modules/ 165 | esbuild 166 | Untitled.ipynb 167 | .DS_Store 168 | .specstory/ 169 | .cursorindexingignore 170 | **/__marimo__/** 171 | mkdocs/examples/ 172 | !mkdocs/examples/.gitkeep 173 | -------------------------------------------------------------------------------- /js/driver-tour/widget.js: -------------------------------------------------------------------------------- 1 | import { driver } from 'driver.js'; 2 | import driverCSS from 'driver.js/dist/driver.css'; 3 | 4 | // Inject driver.js CSS into document head (needed for popover which is appended to body) 5 | function injectDriverCSS() { 6 | if (!document.querySelector('style[data-driver-css]')) { 7 | const style = document.createElement('style'); 8 | style.setAttribute('data-driver-css', 'true'); 9 | style.textContent = driverCSS; 10 | document.head.appendChild(style); 11 | } 12 | } 13 | 14 | function render({ model, el }) { 15 | // Ensure driver.js CSS is in document head 16 | injectDriverCSS(); 17 | let driverObj = null; 18 | let startButton = null; 19 | 20 | // Add wrapper class for CSS variable scoping 21 | el.classList.add('driver-tour-wrapper'); 22 | 23 | function initializeDriver() { 24 | const steps = model.get('steps'); 25 | const showProgress = model.get('show_progress'); 26 | 27 | if (!steps || steps.length === 0) { 28 | el.innerHTML = '
No tour steps defined
'; 29 | return; 30 | } 31 | 32 | // Create driver instance with configuration 33 | driverObj = driver({ 34 | showProgress: showProgress, 35 | allowClose: true, 36 | showButtons: ['next', 'previous', 'close'], 37 | onDestroyStarted: () => { 38 | if (driverObj) { 39 | driverObj.destroy(); 40 | model.set('active', false); 41 | model.set('current_step', 0); 42 | model.save_changes(); 43 | renderButton(); 44 | } 45 | }, 46 | onNextClick: () => { 47 | driverObj.moveNext(); 48 | model.set('current_step', driverObj.getActiveIndex()); 49 | model.save_changes(); 50 | }, 51 | onPrevClick: () => { 52 | driverObj.movePrevious(); 53 | model.set('current_step', driverObj.getActiveIndex()); 54 | model.save_changes(); 55 | }, 56 | steps: steps.map(step => { 57 | // Handle indexed selection if index is provided 58 | let element = step.element || null; 59 | if (element && step.index !== undefined) { 60 | const elements = document.querySelectorAll(element); 61 | element = elements[step.index] || null; 62 | } 63 | 64 | return { 65 | element: element, 66 | popover: { 67 | title: step.popover?.title || '', 68 | description: step.popover?.description || '', 69 | side: step.popover?.position || 'bottom', 70 | align: step.popover?.align || 'start', 71 | } 72 | }; 73 | }) 74 | }); 75 | 76 | return driverObj; 77 | } 78 | 79 | function renderButton() { 80 | el.innerHTML = ''; 81 | startButton = document.createElement('button'); 82 | startButton.className = 'driver-tour-start-button'; 83 | startButton.textContent = 'Start Tour'; 84 | startButton.onclick = () => { 85 | if (!driverObj) { 86 | initializeDriver(); 87 | } 88 | if (driverObj) { 89 | driverObj.drive(); 90 | model.set('active', true); 91 | model.save_changes(); 92 | el.innerHTML = ''; 93 | } 94 | }; 95 | el.appendChild(startButton); 96 | } 97 | 98 | function initialize() { 99 | initializeDriver(); 100 | 101 | if (model.get('auto_start')) { 102 | if (driverObj) { 103 | driverObj.drive(); 104 | model.set('active', true); 105 | model.save_changes(); 106 | } 107 | } else { 108 | renderButton(); 109 | } 110 | } 111 | 112 | // Handle step changes from Python 113 | model.on('change:steps', () => { 114 | if (driverObj) { 115 | driverObj.destroy(); 116 | driverObj = null; 117 | } 118 | initialize(); 119 | }); 120 | 121 | // Handle auto_start changes 122 | model.on('change:auto_start', () => { 123 | if (model.get('auto_start') && !model.get('active')) { 124 | if (startButton) { 125 | startButton.click(); 126 | } 127 | } 128 | }); 129 | 130 | // Initialize on load 131 | initialize(); 132 | } 133 | 134 | export default { render }; 135 | -------------------------------------------------------------------------------- /wigglystuff/tangle.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, List, Optional 3 | 4 | import anywidget 5 | import traitlets 6 | 7 | 8 | class TangleSlider(anywidget.AnyWidget): 9 | """Inline slider inspired by Bret Victor's Tangle UI. 10 | 11 | Examples: 12 | ```python 13 | slider = TangleSlider(amount=50, min_value=0, max_value=100) 14 | slider 15 | ``` 16 | """ 17 | 18 | _esm = Path(__file__).parent / "static" / "tangle-slider.js" 19 | amount = traitlets.Float(0.0).tag(sync=True) 20 | min_value = traitlets.Float(-100.0).tag(sync=True) 21 | max_value = traitlets.Float(100.0).tag(sync=True) 22 | step = traitlets.Float(1.0).tag(sync=True) 23 | pixels_per_step = traitlets.Int(2).tag(sync=True) 24 | prefix = traitlets.Unicode("").tag(sync=True) 25 | suffix = traitlets.Unicode("").tag(sync=True) 26 | digits = traitlets.Int(1).tag(sync=True) 27 | 28 | def __init__( 29 | self, 30 | amount: Optional[float] = None, 31 | min_value: float = -100, 32 | max_value: float = 100, 33 | step: float = 1.0, 34 | pixels_per_step: int = 2, 35 | prefix: str = "", 36 | suffix: str = "", 37 | digits: int = 1, 38 | **kwargs: Any, 39 | ) -> None: 40 | """Create a slider suitable for inline Tangle interactions. 41 | 42 | Args: 43 | amount: Starting value; defaults to midpoint of bounds. 44 | min_value: Lower bound. 45 | max_value: Upper bound. 46 | step: Increment size. 47 | pixels_per_step: Drag distance per step. 48 | prefix: Text shown before the value. 49 | suffix: Text shown after the value. 50 | digits: Number formatting precision. 51 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 52 | """ 53 | if amount is None: 54 | amount = (max_value + min_value) / 2 55 | super().__init__( 56 | amount=amount, 57 | min_value=min_value, 58 | max_value=max_value, 59 | step=step, 60 | pixels_per_step=pixels_per_step, 61 | prefix=prefix, 62 | suffix=suffix, 63 | digits=digits, 64 | **kwargs, 65 | ) 66 | 67 | 68 | class TangleChoice(anywidget.AnyWidget): 69 | """Inline choice widget that cycles through labeled options. 70 | 71 | Examples: 72 | ```python 73 | choice = TangleChoice(choices=["small", "medium", "large"]) 74 | choice 75 | ``` 76 | """ 77 | 78 | _esm = Path(__file__).parent / "static" / "tangle-choice.js" 79 | choice = traitlets.Unicode("").tag(sync=True) 80 | choices = traitlets.List([]).tag(sync=True) 81 | 82 | def __init__(self, choices: List[str], **kwargs: Any) -> None: 83 | """Create a TangleChoice widget. 84 | 85 | Args: 86 | choices: Ordered sequence of options (min two). 87 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 88 | """ 89 | if len(choices) < 2: 90 | raise ValueError("Must pass at least two choices.") 91 | super().__init__(value=choices[1], choices=choices, **kwargs) 92 | 93 | 94 | class TangleSelect(anywidget.AnyWidget): 95 | """Dropdown-based take on the Tangle choice pattern. 96 | 97 | Examples: 98 | ```python 99 | select = TangleSelect(choices=["red", "green", "blue"]) 100 | select 101 | ``` 102 | """ 103 | 104 | _esm = Path(__file__).parent / "static" / "tangle-select.js" 105 | choice = traitlets.Unicode("").tag(sync=True) 106 | choices = traitlets.List([]).tag(sync=True) 107 | 108 | def __init__(self, choices: List[str], **kwargs: Any) -> None: 109 | """Create a TangleSelect dropdown. 110 | 111 | Args: 112 | choices: Ordered sequence of options (min two). 113 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 114 | """ 115 | if len(choices) < 2: 116 | raise ValueError("Must pass at least two choices.") 117 | super().__init__(choice=choices[0], choices=choices, **kwargs) 118 | -------------------------------------------------------------------------------- /mkdocs/assets/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --wiggly-demo-border: color-mix(in srgb, var(--md-sys-color-outline) 70%, transparent); 3 | --wiggly-button-border: color-mix(in srgb, var(--md-sys-color-outline) 85%, transparent); 4 | } 5 | 6 | [data-md-color-scheme="default"] { 7 | --md-primary-fg-color: #111111; 8 | --md-accent-fg-color: #5f6368; 9 | --md-default-bg-color: #f8f9fa; 10 | } 11 | 12 | [data-md-color-scheme="slate"] { 13 | --md-primary-fg-color: #f4f4f4; 14 | --md-accent-fg-color: #aeb0b5; 15 | --md-default-bg-color: #121212; 16 | } 17 | 18 | .demo-frame { 19 | width: 100%; 20 | min-height: 520px; 21 | border: 1px solid var(--wiggly-demo-border); 22 | border-radius: 0.75rem; 23 | margin-block: 1rem; 24 | background: var(--md-sys-color-surface); 25 | } 26 | 27 | .demo-links { 28 | display: flex; 29 | flex-wrap: wrap; 30 | gap: 0.5rem; 31 | } 32 | 33 | .demo-links a { 34 | text-decoration: none; 35 | } 36 | 37 | .md-typeset .md-button { 38 | border-radius: 999px; 39 | border: 1px solid var(--wiggly-button-border); 40 | background-color: color-mix(in srgb, var(--md-default-bg-color) 70%, transparent); 41 | color: var(--md-primary-fg-color); 42 | padding-inline: 1.25rem; 43 | font-weight: 600; 44 | text-transform: none; 45 | transition: background-color 0.2s ease, border-color 0.2s ease; 46 | } 47 | 48 | .md-typeset .md-button:hover { 49 | border-color: color-mix(in srgb, var(--md-accent-fg-color) 70%, transparent); 50 | background-color: color-mix(in srgb, var(--md-accent-fg-color) 15%, var(--md-default-bg-color)); 51 | } 52 | 53 | [data-md-color-scheme="slate"] .md-typeset .md-button { 54 | color: var(--md-accent-fg-color); 55 | background-color: color-mix(in srgb, #1e1f20 85%, transparent); 56 | } 57 | 58 | [data-md-color-scheme="slate"] .md-typeset .md-button:hover { 59 | border-color: color-mix(in srgb, var(--md-accent-fg-color) 60%, transparent); 60 | background-color: color-mix(in srgb, var(--md-accent-fg-color) 10%, #1e1f20); 61 | } 62 | [data-md-component="header"] .md-header__button.md-logo img { 63 | filter: brightness(0) invert(1); 64 | } 65 | 66 | /* Widget Gallery */ 67 | .widget-gallery { 68 | display: grid; 69 | grid-template-columns: repeat(4, 1fr); 70 | gap: 1rem; 71 | margin-block: 1.5rem; 72 | } 73 | 74 | @media (max-width: 900px) { 75 | .widget-gallery { 76 | grid-template-columns: repeat(3, 1fr); 77 | } 78 | } 79 | 80 | @media (max-width: 600px) { 81 | .widget-gallery { 82 | grid-template-columns: repeat(2, 1fr); 83 | } 84 | } 85 | 86 | .gallery-item { 87 | display: flex; 88 | flex-direction: column; 89 | border: 1px solid var(--wiggly-demo-border); 90 | border-radius: 0.75rem; 91 | overflow: hidden; 92 | background: var(--md-default-bg-color); 93 | transition: border-color 0.2s ease, box-shadow 0.2s ease; 94 | } 95 | 96 | .gallery-item:hover { 97 | border-color: var(--md-accent-fg-color); 98 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 99 | } 100 | 101 | [data-md-color-scheme="slate"] .gallery-item:hover { 102 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 103 | } 104 | 105 | .gallery-item .gallery-title { 106 | font-size: 0.75rem; 107 | font-weight: 600; 108 | color: var(--md-primary-fg-color); 109 | padding: 0.5rem 0.75rem; 110 | border-bottom: 1px solid var(--wiggly-demo-border); 111 | } 112 | 113 | .gallery-item .gallery-img { 114 | display: block; 115 | line-height: 0; 116 | } 117 | 118 | .gallery-item img { 119 | width: 100%; 120 | aspect-ratio: 1 / 1; 121 | object-fit: cover; 122 | border-bottom: 1px solid var(--wiggly-demo-border); 123 | } 124 | 125 | .gallery-links { 126 | padding: 0.5rem 0.75rem; 127 | display: flex; 128 | gap: 0.5rem; 129 | border-top: 1px solid var(--wiggly-demo-border); 130 | } 131 | 132 | .gallery-links a { 133 | font-size: 0.75rem; 134 | color: var(--md-accent-fg-color); 135 | text-decoration: none; 136 | padding: 0.25rem 0.5rem; 137 | border: 1px solid var(--wiggly-button-border); 138 | border-radius: 999px; 139 | transition: background-color 0.2s ease; 140 | } 141 | 142 | .gallery-links a:hover { 143 | background-color: color-mix(in srgb, var(--md-accent-fg-color) 15%, transparent); 144 | } 145 | -------------------------------------------------------------------------------- /wigglystuff/matrix.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, List, Optional 3 | 4 | import anywidget 5 | import numpy as np 6 | import traitlets 7 | 8 | 9 | class Matrix(anywidget.AnyWidget): 10 | """Spreadsheet-like numeric editor with bounds, naming, and symmetry helpers. 11 | 12 | Examples: 13 | ```python 14 | matrix = Matrix(rows=3, cols=3, min_value=0, max_value=10) 15 | matrix 16 | ``` 17 | """ 18 | 19 | _esm = Path(__file__).parent / "static" / "matrix.js" 20 | _css = Path(__file__).parent / "static" / "matrix.css" 21 | matrix = traitlets.List([]).tag(sync=True) 22 | rows = traitlets.Int(3).tag(sync=True) 23 | cols = traitlets.Int(3).tag(sync=True) 24 | min_value = traitlets.Float(-100.0).tag(sync=True) 25 | max_value = traitlets.Float(100.0).tag(sync=True) 26 | mirror = traitlets.Bool(False).tag(sync=True) 27 | step = traitlets.Float(1.0).tag(sync=True) 28 | digits = traitlets.Int(1).tag(sync=True) 29 | row_names = traitlets.List([]).tag(sync=True) 30 | col_names = traitlets.List([]).tag(sync=True) 31 | static = traitlets.Bool(False).tag(sync=True) 32 | flexible_cols = traitlets.Bool(False).tag(sync=True) 33 | 34 | def __init__( 35 | self, 36 | matrix: Optional[List[List[float]]] = None, 37 | rows: int = 3, 38 | cols: int = 3, 39 | min_value: float = -100, 40 | max_value: float = 100, 41 | triangular: bool = False, 42 | row_names: Optional[List[str]] = None, 43 | col_names: Optional[List[str]] = None, 44 | static: bool = False, 45 | flexible_cols: bool = False, 46 | **kwargs: Any, 47 | ) -> None: 48 | """Create a Matrix editor. 49 | 50 | Args: 51 | matrix: Optional 2D list of initial values. 52 | rows: Number of rows when ``matrix`` is omitted. 53 | cols: Number of columns when ``matrix`` is omitted. 54 | min_value: Lower bound for cell values. 55 | max_value: Upper bound for cell values. 56 | triangular: If ``True``, enforce triangular editing constraints. 57 | row_names: Custom labels for rows. 58 | col_names: Custom labels for columns. 59 | static: Disable editing when ``True``. 60 | flexible_cols: Allow column count changes interactively. 61 | **kwargs: Forwarded to ``anywidget.AnyWidget``. 62 | """ 63 | if matrix is not None: 64 | matrix_array = np.array(matrix) 65 | if matrix_array.min() < min_value: 66 | raise ValueError( 67 | f"The min value of input matrix is less than min_value={min_value}." 68 | ) 69 | if matrix_array.max() > max_value: 70 | raise ValueError( 71 | f"The max value of input matrix is less than max_value={max_value}." 72 | ) 73 | rows, cols = matrix_array.shape 74 | matrix = matrix_array.tolist() 75 | else: 76 | matrix = [ 77 | [(min_value + max_value) / 2 for _ in range(cols)] 78 | for _ in range(rows) 79 | ] 80 | 81 | if row_names is not None and len(row_names) != rows: 82 | raise ValueError( 83 | f"Length of row_names ({len(row_names)}) must match number of rows ({rows})." 84 | ) 85 | if col_names is not None and len(col_names) != cols: 86 | raise ValueError( 87 | f"Length of col_names ({len(col_names)}) must match number of columns ({cols})." 88 | ) 89 | 90 | if row_names is None: 91 | row_names = [] 92 | if col_names is None: 93 | col_names = [] 94 | 95 | super().__init__( 96 | matrix=matrix, 97 | rows=rows, 98 | cols=cols, 99 | min_value=min_value, 100 | max_value=max_value, 101 | triangular=triangular, 102 | row_names=row_names, 103 | col_names=col_names, 104 | static=static, 105 | flexible_cols=flexible_cols, 106 | **kwargs, 107 | ) 108 | -------------------------------------------------------------------------------- /js/talk/widget.js: -------------------------------------------------------------------------------- 1 | function render({ model, el }) { 2 | const container = document.createElement('div'); 3 | container.className = 'speech-container'; 4 | 5 | const status = document.createElement('div'); 6 | status.className = 'status-indicator'; 7 | status.textContent = model.get('listening') ? 'Listening...' : 'Not listening'; 8 | 9 | const display = document.createElement('div'); 10 | display.className = 'transcript-display'; 11 | const transcriptValue = model.get('transcript') || 'Transcript will appear here...'; 12 | display.textContent = transcriptValue; 13 | 14 | const button = document.createElement('button'); 15 | button.className = 'speech-button'; 16 | button.textContent = model.get('listening') ? 'Stop Listening' : 'Start Listening'; 17 | 18 | container.appendChild(status); 19 | container.appendChild(display); 20 | container.appendChild(button); 21 | el.appendChild(container); 22 | 23 | let recognition = null; 24 | 25 | try { 26 | const SpeechRecognition = 27 | window.SpeechRecognition || window.webkitSpeechRecognition; 28 | recognition = new SpeechRecognition(); 29 | recognition.continuous = true; 30 | recognition.interimResults = true; 31 | 32 | recognition.onstart = () => { 33 | status.textContent = 'Listening...'; 34 | status.classList.add('active'); 35 | button.textContent = 'Stop Listening'; 36 | model.set('listening', true); 37 | model.save_changes(); 38 | }; 39 | 40 | recognition.onresult = (event) => { 41 | let finalTranscript = ''; 42 | let interimTranscript = ''; 43 | 44 | for (let i = event.resultIndex; i < event.results.length; i++) { 45 | const transcript = event.results[i][0].transcript; 46 | if (event.results[i].isFinal) { 47 | finalTranscript += transcript; 48 | } else { 49 | interimTranscript += transcript; 50 | } 51 | } 52 | 53 | const fullTranscript = finalTranscript || interimTranscript; 54 | display.textContent = fullTranscript || 'Transcript will appear here...'; 55 | model.set('transcript', fullTranscript); 56 | model.save_changes(); 57 | }; 58 | 59 | recognition.onerror = (event) => { 60 | console.error('Speech recognition error', event.error); 61 | status.textContent = `Error: ${event.error}`; 62 | status.classList.remove('active'); 63 | button.textContent = 'Start Listening'; 64 | model.set('listening', false); 65 | model.save_changes(); 66 | }; 67 | 68 | recognition.onend = () => { 69 | status.textContent = 'Not listening'; 70 | status.classList.remove('active'); 71 | button.textContent = 'Start Listening'; 72 | model.set('listening', false); 73 | model.save_changes(); 74 | }; 75 | } catch (error) { 76 | console.error('Speech recognition not supported', error); 77 | status.textContent = 'Speech recognition not supported in this browser'; 78 | button.disabled = true; 79 | recognition = null; 80 | } 81 | 82 | const toggleListening = () => { 83 | if (!recognition) { 84 | return; 85 | } 86 | 87 | const isListening = model.get('listening'); 88 | if (isListening) { 89 | recognition.stop(); 90 | } else { 91 | recognition.start(); 92 | } 93 | }; 94 | 95 | button.addEventListener('click', toggleListening); 96 | 97 | model.on('change:trigger_listen', () => { 98 | if (model.get('trigger_listen')) { 99 | toggleListening(); 100 | model.set('trigger_listen', false); 101 | model.save_changes(); 102 | } 103 | }); 104 | 105 | model.on('change:listening', () => { 106 | const isListening = model.get('listening'); 107 | const currentlyListening = status.textContent === 'Listening...'; 108 | 109 | if (isListening !== currentlyListening) { 110 | if (isListening && recognition) { 111 | recognition.start(); 112 | } else if (recognition) { 113 | recognition.stop(); 114 | } 115 | } 116 | 117 | button.textContent = isListening ? 'Stop Listening' : 'Start Listening'; 118 | status.textContent = isListening ? 'Listening...' : 'Not listening'; 119 | status.classList.toggle('active', isListening); 120 | }); 121 | 122 | model.on('change:transcript', () => { 123 | display.textContent = model.get('transcript') || 'Transcript will appear here...'; 124 | }); 125 | } 126 | 127 | export default { render }; 128 | -------------------------------------------------------------------------------- /wigglystuff/static/talk-widget.js: -------------------------------------------------------------------------------- 1 | function render({ model, el }) { 2 | const container = document.createElement('div'); 3 | container.className = 'speech-container'; 4 | 5 | const status = document.createElement('div'); 6 | status.className = 'status-indicator'; 7 | status.textContent = model.get('listening') ? 'Listening...' : 'Not listening'; 8 | 9 | const display = document.createElement('div'); 10 | display.className = 'transcript-display'; 11 | const transcriptValue = model.get('transcript') || 'Transcript will appear here...'; 12 | display.textContent = transcriptValue; 13 | 14 | const button = document.createElement('button'); 15 | button.className = 'speech-button'; 16 | button.textContent = model.get('listening') ? 'Stop Listening' : 'Start Listening'; 17 | 18 | container.appendChild(status); 19 | container.appendChild(display); 20 | container.appendChild(button); 21 | el.appendChild(container); 22 | 23 | let recognition = null; 24 | 25 | try { 26 | const SpeechRecognition = 27 | window.SpeechRecognition || window.webkitSpeechRecognition; 28 | recognition = new SpeechRecognition(); 29 | recognition.continuous = true; 30 | recognition.interimResults = true; 31 | 32 | recognition.onstart = () => { 33 | status.textContent = 'Listening...'; 34 | status.classList.add('active'); 35 | button.textContent = 'Stop Listening'; 36 | model.set('listening', true); 37 | model.save_changes(); 38 | }; 39 | 40 | recognition.onresult = (event) => { 41 | let finalTranscript = ''; 42 | let interimTranscript = ''; 43 | 44 | for (let i = event.resultIndex; i < event.results.length; i++) { 45 | const transcript = event.results[i][0].transcript; 46 | if (event.results[i].isFinal) { 47 | finalTranscript += transcript; 48 | } else { 49 | interimTranscript += transcript; 50 | } 51 | } 52 | 53 | const fullTranscript = finalTranscript || interimTranscript; 54 | display.textContent = fullTranscript || 'Transcript will appear here...'; 55 | model.set('transcript', fullTranscript); 56 | model.save_changes(); 57 | }; 58 | 59 | recognition.onerror = (event) => { 60 | console.error('Speech recognition error', event.error); 61 | status.textContent = `Error: ${event.error}`; 62 | status.classList.remove('active'); 63 | button.textContent = 'Start Listening'; 64 | model.set('listening', false); 65 | model.save_changes(); 66 | }; 67 | 68 | recognition.onend = () => { 69 | status.textContent = 'Not listening'; 70 | status.classList.remove('active'); 71 | button.textContent = 'Start Listening'; 72 | model.set('listening', false); 73 | model.save_changes(); 74 | }; 75 | } catch (error) { 76 | console.error('Speech recognition not supported', error); 77 | status.textContent = 'Speech recognition not supported in this browser'; 78 | button.disabled = true; 79 | recognition = null; 80 | } 81 | 82 | const toggleListening = () => { 83 | if (!recognition) { 84 | return; 85 | } 86 | 87 | const isListening = model.get('listening'); 88 | if (isListening) { 89 | recognition.stop(); 90 | } else { 91 | recognition.start(); 92 | } 93 | }; 94 | 95 | button.addEventListener('click', toggleListening); 96 | 97 | model.on('change:trigger_listen', () => { 98 | if (model.get('trigger_listen')) { 99 | toggleListening(); 100 | model.set('trigger_listen', false); 101 | model.save_changes(); 102 | } 103 | }); 104 | 105 | model.on('change:listening', () => { 106 | const isListening = model.get('listening'); 107 | const currentlyListening = status.textContent === 'Listening...'; 108 | 109 | if (isListening !== currentlyListening) { 110 | if (isListening && recognition) { 111 | recognition.start(); 112 | } else if (recognition) { 113 | recognition.stop(); 114 | } 115 | } 116 | 117 | button.textContent = isListening ? 'Stop Listening' : 'Start Listening'; 118 | status.textContent = isListening ? 'Listening...' : 'Not listening'; 119 | status.classList.toggle('active', isListening); 120 | }); 121 | 122 | model.on('change:transcript', () => { 123 | display.textContent = model.get('transcript') || 'Transcript will appear here...'; 124 | }); 125 | } 126 | 127 | export default { render }; 128 | -------------------------------------------------------------------------------- /mkdocs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: wigglystuff 3 | hide: 4 | - toc 5 | --- 6 | 7 | # Wiggly notebooks, zero friction 8 | 9 | > "A collection of creative AnyWidgets for Python notebook environments." 10 | 11 | ## Install wigglystuff 12 | 13 | === "uv" 14 | 15 | ```bash 16 | uv pip install wigglystuff 17 | ``` 18 | 19 | === "pip" 20 | 21 | ```bash 22 | pip install wigglystuff 23 | ``` 24 | 25 | 26 | ## What you can build 27 | 28 | 90 | 91 | Each widget page embeds a marimo-powered html-wasm export and links back to the exact notebook that generated the demo, so you can open the original `.py` file and rerun it locally. 92 | -------------------------------------------------------------------------------- /js/keystroke/widget.js: -------------------------------------------------------------------------------- 1 | const containerStyles = ` 2 | font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 3 | border: 1px solid #d1d5db; 4 | border-radius: 10px; 5 | padding: 16px; 6 | max-width: 360px; 7 | background: #ffffff; 8 | display: flex; 9 | flex-direction: column; 10 | gap: 8px; 11 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); 12 | `; 13 | 14 | const canvasStyles = ` 15 | border: 2px dashed #a7f3d0; 16 | border-radius: 8px; 17 | padding: 20px; 18 | min-height: 80px; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: 16px; 23 | font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono"; 24 | cursor: pointer; 25 | transition: border-color 0.2s ease, background-color 0.2s ease; 26 | outline: none; 27 | `; 28 | 29 | function formatShortcut(keyInfo) { 30 | if (!keyInfo || !keyInfo.key) { 31 | return "Click here and press any key combination…"; 32 | } 33 | 34 | const modifiers = []; 35 | if (keyInfo.ctrlKey) modifiers.push("Ctrl"); 36 | if (keyInfo.shiftKey) modifiers.push("Shift"); 37 | if (keyInfo.altKey) modifiers.push("Alt"); 38 | if (keyInfo.metaKey) modifiers.push("Meta"); 39 | const modStr = modifiers.length ? `${modifiers.join(" + ")} + ` : ""; 40 | 41 | return `${modStr}${keyInfo.key}`; 42 | } 43 | 44 | function render({ model, el }) { 45 | const container = document.createElement("div"); 46 | container.style.cssText = containerStyles; 47 | 48 | const title = document.createElement("div"); 49 | title.textContent = "Keyboard shortcut listener"; 50 | title.style.fontWeight = "600"; 51 | title.style.color = "#065f46"; 52 | 53 | const instructions = document.createElement("div"); 54 | instructions.style.fontSize = "14px"; 55 | instructions.style.color = "#4b5563"; 56 | instructions.textContent = "Click the panel below and press any shortcut."; 57 | 58 | const keyCanvas = document.createElement("div"); 59 | keyCanvas.setAttribute("role", "button"); 60 | keyCanvas.setAttribute("aria-label", "Capture keyboard shortcut"); 61 | keyCanvas.tabIndex = 0; 62 | keyCanvas.style.cssText = canvasStyles; 63 | keyCanvas.textContent = "Click here and press any key combination…"; 64 | 65 | const metadata = document.createElement("div"); 66 | metadata.style.fontSize = "13px"; 67 | metadata.style.color = "#6b7280"; 68 | metadata.style.fontFamily = 69 | '"JetBrains Mono", ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono"'; 70 | 71 | container.appendChild(title); 72 | container.appendChild(instructions); 73 | container.appendChild(keyCanvas); 74 | container.appendChild(metadata); 75 | el.appendChild(container); 76 | 77 | const flash = () => { 78 | keyCanvas.style.backgroundColor = "#ecfdf5"; 79 | keyCanvas.style.borderColor = "#34d399"; 80 | setTimeout(() => { 81 | keyCanvas.style.backgroundColor = "transparent"; 82 | keyCanvas.style.borderColor = "#a7f3d0"; 83 | }, 200); 84 | }; 85 | 86 | const updateDisplay = (info) => { 87 | keyCanvas.textContent = formatShortcut(info); 88 | if (info && info.code) { 89 | metadata.innerHTML = ` 90 | Code: ${info.code}
91 | Timestamp: ${info.timestamp || "—"} 92 | `; 93 | } else { 94 | metadata.textContent = "No keystrokes recorded yet."; 95 | } 96 | }; 97 | 98 | keyCanvas.addEventListener("click", () => keyCanvas.focus()); 99 | keyCanvas.addEventListener("focus", () => { 100 | keyCanvas.style.borderColor = "#34d399"; 101 | keyCanvas.style.backgroundColor = "#f0fdf4"; 102 | }); 103 | keyCanvas.addEventListener("blur", () => { 104 | keyCanvas.style.borderColor = "#a7f3d0"; 105 | keyCanvas.style.backgroundColor = "transparent"; 106 | }); 107 | 108 | keyCanvas.addEventListener("keydown", (event) => { 109 | event.preventDefault(); 110 | event.stopPropagation(); 111 | 112 | const keyInfo = { 113 | key: event.key, 114 | code: event.code, 115 | ctrlKey: event.ctrlKey, 116 | shiftKey: event.shiftKey, 117 | altKey: event.altKey, 118 | metaKey: event.metaKey, 119 | timestamp: Date.now(), 120 | }; 121 | 122 | model.set("last_key", keyInfo); 123 | model.save_changes(); 124 | updateDisplay(keyInfo); 125 | flash(); 126 | }); 127 | 128 | model.on("change:last_key", () => updateDisplay(model.get("last_key"))); 129 | updateDisplay(model.get("last_key")); 130 | } 131 | 132 | export default { render }; 133 | -------------------------------------------------------------------------------- /wigglystuff/static/matrix.css: -------------------------------------------------------------------------------- 1 | .matrix { 2 | display: inline-block; 3 | vertical-align: middle; 4 | font-size: 18px; 5 | line-height: 1; 6 | white-space: nowrap; 7 | padding: 10px; 8 | position: relative; 9 | user-select: none; 10 | } 11 | 12 | .matrix:before, 13 | .matrix:after { 14 | content: ""; 15 | position: absolute; 16 | top: 0; 17 | bottom: 0; 18 | width: 2px; 19 | background-color: var(--matrix-bracket, #000000); 20 | } 21 | 22 | .matrix:before { 23 | left: 0; 24 | } 25 | 26 | .matrix:after { 27 | right: 0; 28 | } 29 | 30 | .matrix-row { 31 | display: flex; 32 | justify-content: flex-start; 33 | } 34 | 35 | .matrix-wrapper { 36 | display: inline-block; 37 | font-family: monospace; 38 | font-size: 14px; 39 | color-scheme: light dark; 40 | --matrix-text: #111827; 41 | --matrix-muted: #333333; 42 | --matrix-accent: #0066cc; 43 | --matrix-static: #666666; 44 | --matrix-bracket: rgba(0, 0, 0, 0.85); 45 | } 46 | 47 | .dark .matrix-wrapper, 48 | .dark-theme .matrix-wrapper, 49 | [data-theme="dark"] .matrix-wrapper { 50 | --matrix-text: #f3f4f6; 51 | --matrix-muted: #c7d2fe; 52 | --matrix-accent: #7dd3fc; 53 | --matrix-static: #e2e8f0; 54 | --matrix-bracket: rgba(148, 163, 184, 0.85); 55 | } 56 | 57 | .matrix-grid { 58 | display: flex; 59 | align-items: flex-start; 60 | color: inherit; 61 | } 62 | 63 | .matrix-column { 64 | display: flex; 65 | flex-direction: column; 66 | color: inherit; 67 | } 68 | 69 | .matrix-label-column { 70 | margin-right: 5px; 71 | } 72 | 73 | .matrix-data-wrapper { 74 | display: flex; 75 | position: relative; 76 | padding: 0 15px; 77 | } 78 | 79 | .matrix-data-wrapper::before, 80 | .matrix-data-wrapper::after { 81 | content: ''; 82 | position: absolute; 83 | width: 10px; 84 | height: 100%; 85 | pointer-events: none; 86 | } 87 | 88 | .matrix-data-wrapper::before { 89 | left: 3px; 90 | border-left: 2px solid var(--matrix-bracket, black); 91 | border-top: 2px solid var(--matrix-bracket, black); 92 | border-bottom: 2px solid var(--matrix-bracket, black); 93 | } 94 | 95 | .matrix-data-wrapper::after { 96 | right: 3px; 97 | border-right: 2px solid var(--matrix-bracket, black); 98 | border-top: 2px solid var(--matrix-bracket, black); 99 | border-bottom: 2px solid var(--matrix-bracket, black); 100 | } 101 | 102 | .matrix-data-column { 103 | margin: 0; 104 | } 105 | 106 | .matrix-cell { 107 | display: flex; 108 | align-items: center; 109 | justify-content: center; 110 | height: 2em; 111 | box-sizing: border-box; 112 | color: inherit; 113 | } 114 | 115 | .matrix-element { 116 | width: 3em; 117 | height: 1.5em; 118 | text-align: center; 119 | margin: 0.2em; 120 | cursor: ew-resize; 121 | color: var(--matrix-accent, #0066cc); 122 | display: flex; 123 | align-items: center; 124 | justify-content: center; 125 | } 126 | 127 | .matrix-element::selection { 128 | background-color: transparent; 129 | } 130 | 131 | .matrix-element-static { 132 | cursor: default; 133 | color: var(--matrix-static, #666666); 134 | } 135 | 136 | .matrix-row-label { 137 | width: auto; 138 | min-width: 5em; 139 | justify-content: flex-end; 140 | padding-right: 0.5em; 141 | font-weight: bold; 142 | color: var(--matrix-muted, #333333); 143 | white-space: nowrap; 144 | } 145 | 146 | .matrix-col-label { 147 | font-weight: bold; 148 | color: var(--matrix-muted, #333333); 149 | margin-bottom: 5px; 150 | width: 3.6em; 151 | overflow: hidden; 152 | text-overflow: ellipsis; 153 | white-space: nowrap; 154 | } 155 | 156 | .matrix-grid.flexible-columns .matrix-element { 157 | width: auto; 158 | min-width: 3em; 159 | padding: 0 0.5em; 160 | } 161 | 162 | .matrix-grid.flexible-columns .matrix-col-label { 163 | width: auto; 164 | min-width: 3.6em; 165 | padding: 0 0.5em; 166 | } 167 | 168 | .matrix-grid.flexible-columns .matrix-data-column { 169 | flex-shrink: 0; 170 | } 171 | 172 | .matrix-corner-cell { 173 | visibility: hidden; 174 | height: 2em; 175 | margin-bottom: 5px; 176 | } 177 | 178 | .matrix-grid:has(.matrix-col-label) .matrix-data-wrapper::before, 179 | .matrix-grid:has(.matrix-col-label) .matrix-data-wrapper::after { 180 | top: calc(2em + 5px); 181 | height: calc(100% - 2em - 5px); 182 | } 183 | 184 | .matrix-grid:not(:has(.matrix-col-label)) .matrix-data-wrapper::before, 185 | .matrix-grid:not(:has(.matrix-col-label)) .matrix-data-wrapper::after { 186 | top: 0; 187 | height: 100%; 188 | } 189 | -------------------------------------------------------------------------------- /wigglystuff/static/webcam-capture.css: -------------------------------------------------------------------------------- 1 | .webcam-capture { 2 | font-family: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; 3 | display: grid; 4 | gap: 12px; 5 | padding: 16px; 6 | border-radius: 16px; 7 | border: 1px solid var(--webcam-border, #e2e8f0); 8 | background: var(--webcam-surface, #f8fafc); 9 | color: var(--webcam-text, #0f172a); 10 | box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); 11 | max-width: 560px; 12 | color-scheme: light dark; 13 | --webcam-text: #0f172a; 14 | --webcam-muted: #64748b; 15 | --webcam-border: #e2e8f0; 16 | --webcam-surface: #f8fafc; 17 | --webcam-panel: #ffffff; 18 | --webcam-accent: #0ea5e9; 19 | --webcam-accent-strong: #0284c7; 20 | --webcam-danger: #ef4444; 21 | --webcam-ready: #10b981; 22 | } 23 | 24 | .dark .webcam-capture, 25 | .dark-theme .webcam-capture, 26 | [data-theme="dark"] .webcam-capture { 27 | --webcam-text: #f8fafc; 28 | --webcam-muted: #94a3b8; 29 | --webcam-border: #334155; 30 | --webcam-surface: #0f172a; 31 | --webcam-panel: #111827; 32 | --webcam-accent: #38bdf8; 33 | --webcam-accent-strong: #0ea5e9; 34 | --webcam-danger: #f87171; 35 | --webcam-ready: #34d399; 36 | } 37 | 38 | .webcam-capture__header { 39 | display: flex; 40 | align-items: center; 41 | justify-content: space-between; 42 | gap: 12px; 43 | } 44 | 45 | .webcam-capture__title { 46 | font-weight: 600; 47 | font-size: 16px; 48 | letter-spacing: 0.01em; 49 | } 50 | 51 | .webcam-capture__status { 52 | font-size: 12px; 53 | text-transform: uppercase; 54 | letter-spacing: 0.12em; 55 | color: var(--webcam-muted, #64748b); 56 | } 57 | 58 | .webcam-capture__status[data-tone="ready"] { 59 | color: var(--webcam-ready, #10b981); 60 | } 61 | 62 | .webcam-capture__status[data-tone="active"] { 63 | color: var(--webcam-accent, #0ea5e9); 64 | } 65 | 66 | .webcam-capture__status[data-tone="error"] { 67 | color: var(--webcam-danger, #ef4444); 68 | } 69 | 70 | .webcam-capture__video { 71 | background: var(--webcam-panel, #ffffff); 72 | border-radius: 14px; 73 | border: 1px solid var(--webcam-border, #e2e8f0); 74 | overflow: hidden; 75 | aspect-ratio: 16 / 10; 76 | display: grid; 77 | place-items: center; 78 | } 79 | 80 | .webcam-capture__video video { 81 | width: 100%; 82 | height: 100%; 83 | object-fit: cover; 84 | } 85 | 86 | .webcam-capture__controls { 87 | display: flex; 88 | align-items: center; 89 | justify-content: space-between; 90 | gap: 12px; 91 | } 92 | 93 | .webcam-capture__button { 94 | border: none; 95 | padding: 10px 18px; 96 | border-radius: 999px; 97 | font-weight: 600; 98 | letter-spacing: 0.01em; 99 | color: #ffffff; 100 | background: var(--webcam-accent, #0ea5e9); 101 | cursor: pointer; 102 | transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; 103 | box-shadow: 0 6px 16px rgba(14, 165, 233, 0.35); 104 | } 105 | 106 | .webcam-capture__button:hover { 107 | background: var(--webcam-accent-strong, #0284c7); 108 | transform: translateY(-1px); 109 | } 110 | 111 | .webcam-capture__button:active { 112 | transform: translateY(0); 113 | } 114 | 115 | .webcam-capture__toggle { 116 | display: inline-flex; 117 | align-items: center; 118 | gap: 8px; 119 | font-size: 14px; 120 | color: var(--webcam-muted, #64748b); 121 | } 122 | 123 | .webcam-capture__toggle input { 124 | appearance: none; 125 | width: 42px; 126 | height: 24px; 127 | border-radius: 999px; 128 | background: rgba(100, 116, 139, 0.3); 129 | position: relative; 130 | cursor: pointer; 131 | transition: background 0.2s ease; 132 | } 133 | 134 | .webcam-capture__toggle input::after { 135 | content: ""; 136 | width: 18px; 137 | height: 18px; 138 | border-radius: 50%; 139 | background: #ffffff; 140 | position: absolute; 141 | top: 3px; 142 | left: 3px; 143 | transition: transform 0.2s ease; 144 | box-shadow: 0 2px 6px rgba(15, 23, 42, 0.2); 145 | } 146 | 147 | .webcam-capture__toggle input:checked { 148 | background: var(--webcam-accent, #0ea5e9); 149 | } 150 | 151 | .webcam-capture__toggle input:checked::after { 152 | transform: translateX(18px); 153 | } 154 | 155 | .webcam-capture__toggle span { 156 | font-weight: 500; 157 | } 158 | 159 | @media (max-width: 560px) { 160 | .webcam-capture { 161 | padding: 14px; 162 | } 163 | 164 | .webcam-capture__controls { 165 | flex-direction: column; 166 | align-items: stretch; 167 | } 168 | 169 | .webcam-capture__button { 170 | width: 100%; 171 | justify-content: center; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /wigglystuff/static/sortable-list.css: -------------------------------------------------------------------------------- 1 | .draggable-list-widget { 2 | font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; 3 | max-width: 100%; 4 | color-scheme: light dark; 5 | 6 | /* CSS Variables for theming */ 7 | --sortable-bg-container: #ffffff; 8 | --sortable-bg-item: #ffffff; 9 | --sortable-bg-hover: #f8f9fa; 10 | --sortable-bg-focus: #f8f9fa; 11 | --sortable-bg-button-hover: #e4e6ea; 12 | --sortable-border-color: #e1e5e9; 13 | --sortable-text-primary: #172b4d; 14 | --sortable-text-secondary: #6b778c; 15 | --sortable-text-button-hover: #42526e; 16 | --sortable-accent: #0052cc; 17 | --sortable-shadow: rgba(0, 0, 0, 0.1); 18 | 19 | .list-label { 20 | display: block; 21 | font-size: 14px; 22 | font-weight: 600; 23 | margin-bottom: 8px; 24 | color: var(--sortable-text-primary); 25 | } 26 | 27 | .list-container { 28 | background: var(--sortable-bg-container); 29 | border: 1px solid var(--sortable-border-color); 30 | border-radius: 6px; 31 | overflow: hidden; 32 | box-shadow: 0 1px 3px var(--sortable-shadow); 33 | } 34 | .list-item { 35 | position: relative; 36 | display: flex; 37 | align-items: center; 38 | gap: 6px; 39 | padding: 6px 10px; 40 | background: var(--sortable-bg-item); 41 | border-bottom: 1px solid var(--sortable-border-color); 42 | transition: background-color 0.15s ease, opacity 0.15s ease; 43 | cursor: grab; 44 | } 45 | .list-item:last-child { 46 | border-bottom: none; 47 | } 48 | .list-item:hover { 49 | background-color: var(--sortable-bg-hover); 50 | } 51 | .list-item:hover .remove-button { 52 | opacity: 1; 53 | } 54 | .list-item.dragging { 55 | opacity: 0.5; 56 | cursor: grabbing; 57 | } 58 | .drag-handle { 59 | display: flex; 60 | align-items: center; 61 | justify-content: center; 62 | width: 18px; 63 | height: 18px; 64 | border: none; 65 | background: transparent; 66 | cursor: grab; 67 | color: var(--sortable-text-secondary); 68 | flex-shrink: 0; 69 | } 70 | .drag-handle:active { 71 | cursor: grabbing; 72 | } 73 | .drag-handle svg { 74 | fill: currentColor; 75 | } 76 | .item-label { 77 | flex: 1; 78 | color: var(--sortable-text-primary); 79 | font-size: 14px; 80 | white-space: nowrap; 81 | overflow: hidden; 82 | text-overflow: ellipsis; 83 | } 84 | .item-label.editable { 85 | cursor: pointer; 86 | border-radius: 3px; 87 | padding: 1px 4px; 88 | margin: -1px -4px; 89 | } 90 | .item-label.editable:hover { 91 | background-color: var(--sortable-bg-focus); 92 | } 93 | .item-label.editable:focus { 94 | outline: 2px solid var(--sortable-accent); 95 | outline-offset: 1px; 96 | } 97 | .remove-button { 98 | display: flex; 99 | align-items: center; 100 | justify-content: center; 101 | width: 18px; 102 | height: 18px; 103 | border: none; 104 | background: transparent; 105 | cursor: pointer; 106 | border-radius: 3px; 107 | color: var(--sortable-text-secondary); 108 | flex-shrink: 0; 109 | opacity: 0; 110 | transition: opacity 0.15s ease, background-color 0.15s ease; 111 | } 112 | .remove-button:hover { 113 | background-color: var(--sortable-bg-button-hover); 114 | color: var(--sortable-text-button-hover); 115 | } 116 | .add-input { 117 | width: 100%; 118 | padding: 8px 10px; 119 | margin-top: 8px; 120 | border: none; 121 | font-size: 14px; 122 | outline: none; 123 | background: transparent; 124 | color: var(--sortable-text-secondary); 125 | } 126 | .add-input:focus { 127 | background: var(--sortable-bg-focus); 128 | color: var(--sortable-text-primary); 129 | border-radius: 3px; 130 | } 131 | .edit-input { 132 | flex: 1; 133 | padding: 1px 4px; 134 | margin: -1px -4px; 135 | border: 1px solid var(--sortable-accent); 136 | border-radius: 3px; 137 | font-size: 14px; 138 | font-family: inherit; 139 | background: var(--sortable-bg-item); 140 | color: var(--sortable-text-primary); 141 | outline: none; 142 | } 143 | .drop-indicator { 144 | background-color: var(--sortable-accent) !important; 145 | border-radius: 1px; 146 | } 147 | } 148 | 149 | /* Dark mode styles */ 150 | .dark .draggable-list-widget, 151 | .dark-theme .draggable-list-widget { 152 | --sortable-bg-container: #1e1e1e; 153 | --sortable-bg-item: #2a2a2a; 154 | --sortable-bg-hover: #333333; 155 | --sortable-bg-focus: #333333; 156 | --sortable-bg-button-hover: #3a3a3a; 157 | --sortable-border-color: #3a3a3a; 158 | --sortable-text-primary: #e0e0e0; 159 | --sortable-text-secondary: #a0a0a0; 160 | --sortable-text-button-hover: #f0f0f0; 161 | --sortable-accent: #4d9fff; 162 | --sortable-shadow: rgba(0, 0, 0, 0.3); 163 | } 164 | -------------------------------------------------------------------------------- /wigglystuff/edge_draw.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Iterable, List, Optional, Sequence, Tuple, Union 3 | 4 | import anywidget 5 | import numpy as np 6 | import traitlets 7 | 8 | 9 | class EdgeDraw(anywidget.AnyWidget): 10 | """Sketch node/link diagrams and sync edges as adjacency-friendly data. 11 | 12 | Examples: 13 | ```python 14 | graph = EdgeDraw(names=["A", "B", "C", "D"]) 15 | graph 16 | ``` 17 | """ 18 | 19 | _esm = Path(__file__).parent / "static" / "edgedraw.js" 20 | _css = Path(__file__).parent / "static" / "edgedraw.css" 21 | names = traitlets.List([]).tag(sync=True) 22 | links = traitlets.List([]).tag(sync=True) 23 | directed = traitlets.Bool(True).tag(sync=True) 24 | height = traitlets.Int(400).tag(sync=True) 25 | width = traitlets.Int(600).tag(sync=True) 26 | 27 | @staticmethod 28 | def _coerce_links( 29 | links: Optional[Iterable[Union[Sequence[str], dict]]], 30 | ) -> List[dict]: 31 | normalized: List[dict] = [] 32 | if not links: 33 | return normalized 34 | for link in links: 35 | if isinstance(link, dict): 36 | if "source" in link and "target" in link: 37 | normalized.append({"source": link["source"], "target": link["target"]}) 38 | continue 39 | if isinstance(link, (tuple, list)) and len(link) >= 2: 40 | normalized.append({"source": link[0], "target": link[1]}) 41 | return normalized 42 | 43 | @traitlets.validate("links") 44 | def _validate_links(self, proposal: traitlets.Bunch) -> List[dict]: 45 | return self._coerce_links(proposal.value) 46 | 47 | @staticmethod 48 | def _iter_links( 49 | links: Iterable[dict], 50 | ) -> Iterable[Tuple[str, str]]: 51 | for link in links: 52 | yield link["source"], link["target"] 53 | 54 | def __init__( 55 | self, 56 | names: List[str], 57 | height: int = 400, 58 | width: int = 600, 59 | directed: bool = True, 60 | links: Optional[Iterable[Union[Sequence[str], dict]]] = None, 61 | ) -> None: 62 | """Create an EdgeDraw widget. 63 | 64 | Args: 65 | names: Ordered list of node labels. 66 | height: Canvas height in pixels. 67 | width: Canvas width in pixels. 68 | directed: Whether to draw directed edges with arrowheads. 69 | links: Optional list of (source, target) pairs to seed the widget. 70 | """ 71 | super().__init__( 72 | names=names, 73 | height=height, 74 | width=width, 75 | directed=directed, 76 | links=self._coerce_links(links), 77 | ) 78 | 79 | def get_adjacency_matrix(self, directed: bool = False) -> np.ndarray: 80 | """Create an adjacency matrix from links and node names.""" 81 | num_nodes = len(self.names) 82 | matrix = np.zeros((num_nodes, num_nodes)) 83 | for source, target in self._iter_links(self._coerce_links(self.links)): 84 | src = self.names.index(source) 85 | dst = self.names.index(target) 86 | matrix[src][dst] = 1 87 | if not directed: 88 | matrix[dst][src] = 1 89 | return matrix 90 | 91 | def get_neighbors(self, node_name: str, directed: bool = False) -> List[str]: 92 | """Return neighbors of a node.""" 93 | neighbors = [] 94 | for source, target in self._iter_links(self._coerce_links(self.links)): 95 | if source == node_name: 96 | neighbors.append(target) 97 | if not directed and target == node_name: 98 | neighbors.append(source) 99 | return neighbors 100 | 101 | def has_cycle(self, directed: bool = False) -> bool: 102 | """Check if the graph contains cycles.""" 103 | if directed: 104 | return self._has_cycle_directed() 105 | return self._has_cycle_undirected() 106 | 107 | def _has_cycle_directed(self) -> bool: 108 | """Detect cycles in a directed graph using DFS.""" 109 | visited = set() 110 | rec_stack = set() 111 | 112 | def dfs(node: str) -> bool: 113 | visited.add(node) 114 | rec_stack.add(node) 115 | 116 | for neighbor in self.get_neighbors(node, directed=True): 117 | if neighbor not in visited: 118 | if dfs(neighbor): 119 | return True 120 | elif neighbor in rec_stack: 121 | return True 122 | 123 | rec_stack.remove(node) 124 | return False 125 | 126 | for node in self.names: 127 | if node not in visited and dfs(node): 128 | return True 129 | return False 130 | 131 | def _has_cycle_undirected(self) -> bool: 132 | """Detect cycles in an undirected graph using DFS.""" 133 | visited = set() 134 | 135 | def dfs(node: str, parent: Optional[str]) -> bool: 136 | visited.add(node) 137 | for neighbor in self.get_neighbors(node, directed=False): 138 | if neighbor not in visited: 139 | if dfs(neighbor, node): 140 | return True 141 | elif neighbor != parent: 142 | return True 143 | return False 144 | 145 | for node in self.names: 146 | if node not in visited and dfs(node, None): 147 | return True 148 | return False 149 | -------------------------------------------------------------------------------- /wigglystuff/paint.py: -------------------------------------------------------------------------------- 1 | """Paint widget ported from the mopaint project.""" 2 | 3 | from __future__ import annotations 4 | 5 | import base64 6 | from io import BytesIO 7 | from pathlib import Path 8 | from typing import Any, Optional, Union 9 | import urllib.request 10 | 11 | import anywidget 12 | import traitlets 13 | 14 | DEFAULT_HEIGHT = 500 15 | DEFAULT_WIDTH = 889 16 | 17 | 18 | def base64_to_pil(base64_string: str): 19 | """Convert a base64 string to a PIL Image.""" 20 | from PIL import Image 21 | 22 | if "base64," in base64_string: 23 | base64_string = base64_string.split("base64,")[1] 24 | 25 | img_data = base64.b64decode(base64_string) 26 | return Image.open(BytesIO(img_data)) 27 | 28 | 29 | def pil_to_base64(img): 30 | """Convert a PIL Image to a base64 data URL string.""" 31 | buffered = BytesIO() 32 | img.save(buffered, format="PNG") 33 | img_str = base64.b64encode(buffered.getvalue()).decode() 34 | return f"data:image/png;base64,{img_str}" 35 | 36 | 37 | def create_empty_image(width: int = 500, height: int = 500, background_color=(255, 255, 255, 255)): 38 | """Create an empty image with given dimensions and background color.""" 39 | from PIL import Image 40 | 41 | return Image.new("RGBA", (width, height), background_color) 42 | 43 | 44 | def input_to_pil(input_data: Union[str, Path, "Image.Image", bytes, None]): 45 | """Convert an input object (path, bytes, URL, etc.) into a PIL Image.""" 46 | from PIL import Image 47 | 48 | if input_data is None: 49 | return None 50 | 51 | if hasattr(input_data, "mode") and hasattr(input_data, "size"): 52 | return input_data 53 | 54 | if isinstance(input_data, (str, Path)): 55 | input_str = str(input_data) 56 | 57 | if input_str.startswith(("http://", "https://")): 58 | with urllib.request.urlopen(input_str, timeout=10) as response: 59 | img_data = response.read() 60 | return Image.open(BytesIO(img_data)) 61 | 62 | if "base64," in input_str or ( 63 | len(input_str) > 50 64 | and all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" for c in input_str.replace("\n", "").replace("\r", "")) 65 | ): 66 | return base64_to_pil(input_str) 67 | 68 | file_path = Path(input_str) 69 | if not file_path.exists(): 70 | raise FileNotFoundError(f"Image file not found: {file_path}") 71 | return Image.open(file_path) 72 | 73 | if isinstance(input_data, bytes): 74 | return Image.open(BytesIO(input_data)) 75 | 76 | raise ValueError( 77 | f"Unsupported input type: {type(input_data)}. Expected PIL Image, file path, URL, base64 string, or bytes." 78 | ) 79 | 80 | 81 | class Paint(anywidget.AnyWidget): 82 | """Notebook-friendly paint widget with MS Paint style tools and PIL helpers. 83 | 84 | Examples: 85 | ```python 86 | paint = Paint(width=400, height=300) 87 | paint 88 | ``` 89 | """ 90 | 91 | _esm = Path(__file__).parent / "static" / "paint.js" 92 | _css = Path(__file__).parent / "static" / "paint.css" 93 | 94 | base64 = traitlets.Unicode("").tag(sync=True) 95 | height = traitlets.Int(DEFAULT_HEIGHT).tag(sync=True) 96 | width = traitlets.Int(DEFAULT_WIDTH).tag(sync=True) # rough 16:9 ratio 97 | store_background = traitlets.Bool(True).tag(sync=True) 98 | 99 | def __init__(self, height: int = DEFAULT_HEIGHT, width: int = DEFAULT_WIDTH, store_background: bool = True, init_image: Optional[Any] = None): 100 | """Create a Paint widget. 101 | 102 | Args: 103 | height: Canvas height in pixels. 104 | width: Canvas width in pixels (ignored when ``init_image`` sets aspect ratio). 105 | store_background: Persist previous strokes when background changes. 106 | init_image: Optional path/URL/PIL image/bytes to preload. 107 | """ 108 | super().__init__() 109 | 110 | user_provided_width = width != DEFAULT_WIDTH 111 | user_provided_height = height != DEFAULT_HEIGHT 112 | 113 | if init_image is not None and user_provided_width: 114 | raise ValueError( 115 | "Cannot specify both init_image and explicit width parameter. " 116 | "Canvas width is automatically calculated from the image aspect ratio." 117 | ) 118 | 119 | if init_image is not None: 120 | pil_image = input_to_pil(init_image) 121 | if pil_image is not None: 122 | image_width, image_height = pil_image.size 123 | aspect_ratio = image_width / image_height 124 | 125 | if user_provided_height: 126 | self.height = height 127 | self.width = int(height * aspect_ratio) 128 | else: 129 | self.width = image_width 130 | self.height = image_height 131 | 132 | encoded = pil_to_base64(pil_image).split(",")[1] 133 | self.base64 = encoded 134 | else: 135 | self.width = width 136 | self.height = height 137 | self.base64 = "" 138 | else: 139 | self.width = width 140 | self.height = height 141 | self.base64 = "" 142 | 143 | self.store_background = store_background 144 | 145 | def get_pil(self): 146 | """Return the current drawing as a PIL Image.""" 147 | if not self.base64: 148 | return create_empty_image(width=self.width, height=self.height, background_color=(0, 0, 0, 0)) 149 | 150 | return base64_to_pil(self.base64) 151 | 152 | def get_base64(self) -> str: 153 | """Return the current drawing as a base64 string (data URL).""" 154 | if not self.base64: 155 | return "" 156 | return pil_to_base64(self.get_pil()) 157 | -------------------------------------------------------------------------------- /wigglystuff/static/matrix.js: -------------------------------------------------------------------------------- 1 | function render({model, el}){ 2 | const config = { 3 | rows: model.get("rows"), 4 | cols: model.get("cols"), 5 | minValue: model.get("min_value"), 6 | maxValue: model.get("max_value"), 7 | isMirrored: model.get("mirror"), 8 | stepSize: model.get("step"), 9 | digits: model.get("digits"), 10 | pixelsPerStep: 2, 11 | rowNames: model.get("row_names"), 12 | colNames: model.get("col_names"), 13 | isStatic: model.get("static"), 14 | flexibleColumns: model.get("flexible_cols") 15 | }; 16 | 17 | let matrix = JSON.parse(JSON.stringify(model.get("matrix"))); // Deep copy 18 | 19 | const wrapper = document.createElement('div'); 20 | wrapper.classList.add("matrix-wrapper"); 21 | el.appendChild(wrapper); 22 | 23 | function renderMatrix() { 24 | wrapper.innerHTML = ''; 25 | 26 | // Create main grid container 27 | const gridContainer = document.createElement('div'); 28 | gridContainer.className = 'matrix-grid'; 29 | if (config.flexibleColumns) { 30 | gridContainer.classList.add('flexible-columns'); 31 | } 32 | 33 | // First column for row labels (if they exist) 34 | if (config.rowNames.length > 0) { 35 | const rowLabelColumn = document.createElement('div'); 36 | rowLabelColumn.className = 'matrix-column matrix-label-column'; 37 | 38 | // Empty corner cell if we have column names 39 | if (config.colNames.length > 0) { 40 | const corner = document.createElement('div'); 41 | corner.className = 'matrix-cell matrix-corner-cell'; 42 | rowLabelColumn.appendChild(corner); 43 | } 44 | 45 | // Row labels 46 | config.rowNames.forEach(name => { 47 | const cell = document.createElement('div'); 48 | cell.className = 'matrix-cell matrix-row-label'; 49 | cell.textContent = name; 50 | rowLabelColumn.appendChild(cell); 51 | }); 52 | 53 | gridContainer.appendChild(rowLabelColumn); 54 | } 55 | 56 | // Create matrix container that will hold all data columns 57 | const matrixWrapper = document.createElement('div'); 58 | matrixWrapper.className = 'matrix-data-wrapper'; 59 | 60 | // Create columns for each matrix column 61 | for (let colIndex = 0; colIndex < config.cols; colIndex++) { 62 | const column = document.createElement('div'); 63 | column.className = 'matrix-column matrix-data-column'; 64 | 65 | // Column header if exists 66 | if (config.colNames.length > 0) { 67 | const header = document.createElement('div'); 68 | header.className = 'matrix-cell matrix-col-label'; 69 | header.textContent = config.colNames[colIndex] || ''; 70 | column.appendChild(header); 71 | } 72 | 73 | // Matrix values 74 | matrix.forEach((row, rowIndex) => { 75 | const cell = document.createElement('div'); 76 | cell.className = 'matrix-cell matrix-element'; 77 | if (config.isStatic) { 78 | cell.classList.add('matrix-element-static'); 79 | } 80 | cell.textContent = row[colIndex].toFixed(config.digits); 81 | cell.dataset.row = rowIndex; 82 | cell.dataset.col = colIndex; 83 | if (!config.isStatic) { 84 | cell.addEventListener('mousedown', startDragging); 85 | } 86 | column.appendChild(cell); 87 | }); 88 | 89 | matrixWrapper.appendChild(column); 90 | } 91 | 92 | gridContainer.appendChild(matrixWrapper); 93 | wrapper.appendChild(gridContainer); 94 | 95 | updateModel(); 96 | }; 97 | 98 | function updateModel() { 99 | model.set("matrix", JSON.parse(JSON.stringify(matrix))); // Deep copy 100 | model.save_changes(); 101 | }; 102 | 103 | let updateTimeout; 104 | function debouncedUpdateModel() { 105 | clearTimeout(updateTimeout); 106 | updateTimeout = setTimeout(updateModel, 100); // Debounce for 100ms 107 | }; 108 | 109 | function startDragging(e) { 110 | e.preventDefault(); 111 | const element = e.target; 112 | const startX = e.clientX; 113 | const startValue = parseFloat(element.textContent); 114 | const row = parseInt(element.dataset.row); 115 | const col = parseInt(element.dataset.col); 116 | 117 | function onMouseMove(e) { 118 | const deltaX = e.clientX - startX; 119 | const steps = Math.floor(deltaX / config.pixelsPerStep); 120 | const newValue = Math.max(config.minValue, Math.min(config.maxValue, startValue + steps * config.stepSize)); 121 | updateMatrixValue(row, col, newValue); 122 | renderMatrix(); 123 | debouncedUpdateModel(); 124 | } 125 | 126 | function onMouseUp() { 127 | document.removeEventListener('mousemove', onMouseMove); 128 | document.removeEventListener('mouseup', onMouseUp); 129 | updateModel(); // Ensure final state is updated 130 | } 131 | 132 | document.addEventListener('mousemove', onMouseMove); 133 | document.addEventListener('mouseup', onMouseUp); 134 | }; 135 | 136 | function updateMatrixValue(row, col, value) { 137 | matrix[row][col] = parseFloat(value.toFixed(config.digits)); 138 | if (config.isMirrored && (col < config.rows) && (row < config.cols)) { 139 | matrix[col][row] = parseFloat(value.toFixed(config.digits)); 140 | } 141 | }; 142 | 143 | renderMatrix(); 144 | } 145 | 146 | export default { render }; -------------------------------------------------------------------------------- /wigglystuff/static/webcam-capture.js: -------------------------------------------------------------------------------- 1 | function render({ model, el }) { 2 | const root = document.createElement("div"); 3 | root.className = "webcam-capture"; 4 | 5 | const header = document.createElement("div"); 6 | header.className = "webcam-capture__header"; 7 | 8 | const title = document.createElement("div"); 9 | title.className = "webcam-capture__title"; 10 | title.textContent = "Webcam"; 11 | 12 | const status = document.createElement("div"); 13 | status.className = "webcam-capture__status"; 14 | 15 | header.appendChild(title); 16 | header.appendChild(status); 17 | 18 | const videoWrap = document.createElement("div"); 19 | videoWrap.className = "webcam-capture__video"; 20 | 21 | const video = document.createElement("video"); 22 | video.autoplay = true; 23 | video.playsInline = true; 24 | video.muted = true; 25 | videoWrap.appendChild(video); 26 | 27 | const controls = document.createElement("div"); 28 | controls.className = "webcam-capture__controls"; 29 | 30 | const captureButton = document.createElement("button"); 31 | captureButton.type = "button"; 32 | captureButton.className = "webcam-capture__button"; 33 | captureButton.textContent = "Capture"; 34 | 35 | const toggleWrap = document.createElement("label"); 36 | toggleWrap.className = "webcam-capture__toggle"; 37 | 38 | const toggleInput = document.createElement("input"); 39 | toggleInput.type = "checkbox"; 40 | 41 | const toggleLabel = document.createElement("span"); 42 | toggleLabel.textContent = "Auto-capture"; 43 | 44 | toggleWrap.appendChild(toggleInput); 45 | toggleWrap.appendChild(toggleLabel); 46 | 47 | controls.appendChild(captureButton); 48 | controls.appendChild(toggleWrap); 49 | 50 | root.appendChild(header); 51 | root.appendChild(videoWrap); 52 | root.appendChild(controls); 53 | el.appendChild(root); 54 | 55 | let stream = null; 56 | let intervalId = null; 57 | let streamRequestId = 0; 58 | 59 | const setStatus = (text, tone) => { 60 | status.textContent = text; 61 | status.dataset.tone = tone || "neutral"; 62 | }; 63 | 64 | const stopInterval = () => { 65 | if (intervalId) { 66 | clearInterval(intervalId); 67 | intervalId = null; 68 | } 69 | }; 70 | 71 | const applyCapturingState = () => { 72 | const isCapturing = model.get("capturing"); 73 | toggleInput.checked = Boolean(isCapturing); 74 | stopInterval(); 75 | if (isCapturing) { 76 | const intervalMs = Math.max(0, model.get("interval_ms") || 1000); 77 | intervalId = setInterval(() => { 78 | captureFrame(false); 79 | }, intervalMs); 80 | setStatus(`Auto-capture on`, "active"); 81 | } else if (model.get("error")) { 82 | setStatus(model.get("error"), "error"); 83 | } else if (model.get("ready")) { 84 | setStatus("Preview ready", "ready"); 85 | } 86 | }; 87 | 88 | const captureFrame = (manual) => { 89 | if (!stream || video.videoWidth === 0 || video.videoHeight === 0) { 90 | return; 91 | } 92 | const canvas = document.createElement("canvas"); 93 | canvas.width = video.videoWidth; 94 | canvas.height = video.videoHeight; 95 | const ctx = canvas.getContext("2d"); 96 | if (!ctx) { 97 | return; 98 | } 99 | ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 100 | const dataUrl = canvas.toDataURL("image/png"); 101 | model.set("image_base64", dataUrl); 102 | model.save_changes(); 103 | if (manual && model.get("capturing")) { 104 | model.set("capturing", false); 105 | model.save_changes(); 106 | stopInterval(); 107 | } 108 | }; 109 | 110 | const stopStream = () => { 111 | if (stream) { 112 | stream.getTracks().forEach((track) => track.stop()); 113 | stream = null; 114 | } 115 | }; 116 | 117 | const invalidateStreamRequest = () => { 118 | streamRequestId += 1; 119 | return streamRequestId; 120 | }; 121 | 122 | const startStream = async () => { 123 | const requestId = invalidateStreamRequest(); 124 | stopStream(); 125 | setStatus("Requesting access...", "pending"); 126 | try { 127 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 128 | throw new Error("Webcam access is not available in this environment."); 129 | } 130 | const facingMode = model.get("facing_mode") || "user"; 131 | const constraints = { 132 | video: { facingMode: { ideal: facingMode } }, 133 | audio: false, 134 | }; 135 | const nextStream = await navigator.mediaDevices.getUserMedia(constraints); 136 | if (requestId !== streamRequestId) { 137 | nextStream.getTracks().forEach((track) => track.stop()); 138 | return; 139 | } 140 | stream = nextStream; 141 | video.srcObject = stream; 142 | model.set("ready", true); 143 | model.set("error", ""); 144 | model.save_changes(); 145 | setStatus("Preview ready", "ready"); 146 | applyCapturingState(); 147 | } catch (err) { 148 | if (requestId !== streamRequestId) { 149 | return; 150 | } 151 | const message = err && err.message ? err.message : "Unable to access webcam."; 152 | model.set("ready", false); 153 | model.set("error", message); 154 | model.save_changes(); 155 | setStatus(message, "error"); 156 | } 157 | }; 158 | 159 | captureButton.addEventListener("click", () => captureFrame(true)); 160 | 161 | toggleInput.addEventListener("change", () => { 162 | model.set("capturing", toggleInput.checked); 163 | model.save_changes(); 164 | applyCapturingState(); 165 | }); 166 | 167 | const onCapturingChange = () => applyCapturingState(); 168 | const onIntervalChange = () => { 169 | if (model.get("capturing")) { 170 | applyCapturingState(); 171 | } 172 | }; 173 | const onFacingChange = () => { 174 | startStream(); 175 | }; 176 | const onErrorChange = () => { 177 | if (!model.get("capturing") && model.get("error")) { 178 | setStatus(model.get("error"), "error"); 179 | } 180 | }; 181 | 182 | model.on("change:capturing", onCapturingChange); 183 | model.on("change:interval_ms", onIntervalChange); 184 | model.on("change:facing_mode", onFacingChange); 185 | model.on("change:error", onErrorChange); 186 | 187 | setStatus("Starting preview...", "pending"); 188 | startStream(); 189 | 190 | return () => { 191 | stopInterval(); 192 | invalidateStreamRequest(); 193 | stopStream(); 194 | model.off("change:capturing", onCapturingChange); 195 | model.off("change:interval_ms", onIntervalChange); 196 | model.off("change:facing_mode", onFacingChange); 197 | model.off("change:error", onErrorChange); 198 | }; 199 | } 200 | 201 | export default { render }; 202 | -------------------------------------------------------------------------------- /js/edgedraw.js: -------------------------------------------------------------------------------- 1 | import * as d3 from "./d3.min.js"; 2 | 3 | function render({model, el}){ 4 | 5 | const container = document.createElement('div'); 6 | container.classList.add("matrix-container", "edgedraw"); 7 | el.appendChild(container); 8 | 9 | // Sample nodes 10 | const names = model.get("names"); 11 | const nodes = names.map((name) => ({ id: name, x: 100, y: 100 })); 12 | const nodeOrder = new Map(names.map((name, index) => [name, index])); 13 | const nodeById = new Map(nodes.map((node) => [node.id, node])); 14 | 15 | let links = []; 16 | let selectedNode = null; 17 | let directed = model.get("directed"); 18 | 19 | function hydrateLinks(rawLinks) { 20 | return (rawLinks || []) 21 | .map((link) => { 22 | if (!link || typeof link !== "object") { 23 | return null; 24 | } 25 | return { source: nodeById.get(link.source), target: nodeById.get(link.target) }; 26 | }) 27 | .filter((link) => link && link.source && link.target); 28 | } 29 | 30 | // Set up the SVG 31 | const width = 600; 32 | const height = 400; 33 | const svg = d3.select(container) 34 | .append("svg") 35 | .attr("width", width) 36 | .attr("height", height); 37 | 38 | // Define arrow marker 39 | svg.append("defs").append("marker") 40 | .attr("id", "arrowhead") 41 | .attr("viewBox", "-0 -5 10 10") 42 | .attr("refX", 13) 43 | .attr("refY", 0) 44 | .attr("orient", "auto") 45 | .attr("markerWidth", 6) 46 | .attr("markerHeight", 6) 47 | .append("path") 48 | .attr("d", "M0,-5L10,0L0,5") 49 | .attr("class", "arrow"); 50 | 51 | // Force simulation with gentler forces 52 | links = hydrateLinks(model.get("links")); 53 | 54 | const simulation = d3.forceSimulation(nodes) 55 | .force("link", d3.forceLink(links).id(d => d.id).distance(100)) 56 | .force("charge", d3.forceManyBody().strength(-50)) 57 | .force("center", d3.forceCenter(width / 2, height / 2)) 58 | .force("collide", d3.forceCollide().radius(30)) 59 | .on("tick", ticked); 60 | 61 | // Create the link group and node group 62 | const linkGroup = svg.append("g"); 63 | const nodeGroup = svg.append("g"); 64 | 65 | // Draw nodes 66 | const node = nodeGroup.selectAll(".node") 67 | .data(nodes) 68 | .join("circle") 69 | .attr("class", "node") 70 | .attr("r", 10) 71 | .on("click", handleNodeClick); 72 | 73 | // Add labels 74 | const labels = nodeGroup.selectAll(".label") 75 | .data(nodes) 76 | .join("text") 77 | .attr("class", "label") 78 | .attr("dx", 15) 79 | .attr("dy", 4) 80 | .text(d => d.id); 81 | 82 | function canonicalizeEdge(a, b) { 83 | if (nodeOrder.get(a.id) <= nodeOrder.get(b.id)) { 84 | return { source: a, target: b }; 85 | } 86 | return { source: b, target: a }; 87 | } 88 | 89 | function handleNodeClick(event, d) { 90 | if (!selectedNode) { 91 | // First click - select node 92 | selectedNode = d; 93 | node.classed("selected", n => n === selectedNode); 94 | } else if (selectedNode === d) { 95 | // Clicked same node - deselect 96 | selectedNode = null; 97 | node.classed("selected", false); 98 | } else { 99 | // Second click - create link 100 | // Check if a link already exists in either direction 101 | const existingForwardLink = links.find(l => 102 | (l.source === selectedNode && l.target === d) || 103 | (l.source.id === selectedNode.id && l.target.id === d.id) 104 | ); 105 | const existingReverseLink = links.find(l => 106 | (l.source === d && l.target === selectedNode) || 107 | (l.source.id === d.id && l.target.id === selectedNode.id) 108 | ); 109 | const existingUndirectedLink = existingForwardLink || existingReverseLink; 110 | 111 | if (!directed) { 112 | if (existingUndirectedLink) { 113 | links = links.filter(l => l !== existingUndirectedLink); 114 | } else { 115 | links.push(canonicalizeEdge(selectedNode, d)); 116 | } 117 | } else { 118 | // Remove existing reverse link if present 119 | if (existingReverseLink) { 120 | links = links.filter(l => l !== existingReverseLink); 121 | } 122 | 123 | // Add new link if no forward link exists 124 | if (!existingForwardLink) { 125 | links.push({source: selectedNode, target: d}); 126 | } 127 | } 128 | 129 | simulation.force("link").links(links); 130 | selectedNode = null; 131 | node.classed("selected", false); 132 | updateLinks(); 133 | } 134 | model.set("links", links.map(l => ({source: l.source.id, target: l.target.id}))); 135 | console.log(links.map(l => ({source: l.source.id, target: l.target.id}))); 136 | model.save_changes(); 137 | } 138 | 139 | function updateLinks() { 140 | const link = linkGroup.selectAll(".link") 141 | .data(links) 142 | .join("path") 143 | .attr("class", "link") 144 | .attr("marker-end", directed ? "url(#arrowhead)" : null) 145 | .attr("d", d => { 146 | const dx = d.target.x - d.source.x; 147 | const dy = d.target.y - d.source.y; 148 | return `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`; 149 | }) 150 | .on("click", function(event, d) { 151 | event.stopPropagation(); 152 | // Remove link on click 153 | links = links.filter(l => l !== d); 154 | simulation.force("link").links(links); 155 | updateLinks(); 156 | model.set("links", links.map(l => ({source: l.source.id, target: l.target.id}))); 157 | console.log(links.map(l => ({source: l.source.id, target: l.target.id}))); 158 | model.save_changes(); 159 | }); 160 | } 161 | 162 | function ticked() { 163 | // Update node positions 164 | node 165 | .attr("cx", d => d.x = Math.max(15, Math.min(width - 15, d.x))) 166 | .attr("cy", d => d.y = Math.max(15, Math.min(height - 15, d.y))); 167 | 168 | labels 169 | .attr("x", d => d.x) 170 | .attr("y", d => d.y); 171 | 172 | // Update link positions 173 | updateLinks(); 174 | } 175 | 176 | // Add drag behavior just for repositioning 177 | const drag = d3.drag() 178 | .on("drag", function(event, d) { 179 | d.x = event.x; 180 | d.y = event.y; 181 | simulation.alpha(0.1).restart(); 182 | }); 183 | 184 | node.call(drag); 185 | 186 | model.on("change:directed", () => { 187 | directed = model.get("directed"); 188 | updateLinks(); 189 | }); 190 | 191 | model.on("change:links", () => { 192 | links = hydrateLinks(model.get("links")); 193 | simulation.force("link").links(links); 194 | updateLinks(); 195 | }); 196 | }; 197 | 198 | export default { render }; 199 | -------------------------------------------------------------------------------- /wigglystuff/static/sortable-list.js: -------------------------------------------------------------------------------- 1 | function render({ model, el }) { 2 | el.classList.add("draggable-list-widget"); 3 | 4 | let draggedItem = null; 5 | let draggedIndex = null; 6 | let dropTarget = null; 7 | let dropPosition = null; 8 | 9 | function renderList() { 10 | el.replaceChildren(); 11 | 12 | // Render label if provided 13 | let labelText = model.get("label"); 14 | if (labelText) { 15 | let labelElement = document.createElement("label"); 16 | labelElement.className = "list-label"; 17 | labelElement.textContent = labelText; 18 | el.appendChild(labelElement); 19 | } 20 | 21 | let container = document.createElement("div"); 22 | container.className = "list-container"; 23 | 24 | model.get("value").forEach((item, index) => { 25 | let listItem = document.createElement("div"); 26 | listItem.className = "list-item"; 27 | listItem.draggable = true; 28 | listItem.dataset.index = index; 29 | 30 | let dragHandle = document.createElement("button"); 31 | dragHandle.className = "drag-handle"; 32 | dragHandle.innerHTML = ` 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | `; 42 | dragHandle.setAttribute("aria-label", `Reorder ${item}`); 43 | 44 | let label = document.createElement("span"); 45 | label.className = "item-label"; 46 | label.textContent = item; 47 | 48 | if (model.get("editable")) { 49 | label.classList.add("editable"); 50 | label.setAttribute("tabindex", "0"); 51 | label.onclick = (e) => { 52 | e.stopPropagation(); 53 | startEdit(label, index); 54 | }; 55 | label.onkeydown = (e) => { 56 | if (e.key === "Enter" || e.key === " ") { 57 | e.preventDefault(); 58 | e.stopPropagation(); 59 | startEdit(label, index); 60 | } 61 | }; 62 | } 63 | 64 | let removeButton = null; 65 | if (model.get("removable")) { 66 | removeButton = document.createElement("button"); 67 | removeButton.className = "remove-button"; 68 | removeButton.innerHTML = ` 69 | 70 | 71 | 72 | `; 73 | removeButton.setAttribute("aria-label", `Remove ${item}`); 74 | removeButton.onclick = (e) => { 75 | e.stopPropagation(); 76 | removeItem(index); 77 | }; 78 | } 79 | 80 | listItem.appendChild(dragHandle); 81 | listItem.appendChild(label); 82 | if (removeButton) { 83 | listItem.appendChild(removeButton); 84 | } 85 | 86 | listItem.addEventListener("dragstart", (e) => { 87 | draggedItem = listItem; 88 | draggedIndex = index; 89 | listItem.classList.add("dragging"); 90 | e.dataTransfer.effectAllowed = "move"; 91 | e.dataTransfer.setData("text/html", listItem.outerHTML); 92 | }); 93 | 94 | listItem.addEventListener("dragend", () => { 95 | listItem.classList.remove("dragging"); 96 | draggedItem = null; 97 | draggedIndex = null; 98 | clearDropIndicators(); 99 | }); 100 | 101 | listItem.addEventListener("dragover", (e) => { 102 | if (draggedItem && draggedItem !== listItem) { 103 | e.preventDefault(); 104 | e.dataTransfer.dropEffect = "move"; 105 | 106 | let rect = listItem.getBoundingClientRect(); 107 | let midpoint = rect.top + rect.height / 2; 108 | let newDropPosition = e.clientY < midpoint ? "top" : "bottom"; 109 | 110 | if (dropTarget !== listItem || dropPosition !== newDropPosition) { 111 | clearDropIndicators(); 112 | dropTarget = listItem; 113 | dropPosition = newDropPosition; 114 | showDropIndicator(listItem, newDropPosition); 115 | } 116 | } 117 | }); 118 | 119 | listItem.addEventListener("dragleave", (e) => { 120 | if (!listItem.contains(e.relatedTarget)) { 121 | clearDropIndicators(); 122 | } 123 | }); 124 | 125 | listItem.addEventListener("drop", (e) => { 126 | e.preventDefault(); 127 | if (draggedItem && draggedItem !== listItem) { 128 | let targetIndex = parseInt(listItem.dataset.index); 129 | let newIndex = targetIndex; 130 | 131 | if (dropPosition === "bottom") { 132 | newIndex = targetIndex + 1; 133 | } 134 | 135 | if (draggedIndex < newIndex) { 136 | newIndex--; 137 | } 138 | 139 | reorderItems(draggedIndex, newIndex); 140 | } 141 | clearDropIndicators(); 142 | }); 143 | 144 | container.appendChild(listItem); 145 | }); 146 | 147 | el.appendChild(container); 148 | 149 | if (model.get("addable")) { 150 | let addInput = document.createElement("input"); 151 | addInput.type = "text"; 152 | addInput.className = "add-input"; 153 | addInput.placeholder = "Add new item..."; 154 | addInput.onkeydown = (e) => { 155 | if (e.key === "Enter" && addInput.value.trim()) { 156 | e.preventDefault(); 157 | addItem(addInput.value.trim()); 158 | addInput.value = ""; 159 | addInput.focus(); 160 | } 161 | }; 162 | 163 | el.appendChild(addInput); 164 | } 165 | } 166 | 167 | function addItem(text) { 168 | model.set("value", [...model.get("value"), text]); 169 | model.save_changes(); 170 | } 171 | 172 | function removeItem(index) { 173 | model.set("value", model.get("value").toSpliced(index, 1)); 174 | model.save_changes(); 175 | } 176 | 177 | function showDropIndicator(element, position) { 178 | let indicator = document.createElement("div"); 179 | indicator.className = "drop-indicator"; 180 | indicator.style.position = "absolute"; 181 | indicator.style.left = "0"; 182 | indicator.style.right = "0"; 183 | indicator.style.height = "2px"; 184 | indicator.style.backgroundColor = "#0066cc"; 185 | indicator.style.zIndex = "1000"; 186 | 187 | if (position === "top") { 188 | indicator.style.top = "-1px"; 189 | } else { 190 | indicator.style.bottom = "-1px"; 191 | } 192 | 193 | element.style.position = "relative"; 194 | element.appendChild(indicator); 195 | } 196 | 197 | function clearDropIndicators() { 198 | el.querySelectorAll(".drop-indicator").forEach(indicator => { 199 | indicator.remove(); 200 | }); 201 | dropTarget = null; 202 | dropPosition = null; 203 | } 204 | 205 | function reorderItems(fromIndex, toIndex) { 206 | let items = [...model.get("value")]; 207 | let [movedItem] = items.splice(fromIndex, 1); 208 | items.splice(toIndex, 0, movedItem); 209 | model.set("value", items); 210 | model.save_changes(); 211 | } 212 | 213 | function startEdit(label, index) { 214 | let currentText = label.textContent; 215 | let input = document.createElement("input"); 216 | input.type = "text"; 217 | input.className = "edit-input"; 218 | input.value = currentText; 219 | 220 | function finishEdit(save = false) { 221 | if (save && input.value.trim() && input.value.trim() !== currentText) { 222 | let items = [...model.get("value")]; 223 | items[index] = input.value.trim(); 224 | model.set("value", items); 225 | model.save_changes(); 226 | } else { 227 | label.textContent = currentText; 228 | label.style.display = ""; 229 | input.remove(); 230 | } 231 | } 232 | 233 | input.onblur = () => finishEdit(true); 234 | input.onkeydown = (e) => { 235 | if (e.key === "Enter") { 236 | e.preventDefault(); 237 | finishEdit(true); 238 | } else if (e.key === "Escape") { 239 | e.preventDefault(); 240 | finishEdit(false); 241 | } 242 | e.stopPropagation(); 243 | }; 244 | 245 | label.style.display = "none"; 246 | label.parentNode.insertBefore(input, label.nextSibling); 247 | input.focus(); 248 | input.select(); 249 | } 250 | 251 | renderList(); 252 | model.on("change:value", renderList); 253 | model.on("change:label", renderList); 254 | } 255 | 256 | export default { render }; -------------------------------------------------------------------------------- /notebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "2eb5e2f9-4a19-4fdf-b320-ef9b920f5911", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "%load_ext autoreload\n", 11 | "%autoreload 2" 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": 2, 17 | "id": "9dfa1674-113f-4a90-bfb4-d668dd75df40", 18 | "metadata": {}, 19 | "outputs": [], 20 | "source": [ 21 | "import anywidget\n", 22 | "from pathlib import Path\n", 23 | "import traitlets\n", 24 | "\n", 25 | "class Matrix(anywidget.AnyWidget):\n", 26 | " \"\"\"\n", 27 | " A very small excel experience for some quick number entry\n", 28 | " \"\"\"\n", 29 | " _esm = Path(\"wigglystuff\") / 'static' / 'matrix.js'\n", 30 | " _css = Path(\"wigglystuff\") / 'static' / 'matrix.css'\n", 31 | " rows = traitlets.Int(3).tag(sync=True)\n", 32 | " cols = traitlets.Int(3).tag(sync=True)\n", 33 | " min_value = traitlets.Float(-100.0).tag(sync=True)\n", 34 | " max_value = traitlets.Float(100.0).tag(sync=True)\n", 35 | " step = traitlets.Float(1.0).tag(sync=True)\n", 36 | " triangular = traitlets.Bool(False).tag(sync=True)\n", 37 | " matrix = traitlets.List([]).tag(sync=True)\n", 38 | "\n", 39 | " def __init__(self, matrix=None, rows=3, cols=3, min_value=-100, max_value=100, triangular=False, **kwargs):\n", 40 | " # if triangular and (rows != cols):\n", 41 | " # raise ValueError(\"triangular setting is only meant for square matrices\")\n", 42 | " if matrix:\n", 43 | " matrix = np.array(matrix)\n", 44 | " rows, cols = matrix.shape\n", 45 | " matrix = matrix.to_list()\n", 46 | " else:\n", 47 | " matrix = [[(min_value + max_value) / 2 for i in range(cols)] for j in range(rows)]\n", 48 | " super().__init__(matrix=matrix, rows=rows, cols=cols, triangular=triangular, **kwargs)\n" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 3, 54 | "id": "eca28d66-3bf1-44be-9832-a74bc4f2584b", 55 | "metadata": {}, 56 | "outputs": [], 57 | "source": [ 58 | "m = Matrix(rows=4, cols=2, triangular=True)" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 4, 64 | "id": "9385e6ec-d9da-472d-ae59-88aa58605a22", 65 | "metadata": {}, 66 | "outputs": [ 67 | { 68 | "data": { 69 | "application/vnd.jupyter.widget-view+json": { 70 | "model_id": "a261cd93450d46039e12bc4f47d15298", 71 | "version_major": 2, 72 | "version_minor": 1 73 | }, 74 | "text/plain": [ 75 | "Matrix(cols=2, matrix=[[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], rows=4, triangular=True)" 76 | ] 77 | }, 78 | "execution_count": 4, 79 | "metadata": {}, 80 | "output_type": "execute_result" 81 | } 82 | ], 83 | "source": [ 84 | "m" 85 | ] 86 | }, 87 | { 88 | "cell_type": "code", 89 | "execution_count": 5, 90 | "id": "bf65ac56-aa53-447d-a581-e6958529b759", 91 | "metadata": {}, 92 | "outputs": [ 93 | { 94 | "data": { 95 | "text/plain": [ 96 | "array([[0., 0.],\n", 97 | " [0., 0.],\n", 98 | " [0., 0.],\n", 99 | " [0., 0.]])" 100 | ] 101 | }, 102 | "execution_count": 5, 103 | "metadata": {}, 104 | "output_type": "execute_result" 105 | } 106 | ], 107 | "source": [ 108 | "import numpy as np \n", 109 | "np.array(m.matrix)" 110 | ] 111 | }, 112 | { 113 | "cell_type": "code", 114 | "execution_count": 6, 115 | "id": "2ae2042e-97fa-4ac2-8329-30df9e16c676", 116 | "metadata": {}, 117 | "outputs": [ 118 | { 119 | "data": { 120 | "application/vnd.jupyter.widget-view+json": { 121 | "model_id": "3acf71db8e00450c841fc7a3e524d610", 122 | "version_major": 2, 123 | "version_minor": 1 124 | }, 125 | "text/plain": [ 126 | "Slider2D()" 127 | ] 128 | }, 129 | "execution_count": 6, 130 | "metadata": {}, 131 | "output_type": "execute_result" 132 | } 133 | ], 134 | "source": [ 135 | "from wigglystuff import Slider2D\n", 136 | "Slider2D()" 137 | ] 138 | }, 139 | { 140 | "cell_type": "code", 141 | "execution_count": 11, 142 | "id": "41333b38-069e-43d7-ba6f-1e02aa0af1c4", 143 | "metadata": {}, 144 | "outputs": [ 145 | { 146 | "data": { 147 | "application/vnd.jupyter.widget-view+json": { 148 | "model_id": "22966591c1a44115ad235b1f06f4a25e", 149 | "version_major": 2, 150 | "version_minor": 1 151 | }, 152 | "text/plain": [ 153 | "TangleSlider()" 154 | ] 155 | }, 156 | "execution_count": 11, 157 | "metadata": {}, 158 | "output_type": "execute_result" 159 | } 160 | ], 161 | "source": [ 162 | "class TangleSlider(anywidget.AnyWidget):\n", 163 | " \"\"\"\n", 164 | " A very small excel experience for some quick number entry\n", 165 | " \"\"\"\n", 166 | " _esm = Path('wigglystuff') / 'static' / 'tangle-slider.js'\n", 167 | " value = traitlets.Float(0.0).tag(sync=True)\n", 168 | " min_value = traitlets.Float(-100.0).tag(sync=True)\n", 169 | " max_value = traitlets.Float(100.0).tag(sync=True)\n", 170 | " step = traitlets.Float(1.0).tag(sync=True)\n", 171 | " pixels_per_step = traitlets.Int(2).tag(sync=True)\n", 172 | "\n", 173 | " def __init__(self, value=0.0, min_value=-100, max_value=100, step=1.0, pixels_per_step=2, **kwargs):\n", 174 | " super().__init__(value=value, min_value=min_value, max_value=max_value, step=step, pixels_per_step=pixels_per_step, **kwargs)\n", 175 | "\n", 176 | "TangleSlider()" 177 | ] 178 | }, 179 | { 180 | "cell_type": "code", 181 | "execution_count": 13, 182 | "id": "a277107a-52b3-48ab-b727-51cfebcdf0b8", 183 | "metadata": {}, 184 | "outputs": [ 185 | { 186 | "ename": "AttributeError", 187 | "evalue": "module 'traitlets' has no attribute 'String'", 188 | "output_type": "error", 189 | "traceback": [ 190 | "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", 191 | "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", 192 | "Cell \u001b[0;32mIn[13], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;28;01mclass\u001b[39;00m \u001b[38;5;21;01mTangleChoice\u001b[39;00m(anywidget\u001b[38;5;241m.\u001b[39mAnyWidget):\n\u001b[1;32m 2\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 3\u001b[0m \u001b[38;5;124;03m A UI element like tangle.js but for Python to make choices. \u001b[39;00m\n\u001b[1;32m 4\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[1;32m 5\u001b[0m _esm \u001b[38;5;241m=\u001b[39m Path(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwigglystuff\u001b[39m\u001b[38;5;124m\"\u001b[39m) \u001b[38;5;241m/\u001b[39m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mstatic\u001b[39m\u001b[38;5;124m'\u001b[39m \u001b[38;5;241m/\u001b[39m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mtangle-choice.js\u001b[39m\u001b[38;5;124m'\u001b[39m\n", 193 | "Cell \u001b[0;32mIn[13], line 6\u001b[0m, in \u001b[0;36mTangleChoice\u001b[0;34m()\u001b[0m\n\u001b[1;32m 2\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 3\u001b[0m \u001b[38;5;124;03mA UI element like tangle.js but for Python to make choices. \u001b[39;00m\n\u001b[1;32m 4\u001b[0m \u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 5\u001b[0m _esm \u001b[38;5;241m=\u001b[39m Path(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwigglystuff\u001b[39m\u001b[38;5;124m\"\u001b[39m) \u001b[38;5;241m/\u001b[39m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mstatic\u001b[39m\u001b[38;5;124m'\u001b[39m \u001b[38;5;241m/\u001b[39m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mtangle-choice.js\u001b[39m\u001b[38;5;124m'\u001b[39m\n\u001b[0;32m----> 6\u001b[0m value \u001b[38;5;241m=\u001b[39m \u001b[43mtraitlets\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mString\u001b[49m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtag(sync\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mTrue\u001b[39;00m)\n\u001b[1;32m 7\u001b[0m choices \u001b[38;5;241m=\u001b[39m traitlets\u001b[38;5;241m.\u001b[39mList([])\u001b[38;5;241m.\u001b[39mtag(sync\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mTrue\u001b[39;00m)\n\u001b[1;32m 9\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21m__init__\u001b[39m(\u001b[38;5;28mself\u001b[39m, value\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m\"\u001b[39m, choices\u001b[38;5;241m=\u001b[39m[], \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs):\n", 194 | "\u001b[0;31mAttributeError\u001b[0m: module 'traitlets' has no attribute 'String'" 195 | ] 196 | } 197 | ], 198 | "source": [ 199 | "class TangleChoice(anywidget.AnyWidget):\n", 200 | " \"\"\"\n", 201 | " A UI element like tangle.js but for Python to make choices. \n", 202 | " \"\"\"\n", 203 | " _esm = Path(\"wigglystuff\") / 'static' / 'tangle-choice.js'\n", 204 | " value = traitlets.(\"\").tag(sync=True)\n", 205 | " choices = traitlets.List([]).tag(sync=True)\n", 206 | "\n", 207 | " def __init__(self, value=\"\", choices=[], **kwargs):\n", 208 | " super().__init__(value=value, choices=choices, **kwargs)\n", 209 | "\n", 210 | "TangleChoice()" 211 | ] 212 | }, 213 | { 214 | "cell_type": "code", 215 | "execution_count": null, 216 | "id": "160e3522-f81a-486d-a1b0-a189bba6398e", 217 | "metadata": {}, 218 | "outputs": [], 219 | "source": [] 220 | } 221 | ], 222 | "metadata": { 223 | "kernelspec": { 224 | "display_name": "Python 3 (ipykernel)", 225 | "language": "python", 226 | "name": "python3" 227 | }, 228 | "language_info": { 229 | "codemirror_mode": { 230 | "name": "ipython", 231 | "version": 3 232 | }, 233 | "file_extension": ".py", 234 | "mimetype": "text/x-python", 235 | "name": "python", 236 | "nbconvert_exporter": "python", 237 | "pygments_lexer": "ipython3", 238 | "version": "3.10.14" 239 | } 240 | }, 241 | "nbformat": 4, 242 | "nbformat_minor": 5 243 | } 244 | -------------------------------------------------------------------------------- /notebook.py: -------------------------------------------------------------------------------- 1 | import marimo 2 | 3 | __generated_with = "0.14.0" 4 | app = marimo.App() 5 | 6 | 7 | @app.cell 8 | def _(alt, df, df_base, mo, slider_2d): 9 | chart = ( 10 | alt.Chart(df_base).mark_point(color="gray").encode(x="x", y="y") + 11 | alt.Chart(df).mark_point().encode(x="x", y="y") 12 | ).properties(width=300, height=300) 13 | 14 | mo.vstack([ 15 | mo.md(""" 16 | ## `Slider2D` demo 17 | 18 | ```python 19 | from wigglystuff import Slider2D 20 | 21 | slider_2d = Slider2D( 22 | width=300, 23 | height=300, 24 | x_bounds=(0.0, 1.0), 25 | y_bounds=(0.0, 1.0) 26 | ) 27 | ``` 28 | 29 | This demo contains a two dimensional slider. The thinking is that sometimes you want to be able to make changes to two variables at the same time."""), 30 | mo.hstack([slider_2d, chart]) 31 | ]) 32 | return 33 | 34 | 35 | @app.cell 36 | def _(alt, arr, df_orig, mat, mo, np, pd): 37 | x_sim = np.random.multivariate_normal( 38 | np.array(arr.matrix).reshape(-1), 39 | np.array(mat.matrix), 40 | 2500 41 | ) 42 | df_sim = pd.DataFrame({"x": x_sim[:, 0], "y": x_sim[:, 1]}) 43 | 44 | chart_sim = ( 45 | alt.Chart(df_sim).mark_point().encode(x="x", y="y") + 46 | alt.Chart(df_orig).mark_point(color="gray").encode(x="x", y="y") 47 | ) 48 | 49 | mo.vstack([ 50 | mo.md(""" 51 | ## `Matrix` demo 52 | 53 | ```python 54 | from wigglystuff import Matrix 55 | 56 | arr = Matrix(rows=1, cols=2, step=0.1) 57 | mat = Matrix(matrix=np.eye(2), mirror=True, step=0.1) 58 | ``` 59 | 60 | This demo contains a representation of a two dimensional gaussian distribution. You can adapt the center by changing the first array that represents the mean and the variance can be updated by alterering the second one that represents the covariance matrix. Notice how the latter matrix has a triangular constraint."""), 61 | mo.hstack([arr, mat, chart_sim]) 62 | ]) 63 | return 64 | 65 | 66 | @app.cell 67 | def _(Matrix, mo, np, pd): 68 | pca_mat = mo.ui.anywidget(Matrix(np.random.normal(0, 1, size=(3, 2)), step=0.1)) 69 | rgb_mat = np.random.randint(0, 255, size=(1000, 3)) 70 | color = ["#{0:02x}{1:02x}{2:02x}".format(r, g, b) for r,g,b in rgb_mat] 71 | 72 | rgb_df = pd.DataFrame({ 73 | "r": rgb_mat[:, 0], "g": rgb_mat[:, 1], "b": rgb_mat[:, 2], 'color': color 74 | }) 75 | return color, pca_mat, rgb_mat 76 | 77 | 78 | @app.cell 79 | def _(alt, color, mo, pca_mat, pd, rgb_mat): 80 | X_tfm = rgb_mat @ pca_mat.matrix 81 | df_pca = pd.DataFrame({"x": X_tfm[:, 0], "y": X_tfm[:, 1], "c": color}) 82 | pca_chart = alt.Chart(df_pca).mark_point().encode(x="x", y="y", color=alt.Color('c:N', scale = None)) 83 | 84 | mo.vstack([ 85 | mo.md(""" 86 | ### PCA demo with `Matrix` 87 | 88 | Ever want to do your own PCA? Try to figure out a mapping from a 3d color map to a 2d representation with the transformation matrix below."""), 89 | mo.hstack([pca_mat, pca_chart]) 90 | ]) 91 | return 92 | 93 | 94 | @app.cell 95 | def _(c, coffees, mo, price, prob1, prob2, saying, shouting, times, total): 96 | mo.vstack([ 97 | mo.md(f""" 98 | ## Tangle objects 99 | 100 | Very much inspired by [tangle.js](), this library also offers some sliders/choice elements that can natively be combined in markdown. 101 | 102 | ```python 103 | from wigglystuff import TangleSlider 104 | ``` 105 | 106 | There are some examples below. 107 | ### Apples example 108 | 109 | Suppose that you have {coffees} and they each cost {price} then in total you would need to spend ${total:.2f}. 110 | 111 | ### Amdhals law 112 | 113 | You cannot always get a speedup by throwing more compute at a problem. Let's compare two scenarios. 114 | 115 | - You might have a parallel program that needs to sync up {prob1}. 116 | - Another parallel program needs to sync up {prob2}. 117 | 118 | The consequences of these choices are shown below. You might be suprised at the result, but you need to remember that if you throw more cores at the problem then you will also have more cores that will idle when the program needs to sync. 119 | 120 | """), 121 | c, 122 | mo.md(f""" 123 | ### Also a choice widget 124 | 125 | The slider widget can do numeric values for you, but sometimes you also want to make a choice between discrete choices. For that, you can use the `TangleChoice` widget. 126 | 127 | ```python 128 | from wigglystuff import TangleChoice 129 | ``` 130 | 131 | As a quick demo, let's repeat {saying} {times}. 132 | 133 | {" ".join([saying.choice] * int(times.amount))} 134 | """ 135 | ), 136 | mo.md(f""" 137 | ### Also a select widget 138 | 139 | Like `TangleChoice` but as a drop-down 140 | 141 | ```python 142 | from wigglystuff import TangleSelect 143 | ``` 144 | 145 | As a quick demo, let's repeat {shouting} {times}. 146 | 147 | {" ".join([shouting.choice] * int(times.amount))} 148 | """ 149 | ) 150 | ]) 151 | return 152 | 153 | 154 | @app.cell 155 | def _(color_picker, mo): 156 | mo.vstack( 157 | [ 158 | mo.md(f""" 159 | ## Pick colors 160 | 161 | Pick colors using a standard browser color input. 162 | 163 | ```python 164 | from wigglystuff import ColorPicker 165 | ColorPicker(color="#444444") 166 | ``` 167 | 168 | You can use a color picker with marimo's `Html` to affect how things are rendered. 169 | """), 170 | mo.Html(f'

Change my color!

'), 171 | mo.hstack([ 172 | color_picker, 173 | mo.md(f"You selected {color_picker.value['color']} which is {color_picker.rgb} in RGB values.") 174 | ]), 175 | ] 176 | ) 177 | return 178 | 179 | 180 | @app.cell 181 | def _(edge_widget, mo): 182 | mo.vstack([ 183 | mo.md(f""" 184 | ## Drawing Edges 185 | 186 | We even have a tool that allows you to connect nodes by drawing edges! 187 | 188 | ```python 189 | from wigglystuff import EdgeDraw 190 | EdgeDraw(["a", "b", "c", "d"]) 191 | ``` 192 | 193 | Try it yourself by drawing below. 194 | """), 195 | edge_widget, 196 | mo.md(f""" 197 | As you draw more nodes, you will also update the `widget.links` property. 198 | """), 199 | edge_widget.links, 200 | edge_widget.get_adjacency_matrix(), 201 | edge_widget.get_neighbors("c", digraph=True) 202 | 203 | ]) 204 | return 205 | 206 | 207 | @app.cell 208 | def _(mo, sortable_list): 209 | mo.vstack([ 210 | mo.md(""" 211 | ## Sortable Lists 212 | 213 | ```python 214 | from wigglystuff import SortableList 215 | SortableList(["Action", "Comedy", "Drama"], addable=True, removable=True, editable=True) 216 | ``` 217 | 218 | Try dragging items to reorder, adding new items, clicking to edit, or removing with the [x] buttons. 219 | """), 220 | sortable_list, 221 | mo.md(f"Current value: `{sortable_list.value}`") 222 | ]) 223 | return 224 | 225 | 226 | @app.cell 227 | def _(mo): 228 | mo.md(r"""## Appendix with all supporting code""") 229 | return 230 | 231 | 232 | @app.cell 233 | def _(mo): 234 | from wigglystuff import EdgeDraw 235 | 236 | edge_widget = mo.ui.anywidget(EdgeDraw(["a", "b", "c", "d"], width=400, height=400)) 237 | return (edge_widget,) 238 | 239 | 240 | @app.cell 241 | def _(coffees, price): 242 | # You need to define derivates in other cells. 243 | total = coffees.amount * price.amount 244 | return (total,) 245 | 246 | 247 | @app.cell 248 | def _(alt, np, pd, prob1, prob2): 249 | cores = np.arange(1, 64 + 1) 250 | p1, p2 = prob1.amount/100, prob2.amount/100 251 | eff1 = 1/(p1 + (1-p1)/cores) 252 | eff2 = 1/(p2 + (1-p2)/cores) 253 | 254 | df_amdahl = pd.DataFrame({ 255 | 'cores': cores, 256 | f'{prob1.amount:.2f}% sync rate': eff1, 257 | f'{prob2.amount:.2f}% sync rate': eff2 258 | }).melt("cores") 259 | 260 | c = ( 261 | alt.Chart(df_amdahl) 262 | .mark_line() 263 | .encode( 264 | x='cores', 265 | y=alt.Y('value').title("effective cores"), 266 | color="variable" 267 | ) 268 | .properties(width=500, title="Comparison between cores and actual speedup.") 269 | ) 270 | return (c,) 271 | 272 | 273 | @app.cell 274 | def _(mo): 275 | from wigglystuff import TangleSlider, TangleChoice, TangleSelect 276 | 277 | coffees = mo.ui.anywidget(TangleSlider(amount=10, min_value=0, step=1, suffix=" coffees", digits=0)) 278 | price = mo.ui.anywidget(TangleSlider(amount=3.50, min_value=0.01, max_value=10, step=0.01, prefix="$", digits=2)) 279 | prob1 = mo.ui.anywidget(TangleSlider(min_value=0, max_value=20, step=0.1, suffix="% of the time", amount=5)) 280 | prob2 = mo.ui.anywidget(TangleSlider(min_value=0, max_value=20, step=0.1, suffix="% of the time", amount=0)) 281 | saying = mo.ui.anywidget(TangleChoice(["🙂", "🎉", "💥"])) 282 | shouting = mo.ui.anywidget(TangleSelect(["🥔", "🥕", "🍎"])) 283 | times = mo.ui.anywidget(TangleSlider(min_value=1, max_value=20, step=1, suffix=" times", amount=3)) 284 | return coffees, price, prob1, prob2, saying, shouting, times 285 | 286 | 287 | @app.cell 288 | def _(): 289 | import altair as alt 290 | import marimo as mo 291 | import micropip 292 | import numpy as np 293 | import pandas as pd 294 | 295 | # await micropip.install("wigglystuff==0.1.1") 296 | return alt, mo, np, pd 297 | 298 | 299 | @app.cell 300 | def _(mo, np): 301 | from wigglystuff import Matrix 302 | 303 | mat = mo.ui.anywidget(Matrix(matrix=np.eye(2), mirror=True, step=0.1)) 304 | arr = mo.ui.anywidget(Matrix(rows=1, cols=2, step=0.1)) 305 | return Matrix, arr, mat 306 | 307 | 308 | @app.cell 309 | def _(Matrix, mo, np): 310 | x1 = mo.ui.anywidget(Matrix(matrix=np.eye(2), step=0.1)) 311 | x2 = mo.ui.anywidget(Matrix(matrix=np.random.random((2, 2)), step=0.1)) 312 | return 313 | 314 | 315 | @app.cell 316 | def _(mo): 317 | from wigglystuff import Slider2D 318 | 319 | slider_2d = mo.ui.anywidget(Slider2D(width=300, height=300, x_bounds=(0.0, 1.0), y_bounds=(0.0, 1.0))) 320 | return (slider_2d,) 321 | 322 | 323 | @app.cell 324 | def _(np, pd, slider_2d): 325 | df = pd.DataFrame({ 326 | "x": np.random.normal(slider_2d.x * 10, 1, 2000), 327 | "y": np.random.normal(slider_2d.y * 10, 1, 2000) 328 | }) 329 | return (df,) 330 | 331 | 332 | @app.cell 333 | def _(np, pd): 334 | df_base = pd.DataFrame({ 335 | "x": np.random.normal(0, 1, 2000), 336 | "y": np.random.normal(0, 1, 2000) 337 | }) 338 | return (df_base,) 339 | 340 | 341 | @app.cell 342 | def _(np, pd): 343 | x_orig = np.random.multivariate_normal(np.array([0, 0]), np.array([[1, 0], [0, 1]]), 2500) 344 | df_orig = pd.DataFrame({"x": x_orig[:, 0], "y": x_orig[:, 1]}) 345 | return (df_orig,) 346 | 347 | 348 | @app.cell 349 | def _(mo): 350 | from wigglystuff import ColorPicker 351 | color_picker = mo.ui.anywidget(ColorPicker(color="#444444")) 352 | return (color_picker,) 353 | 354 | 355 | @app.cell 356 | def _(mo): 357 | from wigglystuff import SortableList 358 | 359 | sortable_list = mo.ui.anywidget( 360 | SortableList( 361 | ["Action", "Comedy", "Drama", "Thriller", "Sci-Fi"], 362 | addable=True, 363 | removable=True, 364 | editable=True, 365 | ) 366 | ) 367 | return (sortable_list,) 368 | 369 | 370 | if __name__ == "__main__": 371 | app.run() 372 | --------------------------------------------------------------------------------