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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- New `HoverZoom` widget — hover over an image to see a magnified side panel, like product zoom on e-commerce sites.

## [0.2.40] - 2026-03-24

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ uv pip install wigglystuff
<td align="center"><b>ColorPicker</b><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/colorpicker.py/wasm"><img src="./mkdocs/assets/gallery/colorpicker.png" width="330"></a><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/colorpicker.py/wasm">molab</a> · <a href="https://koaning.github.io/wigglystuff/reference/color-picker/">API</a> · <a href="https://koaning.github.io/wigglystuff/reference/color-picker.md">MD</a></td>
</tr>
<tr>
<td align="center"><b>HoverZoom</b><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/hoverzoom.py/wasm"><img src="./mkdocs/assets/gallery/hoverzoom.png" width="330"></a><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/hoverzoom.py/wasm">molab</a> · <a href="https://koaning.github.io/wigglystuff/reference/hover-zoom/">API</a> · <a href="https://koaning.github.io/wigglystuff/reference/hover-zoom.md">MD</a></td>
</tr>
<tr>
<td align="center"><b>GamepadWidget</b><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/gamepad.py/wasm"><img src="./mkdocs/assets/gallery/gamepad.png" width="330"></a><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/gamepad.py/wasm">molab</a> · <a href="https://koaning.github.io/wigglystuff/reference/gamepad/">API</a> · <a href="https://koaning.github.io/wigglystuff/reference/gamepad.md">MD</a></td>
<td align="center"><b>KeystrokeWidget</b><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/keystroke.py/wasm"><img src="./mkdocs/assets/gallery/keystroke.png" width="330"></a><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/keystroke.py/wasm">molab</a> · <a href="https://koaning.github.io/wigglystuff/reference/keystroke/">API</a> · <a href="https://koaning.github.io/wigglystuff/reference/keystroke.md">MD</a></td>
<td align="center"><b>SpeechToText</b><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/talk.py/wasm"><img src="./mkdocs/assets/gallery/speechtotext.png" width="330"></a><br><a href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/talk.py/wasm">molab</a> · <a href="https://koaning.github.io/wigglystuff/reference/talk/">API</a> · <a href="https://koaning.github.io/wigglystuff/reference/talk.md">MD</a></td>
Expand Down
1 change: 1 addition & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ syncs back to Python.
| ThreeWidget | `wigglystuff.three_widget.ThreeWidget` | `data`, `width`, `height`, `show_grid`, `show_axes`, `dark_mode`, `axis_labels`, `animate_updates`, `animation_duration_ms` | 3D scatter plot for point clouds |
| WebcamCapture | `wigglystuff.webcam_capture.WebcamCapture` | `image_base64`, `capturing`, `interval_ms`, `facing_mode` | Webcam preview with snapshot capture |
| GamepadWidget | `wigglystuff.gamepad.GamepadWidget` | `axes`, `current_button_press`, `dpad_*`, `current_timestamp` | Streams browser Gamepad API events |
| HoverZoom | `wigglystuff.hover_zoom.HoverZoom` | `image`, `zoom_factor`, `width`, `height`, `_crop` | Image hover zoom with magnified side panel |
| KeystrokeWidget | `wigglystuff.keystroke.KeystrokeWidget` | `last_key` | Captures the latest keypress w/ modifiers |
| WebkitSpeechToTextWidget | `wigglystuff.talk.WebkitSpeechToTextWidget` | `transcript`, `listening`, `trigger_listen` | WebKit speech recognition bridge |
| DriverTour | `wigglystuff.driver_tour.DriverTour` | `steps`, `auto_start`, `show_progress`, `active`, `current_step` | Guided product tours via Driver.js |
Expand Down
86 changes: 86 additions & 0 deletions demos/hoverzoom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "marimo>=0.19.7",
# "wigglystuff==0.2.37",
# "Pillow",
# "matplotlib",
# "numpy",
# ]
# ///

import marimo

__generated_with = "0.21.1"
app = marimo.App(width="medium")


@app.cell
def _():
import marimo as mo
from wigglystuff import HoverZoom

return HoverZoom, mo


@app.cell
def _():
from PIL import Image, ImageDraw

img = Image.new("RGB", (800, 600), (30, 80, 120))
draw = ImageDraw.Draw(img)
for i in range(0, 800, 40):
for j in range(0, 600, 40):
color = ((i * 3) % 256, (j * 4) % 256, ((i + j) * 2) % 256)
draw.ellipse([i, j, i + 30, j + 30], fill=color)
return (img,)


@app.cell
def _(HoverZoom, img, mo):
widget = mo.ui.anywidget(HoverZoom(img, zoom_factor=3.0, width=450))
return (widget,)


@app.cell
def _(widget):
widget
return


@app.cell
def _():
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 200
x = rng.normal(0, 1, n)
y = rng.normal(0, 1, n)
labels = [f"p{i}" for i in range(n)]

fig, ax = plt.subplots(figsize=(8, 6), dpi=200)
ax.scatter(x, y, s=12, alpha=0.6)
for xi, yi, label in zip(x, y, labels):
ax.annotate(label, (xi, yi), fontsize=4, alpha=0.7, ha="center", va="bottom")
ax.set_title("200 labeled points — hover to read the labels")
fig.tight_layout()
return (fig,)


@app.cell
def _(HoverZoom, fig, mo):
chart_widget = mo.ui.anywidget(HoverZoom(fig, zoom_factor=4.0, width=500))
return (chart_widget,)


@app.cell
def _(chart_widget):
chart_widget
return


if __name__ == "__main__":
app.run()
5 changes: 5 additions & 0 deletions mkdocs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ The documentation for wigglystuff is designed for humans (via hosted marimo note
<div class="gallery-links"><a target="_blank" href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/colorpicker.py/wasm">molab</a><a href="reference/color-picker/">API</a><a href="reference/color-picker.md">MD</a></div>
</div>
<div class="gallery-item">
<div class="gallery-title">HoverZoom</div>
<a target="_blank" href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/hoverzoom.py/wasm" class="gallery-img"><img src="assets/gallery/hoverzoom.png" alt="HoverZoom widget"></a>
<div class="gallery-links"><a target="_blank" href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/hoverzoom.py/wasm">molab</a><a href="reference/hover-zoom/">API</a><a href="reference/hover-zoom.md">MD</a></div>
</div>
<div class="gallery-item">
<div class="gallery-title">GamepadWidget</div>
<a target="_blank" href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/gamepad.py/wasm" class="gallery-img"><img src="assets/gallery/gamepad.png" alt="GamepadWidget"></a>
<div class="gallery-links"><a target="_blank" href="https://molab.marimo.io/github/koaning/wigglystuff/blob/main/demos/gamepad.py/wasm">molab</a><a href="reference/gamepad/">API</a><a href="reference/gamepad.md">MD</a></div>
Expand Down
1 change: 1 addition & 0 deletions mkdocs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Install via `pip install wigglystuff` or `uv pip install wigglystuff`.
- [ColorPicker](https://koaning.github.io/wigglystuff/reference/color-picker.md): Color selection widget
- [CopyToClipboard](https://koaning.github.io/wigglystuff/reference/copy-to-clipboard.md): Copy text to clipboard
- [Tangle Widgets](https://koaning.github.io/wigglystuff/reference/tangle.md): Bret Victor-style tangle controls
- [HoverZoom](https://koaning.github.io/wigglystuff/reference/hover-zoom.md): Image hover zoom with magnified side panel
- [GamepadWidget](https://koaning.github.io/wigglystuff/reference/gamepad.md): Gamepad controller input
- [WebcamCapture](https://koaning.github.io/wigglystuff/reference/webcam-capture.md): Webcam image capture
- [CellTour](https://koaning.github.io/wigglystuff/reference/cell-tour.md): Guided notebook tours
Expand Down
2 changes: 2 additions & 0 deletions wigglystuff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .edge_draw import EdgeDraw
from .env_config import EnvConfig
from .gamepad import GamepadWidget
from .hover_zoom import HoverZoom
from .html import HTMLRefreshWidget, ImageRefreshWidget, ProgressBar
from .keystroke import KeystrokeWidget
from .matrix import Matrix
Expand Down Expand Up @@ -74,6 +75,7 @@
"ThreeWidget",
"WandbChart",
"WebcamCapture",
"HoverZoom",
"HTMLRefreshWidget",
"ImageRefreshWidget",
"ProgressBar",
Expand Down
88 changes: 88 additions & 0 deletions wigglystuff/hover_zoom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""HoverZoom widget — magnified side panel on image hover."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Optional, Union

import anywidget
import traitlets

from .chart_puck import fig_to_base64
from .paint import base64_to_pil, input_to_pil, pil_to_base64


class HoverZoom(anywidget.AnyWidget):
"""Image widget with a magnified side panel that appears on hover.

Hovering over the image shows a rectangle indicator and a zoom panel
to the right displaying the magnified region — the classic e-commerce
product zoom pattern.

Examples:
```python
from wigglystuff import HoverZoom

# From a file path
widget = HoverZoom("photo.jpg", zoom_factor=3.0)

# From a matplotlib figure
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.scatter(x, y, s=5, alpha=0.5)
widget = HoverZoom(fig, zoom_factor=4.0)
```
"""

_esm = Path(__file__).parent / "static" / "hover-zoom.js"
_css = Path(__file__).parent / "static" / "hover-zoom.css"

image = traitlets.Unicode("").tag(sync=True)
zoom_factor = traitlets.Float(3.0).tag(sync=True)
width = traitlets.Int(500).tag(sync=True)
height = traitlets.Int(0).tag(sync=True)
_crop = traitlets.List(traitlets.Float(), [0.0, 0.0, 1.0, 1.0]).tag(sync=True)

def __init__(
self,
image: Optional[Union[str, Path, Any]] = None,
*,
zoom_factor: float = 3.0,
width: int = 500,
height: int = 0,
):
"""Create a HoverZoom widget.

Args:
image: Image source — matplotlib figure, file path, URL, PIL Image,
bytes, or base64 string.
zoom_factor: Magnification level for the zoom panel.
width: Display width of the source image in pixels.
height: Display height in pixels (0 = auto, preserve aspect ratio).
"""
super().__init__()
self.zoom_factor = zoom_factor
self.width = width
self.height = height

if image is not None:
# Detect matplotlib figures by checking for savefig
if hasattr(image, "savefig"):
self.image = fig_to_base64(image)
else:
pil_image = input_to_pil(image)
if pil_image is not None:
self.image = pil_to_base64(pil_image)

def get_pil_zoom(self):
"""Return the currently zoomed region as a PIL Image.

Returns the cropped portion of the image that is visible in the
zoom panel. Returns ``None`` if no image is set.
"""
if not self.image:
return None
img = base64_to_pil(self.image)
x0_r, y0_r, x1_r, y1_r = self._crop
w, h = img.size
return img.crop((int(x0_r * w), int(y0_r * h), int(x1_r * w), int(y1_r * h)))
55 changes: 55 additions & 0 deletions wigglystuff/static/hover-zoom.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.hover-zoom-wrapper {
display: inline-flex;
align-items: flex-start;
gap: 8px;
position: relative;
color-scheme: light dark;
--hz-rect-bg: rgba(255, 255, 255, 0.25);
--hz-rect-border: rgba(0, 0, 0, 0.4);
--hz-panel-border: rgba(0, 0, 0, 0.15);
--hz-panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}

.dark .hover-zoom-wrapper,
.dark-theme .hover-zoom-wrapper,
[data-theme="dark"] .hover-zoom-wrapper {
--hz-rect-bg: rgba(255, 255, 255, 0.2);
--hz-rect-border: rgba(255, 255, 255, 0.6);
--hz-panel-border: rgba(255, 255, 255, 0.2);
--hz-panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}

.hover-zoom-source {
position: relative;
display: inline-block;
cursor: crosshair;
flex-shrink: 0;
}

.hover-zoom-img {
display: block;
user-select: none;
-webkit-user-select: none;
}

.hover-zoom-rect {
display: none;
position: absolute;
background: var(--hz-rect-bg);
border: 2px solid var(--hz-rect-border);
pointer-events: none;
box-sizing: border-box;
}

.hover-zoom-rect--pinned {
border-style: dashed;
}

.hover-zoom-panel {
display: none;
flex-shrink: 0;
border: 1px solid var(--hz-panel-border);
box-shadow: var(--hz-panel-shadow);
background-repeat: no-repeat;
background-color: transparent;
}
Loading
Loading