From 326d472de74738b7acb53838f0c4ffb9bf845e0a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 24 Aug 2026 15:34:43 +0200 Subject: [PATCH 1/3] 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 --- src/magicgui/backends/_ipynb/__init__.py | 10 ++++ src/magicgui/backends/_ipynb/widgets.py | 58 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/magicgui/backends/_ipynb/__init__.py b/src/magicgui/backends/_ipynb/__init__.py index 10c97b5e9..aae636d5f 100644 --- a/src/magicgui/backends/_ipynb/__init__.py +++ b/src/magicgui/backends/_ipynb/__init__.py @@ -5,14 +5,19 @@ Container, DateEdit, DateTimeEdit, + FloatRangeSlider, FloatSlider, FloatSpinBox, + Image, Label, LineEdit, LiteralEvalLineEdit, Password, + ProgressBar, PushButton, RadioButton, + RadioButtons, + RangeSlider, Select, Slider, SpinBox, @@ -32,14 +37,19 @@ "Container", "DateEdit", "DateTimeEdit", + "FloatRangeSlider", "FloatSlider", "FloatSpinBox", + "Image", "Label", "LineEdit", "LiteralEvalLineEdit", "Password", + "ProgressBar", "PushButton", "RadioButton", + "RadioButtons", + "RangeSlider", "Select", "Slider", "SpinBox", diff --git a/src/magicgui/backends/_ipynb/widgets.py b/src/magicgui/backends/_ipynb/widgets.py index f5f56c38f..8d2ef7386 100644 --- a/src/magicgui/backends/_ipynb/widgets.py +++ b/src/magicgui/backends/_ipynb/widgets.py @@ -442,6 +442,50 @@ class FloatSlider(_IPySliderWidget): _ipywidget: ipywidgets.FloatSlider +class RangeSlider(_IPySliderWidget): + _ipywidget: ipywidgets.IntRangeSlider + + +class FloatRangeSlider(_IPySliderWidget): + _ipywidget: ipywidgets.FloatRangeSlider + + +class ProgressBar(_IPySliderWidget): + _ipywidget: ipywidgets.FloatProgress + + def __init__(self, **kwargs): + self._step: float = 1.0 + super().__init__(**kwargs) + + # FloatProgress has no step trait; track it ourselves + def _mgui_get_step(self) -> float: + return self._step + + def _mgui_set_step(self, value: float) -> None: + self._step = value + + +class Image(_IPyValueWidget): + _ipywidget: ipywidgets.Image + + def _mgui_set_value(self, value) -> None: + # value is an (M, N, 4) uint8 RGBA numpy array (see widgets.Image.set_data) + try: + from PIL import Image as pil_image + except ImportError as e: + raise ModuleNotFoundError( + "PIL is required to show images in the ipynb backend. " + "Please `pip install magicgui[image]`" + ) from e + + from io import BytesIO + + buf = BytesIO() + pil_image.fromarray(value).save(buf, format="png") + self._ipywidget.value = buf.getvalue() + self._ipywidget.format = "png" + + class ComboBox(_IPyCategoricalWidget): _ipywidget: ipywidgets.Dropdown @@ -450,6 +494,20 @@ class Select(_IPyCategoricalWidget): _ipywidget: ipywidgets.SelectMultiple +class RadioButtons(_IPyCategoricalWidget, protocols.SupportsOrientation): + _ipywidget: ipywidgets.RadioButtons + + def _mgui_set_orientation(self, value: str) -> None: + if value != "vertical": + raise NotImplementedError( + "Only vertical orientation is currently supported for " + "RadioButtons in the ipynb backend" + ) + + def _mgui_get_orientation(self) -> str: + return "vertical" + + # CONTAINER ---------------------------------------------------------------------- From e269826b1436922ea61fd1761941f45d10619188 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 24 Aug 2026 15:34:43 +0200 Subject: [PATCH 2/3] 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 --- src/magicgui/backends/_ipynb/application.py | 34 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/magicgui/backends/_ipynb/application.py b/src/magicgui/backends/_ipynb/application.py index e620785d2..b84504d9a 100644 --- a/src/magicgui/backends/_ipynb/application.py +++ b/src/magicgui/backends/_ipynb/application.py @@ -1,12 +1,21 @@ +from __future__ import annotations + +import asyncio +from typing import Callable + from magicgui.widgets.protocols import BaseApplicationBackend class ApplicationBackend(BaseApplicationBackend): + _timer_handle: asyncio.TimerHandle | None = None + def _mgui_get_backend_name(self): return "ipynb" def _mgui_process_events(self): - raise NotImplementedError() + # ipywidgets updates are pushed to the frontend over the kernel's comm + # channels as traits change, so there is nothing to flush synchronously + pass def _mgui_run(self): pass # We run in IPython, so we don't run! @@ -17,8 +26,25 @@ def _mgui_quit(self): def _mgui_get_native_app(self): return self - def _mgui_start_timer(self, interval=0, on_timeout=None, single=False): - raise NotImplementedError() + def _mgui_start_timer( + self, + interval: int = 0, + on_timeout: Callable[[], None] | None = None, + single: bool = False, + ): + self._mgui_stop_timer() + # in a Jupyter kernel, cells are executed inside a running asyncio loop + loop = asyncio.get_running_loop() + interval_s = interval / 1000 + + def _tick() -> None: + self._timer_handle = None if single else loop.call_later(interval_s, _tick) + if on_timeout is not None: + on_timeout() + + self._timer_handle = loop.call_later(interval_s, _tick) def _mgui_stop_timer(self): - raise NotImplementedError() + if self._timer_handle is not None: + self._timer_handle.cancel() + self._timer_handle = None From 8eedd852a0b5506007ffb5569364a758ae326495 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 24 Aug 2026 15:34:43 +0200 Subject: [PATCH 3/3] test: cover the new ipynb widgets and timers on both backends Co-Authored-By: Claude Fable 5 --- tests/test_widgets.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 2a4ad2ac0..ed5bbce3b 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1198,3 +1198,77 @@ def test_toolbar(): tb.icon_size = 26 assert tb.icon_size == (26, 26) tb.clear() + + +def test_range_slider_backends(backend): + """RangeSlider/FloatRangeSlider work on both backends.""" + use_app(backend) + rslider = widgets.RangeSlider(min=0, max=100, value=(10, 20)) + assert tuple(rslider.value) == (10, 20) + rslider.value = (5, 50) + assert tuple(rslider.value) == (5, 50) + frslider = widgets.FloatRangeSlider(min=0.0, max=1.0, value=(0.2, 0.8)) + assert tuple(round(v, 6) for v in frslider.value) == (0.2, 0.8) + + +def test_progress_bar_backends(backend): + """ProgressBar works on both backends.""" + use_app(backend) + pbar = widgets.ProgressBar(min=0, max=100, value=10, step=5) + assert pbar.value == 10 + pbar.increment() + assert pbar.value == 15 + pbar.decrement(10) + assert pbar.value == 5 + + +def test_radio_buttons_backends(backend): + """RadioButtons works on both backends.""" + use_app(backend) + btns = widgets.RadioButtons(choices=["a", "b", "c"], value="b") + assert btns.value == "b" + assert btns.orientation == "vertical" + fired = [] + btns.changed.connect(lambda v: fired.append(v)) + btns.value = "c" + assert btns.value == "c" + assert fired == ["c"] + + +def test_image_backends(backend): + """Image renders an RGBA array on both backends.""" + np = pytest.importorskip("numpy") + pytest.importorskip("PIL") + use_app(backend) + image = widgets.Image() + data = np.zeros((10, 20, 4), dtype=np.uint8) + data[..., 3] = 255 + image.set_data(data) + if backend == "ipynb": + assert bytes(image.native.value).startswith(b"\x89PNG") + + +def test_ipynb_timer(): + """The ipynb backend supports (asyncio-based) timers and process_events.""" + import asyncio + + pytest.importorskip("ipywidgets") + app = use_app("ipynb") + try: + app.process_events() # smoke test: does not raise + repeated: list[int] = [] + single: list[int] = [] + + async def _run(): + backend_app = app._backend + backend_app._mgui_start_timer(5, lambda: repeated.append(1)) + await asyncio.sleep(0.05) + backend_app._mgui_stop_timer() + backend_app._mgui_start_timer(5, lambda: single.append(1), single=True) + await asyncio.sleep(0.05) + + asyncio.run(_run()) + assert len(repeated) >= 2 + assert len(single) == 1 + finally: + use_app("qt")