diff --git a/examples/ipympl.ipynb b/examples/ipympl.ipynb index 9d80dcc8..35e8c84b 100644 --- a/examples/ipympl.ipynb +++ b/examples/ipympl.ipynb @@ -201,7 +201,7 @@ "source": [ "# Interactions with other widgets and layouting\n", "\n", - "When you want to embed the figure into a layout of other widgets you should call `plt.ioff()` before creating the figure otherwise code inside of `plt.figure()` will display the canvas automatically and outside of your layout. " + "When you want to embed the figure into a layout of other widgets you should call `plt.ioff()` before creating the figure otherwise `plt.figure()` will trigger a display of the canvas automatically and outside of your layout. " ] }, { @@ -225,7 +225,6 @@ "# this is default but if this notebook is executed out of order it may have been turned off\n", "plt.ion()\n", "\n", - "\n", "fig = plt.figure()\n", "ax = fig.gca()\n", "ax.imshow(Z)\n", @@ -268,35 +267,6 @@ ")" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Fixing the double display with `ipywidgets.Output`\n", - "\n", - "Using `plt.ioff` use matplotlib to avoid the double display of the plot. You can also use `ipywidgets.Output` to capture the plot display to prevent this" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "out = widgets.Output()\n", - "with out:\n", - " fig = plt.figure()\n", - "\n", - "ax = fig.gca()\n", - "ax.imshow(Z)\n", - "\n", - "widgets.AppLayout(\n", - " center=out,\n", - " footer=widgets.Button(icon='check'),\n", - " pane_heights=[0, 6, 1]\n", - ")" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -446,18 +416,11 @@ "display(widgets.VBox([slider, fig.canvas]))\n", "display(out)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -471,7 +434,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.8" + "version": "3.9.7" }, "widgets": { "application/vnd.jupyter.widget-state+json": { diff --git a/ipympl/backend_nbagg.py b/ipympl/backend_nbagg.py index a9fa0777..1c955cb2 100644 --- a/ipympl/backend_nbagg.py +++ b/ipympl/backend_nbagg.py @@ -5,16 +5,17 @@ import io from IPython.display import display, HTML +from IPython import get_ipython +from IPython import version_info as ipython_version_info from ipywidgets import DOMWidget, widget_serialization from traitlets import ( - Unicode, Bool, CInt, Float, List, Instance, CaselessStrEnum, Enum, + Unicode, Bool, CInt, List, Instance, CaselessStrEnum, Enum, default ) import matplotlib -from matplotlib import rcParams -from matplotlib import is_interactive +from matplotlib import rcParams, is_interactive from matplotlib.backends.backend_webagg_core import (FigureManagerWebAgg, FigureCanvasWebAggCore, NavigationToolbar2WebAgg, @@ -40,7 +41,6 @@ def connection_info(): use. """ - from matplotlib._pylab_helpers import Gcf result = [] for manager in Gcf.get_all_fig_managers(): fig = manager.canvas.figure @@ -83,16 +83,8 @@ def __init__(self, canvas, *args, **kwargs): def export(self): buf = io.BytesIO() self.canvas.figure.savefig(buf, format='png', dpi='figure') - # Figure width in pixels - pwidth = (self.canvas.figure.get_figwidth() * - self.canvas.figure.get_dpi()) - # Scale size to match widget on HiDPI monitors. - if hasattr(self.canvas, 'device_pixel_ratio'): # Matplotlib 3.5+ - width = pwidth / self.canvas.device_pixel_ratio - else: - width = pwidth / self.canvas._dpi_ratio - data = "" - data = data.format(b64encode(buf.getvalue()).decode('utf-8'), width) + data = "" + data = data.format(b64encode(buf.getvalue()).decode('utf-8')) display(HTML(data)) @default('toolitems') @@ -160,7 +152,9 @@ class Canvas(DOMWidget, FigureCanvasWebAggCore): _png_is_old = Bool() _force_full = Bool() _current_image_mode = Unicode() - _dpi_ratio = Float(1.0) + + # Static as it should be the same for all canvases + current_dpi_ratio = 1.0 def __init__(self, figure, *args, **kwargs): DOMWidget.__init__(self, *args, **kwargs) @@ -172,9 +166,15 @@ def _handle_message(self, object, content, buffers): # Every content has a "type". if content['type'] == 'closing': self._closed = True + elif content['type'] == 'initialized': _, _, w, h = self.figure.bbox.bounds self.manager.resize(w, h) + + elif content['type'] == 'set_dpi_ratio': + Canvas.current_dpi_ratio = content['dpi_ratio'] + self.manager.handle_json(content) + else: self.manager.handle_json(content) @@ -208,6 +208,41 @@ def send_binary(self, data): def new_timer(self, *args, **kwargs): return TimerTornado(*args, **kwargs) + def _repr_mimebundle_(self, **kwargs): + # now happens before the actual display call. + if hasattr(self, '_handle_displayed'): + self._handle_displayed(**kwargs) + plaintext = repr(self) + if len(plaintext) > 110: + plaintext = plaintext[:110] + '…' + + buf = io.BytesIO() + self.figure.savefig(buf, format='png', dpi='figure') + data_url = b64encode(buf.getvalue()).decode('utf-8') + + data = { + 'text/plain': plaintext, + 'image/png': data_url, + 'application/vnd.jupyter.widget-view+json': { + 'version_major': 2, + 'version_minor': 0, + 'model_id': self._model_id + } + } + + return data + + def _ipython_display_(self, **kwargs): + """Called when `IPython.display.display` is called on a widget. + Note: if we are in IPython 6.1 or later, we return NotImplemented so + that _repr_mimebundle_ is used directly. + """ + if ipython_version_info >= (6, 1): + raise NotImplementedError + + data = self._repr_mimebundle_(**kwargs) + display(data, raw=True) + if matplotlib.__version__ < '3.4': # backport the Python side changes to match the js changes def _handle_key(self, event): @@ -294,14 +329,18 @@ class _Backend_ipympl(_Backend): FigureCanvas = Canvas FigureManager = FigureManager + _to_show = [] + _draw_called = False + @staticmethod def new_figure_manager_given_figure(num, figure): canvas = Canvas(figure) if 'nbagg.transparent' in rcParams and rcParams['nbagg.transparent']: figure.patch.set_alpha(0) manager = FigureManager(canvas, num) + if is_interactive(): - manager.show() + _Backend_ipympl._to_show.append(figure) figure.canvas.draw_idle() def destroy(event): @@ -312,17 +351,17 @@ def destroy(event): return manager @staticmethod - def show(block=None): - # TODO: something to do when keyword block==False ? + def show(close=None, block=None): + # # TODO: something to do when keyword block==False ? + interactive = is_interactive() - managers = Gcf.get_all_fig_managers() - if not managers: + manager = Gcf.get_active() + if manager is None: return - interactive = is_interactive() - - for manager in managers: - manager.show() + try: + display(manager.canvas) + # metadata=_fetch_figure_metadata(manager.canvas.figure) # plt.figure adds an event which makes the figure in focus the # active one. Disable this behaviour, as it results in @@ -333,3 +372,57 @@ def show(block=None): if not interactive: Gcf.figs.pop(manager.num, None) + finally: + if manager.canvas.figure in _Backend_ipympl._to_show: + _Backend_ipympl._to_show.remove(manager.canvas.figure) + + @staticmethod + def draw_if_interactive(): + # If matplotlib was manually set to non-interactive mode, this function + # should be a no-op (otherwise we'll generate duplicate plots, since a + # user who set ioff() manually expects to make separate draw/show + # calls). + if not is_interactive(): + return + + manager = Gcf.get_active() + if manager is None: + return + fig = manager.canvas.figure + + # ensure current figure will be drawn, and each subsequent call + # of draw_if_interactive() moves the active figure to ensure it is + # drawn last + try: + _Backend_ipympl._to_show.remove(fig) + except ValueError: + # ensure it only appears in the draw list once + pass + # Queue up the figure for drawing in next show() call + _Backend_ipympl._to_show.append(fig) + _Backend_ipympl._draw_called = True + + +def flush_figures(): + if rcParams['backend'] == 'module://ipympl.backend_nbagg': + if not _Backend_ipympl._draw_called: + return + + try: + # exclude any figures that were closed: + active = set([ + fm.canvas.figure for fm in Gcf.get_all_fig_managers() + ]) + + for fig in [ + fig for fig in _Backend_ipympl._to_show if fig in active]: + # display(fig.canvas, metadata=_fetch_figure_metadata(fig)) + display(fig.canvas) + finally: + # clear flags for next round + _Backend_ipympl._to_show = [] + _Backend_ipympl._draw_called = False + + +ip = get_ipython() +ip.events.register('post_execute', flush_figures) diff --git a/js/src/mpl_widget.js b/js/src/mpl_widget.js index 4e1acca7..d58f5427 100644 --- a/js/src/mpl_widget.js +++ b/js/src/mpl_widget.js @@ -185,9 +185,6 @@ export class MPLCanvasModel extends widgets.DOMWidgetModel { this.image.src = image_url; - // Tell Jupyter that the notebook contents must change. - this.send_message('ack'); - this.waiting = false; }