Skip to content

Commit 965de0e

Browse files
larsonerclaudetlambert03
authored
Ipynb widget parity (#742)
* feat: add ipynb RangeSlider, FloatRangeSlider, ProgressBar, Image, and RadioButtons Widget-parity gaps with the Qt backend, each mapped onto the natural ipywidgets equivalent: - RangeSlider/FloatRangeSlider -> IntRangeSlider/FloatRangeSlider - ProgressBar -> FloatProgress (no step trait, so step is tracked on the backend widget; the frontend ProgressBar manages step itself) - Image -> ipywidgets.Image, encoding the RGBA array to PNG via PIL (the magicgui[image] extra, same as the frontend widget requires) - RadioButtons -> ipywidgets.RadioButtons (vertical only for now; horizontal raises NotImplementedError) The single-RadioButton -> ipywidgets.RadioButtons mapping is left untouched (pre-existing semantic mismatch, needs its own discussion). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: support timers and process_events in the ipynb backend _mgui_start_timer/_mgui_stop_timer are implemented with asyncio.call_later on the kernel's running event loop, matching the Qt backend's single-app-timer semantics (including single-shot). _mgui_process_events becomes a no-op instead of raising: ipywidgets updates are pushed over the kernel's comm channels as traits change, so there is nothing to flush synchronously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover the new ipynb widgets and timers on both backends Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
1 parent d17990c commit 965de0e

4 files changed

Lines changed: 172 additions & 4 deletions

File tree

src/magicgui/backends/_ipynb/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
Container,
66
DateEdit,
77
DateTimeEdit,
8+
FloatRangeSlider,
89
FloatSlider,
910
FloatSpinBox,
11+
Image,
1012
Label,
1113
LineEdit,
1214
LiteralEvalLineEdit,
1315
Password,
16+
ProgressBar,
1417
PushButton,
1518
RadioButton,
19+
RadioButtons,
20+
RangeSlider,
1621
Select,
1722
Slider,
1823
SpinBox,
@@ -32,14 +37,19 @@
3237
"Container",
3338
"DateEdit",
3439
"DateTimeEdit",
40+
"FloatRangeSlider",
3541
"FloatSlider",
3642
"FloatSpinBox",
43+
"Image",
3744
"Label",
3845
"LineEdit",
3946
"LiteralEvalLineEdit",
4047
"Password",
48+
"ProgressBar",
4149
"PushButton",
4250
"RadioButton",
51+
"RadioButtons",
52+
"RangeSlider",
4353
"Select",
4454
"Slider",
4555
"SpinBox",
Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Callable
5+
16
from magicgui.widgets.protocols import BaseApplicationBackend
27

38

49
class ApplicationBackend(BaseApplicationBackend):
10+
_timer_handle: asyncio.TimerHandle | None = None
11+
512
def _mgui_get_backend_name(self):
613
return "ipynb"
714

815
def _mgui_process_events(self):
9-
raise NotImplementedError()
16+
# ipywidgets updates are pushed to the frontend over the kernel's comm
17+
# channels as traits change, so there is nothing to flush synchronously
18+
pass
1019

1120
def _mgui_run(self):
1221
pass # We run in IPython, so we don't run!
@@ -17,8 +26,25 @@ def _mgui_quit(self):
1726
def _mgui_get_native_app(self):
1827
return self
1928

20-
def _mgui_start_timer(self, interval=0, on_timeout=None, single=False):
21-
raise NotImplementedError()
29+
def _mgui_start_timer(
30+
self,
31+
interval: int = 0,
32+
on_timeout: Callable[[], None] | None = None,
33+
single: bool = False,
34+
):
35+
self._mgui_stop_timer()
36+
# in a Jupyter kernel, cells are executed inside a running asyncio loop
37+
loop = asyncio.get_running_loop()
38+
interval_s = interval / 1000
39+
40+
def _tick() -> None:
41+
self._timer_handle = None if single else loop.call_later(interval_s, _tick)
42+
if on_timeout is not None:
43+
on_timeout()
44+
45+
self._timer_handle = loop.call_later(interval_s, _tick)
2246

2347
def _mgui_stop_timer(self):
24-
raise NotImplementedError()
48+
if self._timer_handle is not None:
49+
self._timer_handle.cancel()
50+
self._timer_handle = None

src/magicgui/backends/_ipynb/widgets.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,50 @@ class FloatSlider(_IPySliderWidget):
443443
_ipywidget: ipywidgets.FloatSlider
444444

445445

446+
class RangeSlider(_IPySliderWidget):
447+
_ipywidget: ipywidgets.IntRangeSlider
448+
449+
450+
class FloatRangeSlider(_IPySliderWidget):
451+
_ipywidget: ipywidgets.FloatRangeSlider
452+
453+
454+
class ProgressBar(_IPySliderWidget):
455+
_ipywidget: ipywidgets.FloatProgress
456+
457+
def __init__(self, **kwargs):
458+
self._step: float = 1.0
459+
super().__init__(**kwargs)
460+
461+
# FloatProgress has no step trait; track it ourselves
462+
def _mgui_get_step(self) -> float:
463+
return self._step
464+
465+
def _mgui_set_step(self, value: float) -> None:
466+
self._step = value
467+
468+
469+
class Image(_IPyValueWidget):
470+
_ipywidget: ipywidgets.Image
471+
472+
def _mgui_set_value(self, value) -> None:
473+
# value is an (M, N, 4) uint8 RGBA numpy array (see widgets.Image.set_data)
474+
try:
475+
from PIL import Image as pil_image
476+
except ImportError as e:
477+
raise ModuleNotFoundError(
478+
"PIL is required to show images in the ipynb backend. "
479+
"Please `pip install magicgui[image]`"
480+
) from e
481+
482+
from io import BytesIO
483+
484+
buf = BytesIO()
485+
pil_image.fromarray(value).save(buf, format="png")
486+
self._ipywidget.value = buf.getvalue()
487+
self._ipywidget.format = "png"
488+
489+
446490
class ComboBox(_IPyCategoricalWidget):
447491
_ipywidget: ipywidgets.Dropdown
448492

@@ -451,6 +495,20 @@ class Select(_IPyCategoricalWidget):
451495
_ipywidget: ipywidgets.SelectMultiple
452496

453497

498+
class RadioButtons(_IPyCategoricalWidget, protocols.SupportsOrientation):
499+
_ipywidget: ipywidgets.RadioButtons
500+
501+
def _mgui_set_orientation(self, value: str) -> None:
502+
if value != "vertical":
503+
raise NotImplementedError(
504+
"Only vertical orientation is currently supported for "
505+
"RadioButtons in the ipynb backend"
506+
)
507+
508+
def _mgui_get_orientation(self) -> str:
509+
return "vertical"
510+
511+
454512
# CONTAINER ----------------------------------------------------------------------
455513

456514

tests/test_widgets.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,3 +1196,77 @@ def test_toolbar():
11961196
tb.icon_size = 26
11971197
assert tb.icon_size == (26, 26)
11981198
tb.clear()
1199+
1200+
1201+
def test_range_slider_backends(backend):
1202+
"""RangeSlider/FloatRangeSlider work on both backends."""
1203+
use_app(backend)
1204+
rslider = widgets.RangeSlider(min=0, max=100, value=(10, 20))
1205+
assert tuple(rslider.value) == (10, 20)
1206+
rslider.value = (5, 50)
1207+
assert tuple(rslider.value) == (5, 50)
1208+
frslider = widgets.FloatRangeSlider(min=0.0, max=1.0, value=(0.2, 0.8))
1209+
assert tuple(round(v, 6) for v in frslider.value) == (0.2, 0.8)
1210+
1211+
1212+
def test_progress_bar_backends(backend):
1213+
"""ProgressBar works on both backends."""
1214+
use_app(backend)
1215+
pbar = widgets.ProgressBar(min=0, max=100, value=10, step=5)
1216+
assert pbar.value == 10
1217+
pbar.increment()
1218+
assert pbar.value == 15
1219+
pbar.decrement(10)
1220+
assert pbar.value == 5
1221+
1222+
1223+
def test_radio_buttons_backends(backend):
1224+
"""RadioButtons works on both backends."""
1225+
use_app(backend)
1226+
btns = widgets.RadioButtons(choices=["a", "b", "c"], value="b")
1227+
assert btns.value == "b"
1228+
assert btns.orientation == "vertical"
1229+
fired = []
1230+
btns.changed.connect(lambda v: fired.append(v))
1231+
btns.value = "c"
1232+
assert btns.value == "c"
1233+
assert fired == ["c"]
1234+
1235+
1236+
def test_image_backends(backend):
1237+
"""Image renders an RGBA array on both backends."""
1238+
np = pytest.importorskip("numpy")
1239+
pytest.importorskip("PIL")
1240+
use_app(backend)
1241+
image = widgets.Image()
1242+
data = np.zeros((10, 20, 4), dtype=np.uint8)
1243+
data[..., 3] = 255
1244+
image.set_data(data)
1245+
if backend == "ipynb":
1246+
assert bytes(image.native.value).startswith(b"\x89PNG")
1247+
1248+
1249+
def test_ipynb_timer():
1250+
"""The ipynb backend supports (asyncio-based) timers and process_events."""
1251+
import asyncio
1252+
1253+
pytest.importorskip("ipywidgets")
1254+
app = use_app("ipynb")
1255+
try:
1256+
app.process_events() # smoke test: does not raise
1257+
repeated: list[int] = []
1258+
single: list[int] = []
1259+
1260+
async def _run():
1261+
backend_app = app._backend
1262+
backend_app._mgui_start_timer(5, lambda: repeated.append(1))
1263+
await asyncio.sleep(0.05)
1264+
backend_app._mgui_stop_timer()
1265+
backend_app._mgui_start_timer(5, lambda: single.append(1), single=True)
1266+
await asyncio.sleep(0.05)
1267+
1268+
asyncio.run(_run())
1269+
assert len(repeated) >= 2
1270+
assert len(single) == 1
1271+
finally:
1272+
use_app("qt")

0 commit comments

Comments
 (0)