Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/magicgui/backends/_ipynb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
Container,
DateEdit,
DateTimeEdit,
FloatRangeSlider,
FloatSlider,
FloatSpinBox,
Image,
Label,
LineEdit,
LiteralEvalLineEdit,
Password,
ProgressBar,
PushButton,
RadioButton,
RadioButtons,
RangeSlider,
Select,
Slider,
SpinBox,
Expand All @@ -32,14 +37,19 @@
"Container",
"DateEdit",
"DateTimeEdit",
"FloatRangeSlider",
"FloatSlider",
"FloatSpinBox",
"Image",
"Label",
"LineEdit",
"LiteralEvalLineEdit",
"Password",
"ProgressBar",
"PushButton",
"RadioButton",
"RadioButtons",
"RangeSlider",
"Select",
"Slider",
"SpinBox",
Expand Down
34 changes: 30 additions & 4 deletions src/magicgui/backends/_ipynb/application.py
Original file line number Diff line number Diff line change
@@ -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!
Expand All @@ -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
58 changes: 58 additions & 0 deletions src/magicgui/backends/_ipynb/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 ----------------------------------------------------------------------


Expand Down
74 changes: 74 additions & 0 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading