diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 69643e35fe3f..70db18067a7d 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -30,27 +30,64 @@ def __init__(self, figure): FigureCanvasBase.__init__(self, figure) width, height = self.get_width_height() _macosx.FigureCanvas.__init__(self, width, height) + self._draw_pending = False + self._is_drawing = False def set_cursor(self, cursor): # docstring inherited _macosx.set_cursor(cursor) - def _draw(self): - renderer = self.get_renderer() - if self.figure.stale: - renderer.clear() - self.figure.draw(renderer) - return renderer - def draw(self): - # docstring inherited - self._draw() - self.flush_events() + """Render the figure and update the macosx canvas.""" + # The renderer draw is done here; delaying causes problems with code + # that uses the result of the draw() to update plot elements. + if self._is_drawing: + return + with cbook._setattr_cm(self, _is_drawing=True): + super().draw() + self.update() - # draw_idle is provided by _macosx.FigureCanvas + def draw_idle(self): + # docstring inherited + if not (getattr(self, '_draw_pending', False) or + getattr(self, '_is_drawing', False)): + self._draw_pending = True + # Add a singleshot timer to the eventloop that will call back + # into the Python method _draw_idle to take care of the draw + self._single_shot_timer(self._draw_idle) + + def _single_shot_timer(self, callback): + """Add a single shot timer with the given callback""" + # We need to explicitly stop (called from delete) the timer after + # firing, otherwise segfaults will occur when trying to deallocate + # the singleshot timers. + def callback_func(callback, timer): + callback() + del timer + timer = self.new_timer(interval=0) + timer.add_callback(callback_func, callback, timer) + timer.start() + + def _draw_idle(self): + """ + Draw method for singleshot timer + + This draw method can be added to a singleshot timer, which can + accumulate draws while the eventloop is spinning. This method will + then only draw the first time and short-circuit the others. + """ + with self._idle_draw_cntx(): + if not self._draw_pending: + # Short-circuit because our draw request has already been + # taken care of + return + self._draw_pending = False + self.draw() def blit(self, bbox=None): - self.draw_idle() + # docstring inherited + super().blit(bbox) + self.update() def resize(self, width, height): # Size from macOS is logical pixels, dpi is physical. diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 2818f3d21cca..23991410601c 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -413,3 +413,72 @@ def _lazy_headless(): @pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_lazy_linux_headless(): proc = _run_helper(_lazy_headless, timeout=_test_timeout, MPLBACKEND="") + + +def _test_number_of_draws_script(): + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + + # animated=True tells matplotlib to only draw the artist when we + # explicitly request it + ln, = ax.plot([0, 1], [1, 2], animated=True) + + # make sure the window is raised, but the script keeps going + plt.show(block=False) + plt.pause(0.3) + # Connect to draw_event to count the occurrences + fig.canvas.mpl_connect('draw_event', print) + + # get copy of entire figure (everything inside fig.bbox) + # sans animated artist + bg = fig.canvas.copy_from_bbox(fig.bbox) + # draw the animated artist, this uses a cached renderer + ax.draw_artist(ln) + # show the result to the screen + fig.canvas.blit(fig.bbox) + + for j in range(10): + # reset the background back in the canvas state, screen unchanged + fig.canvas.restore_region(bg) + # Create a **new** artist here, this is poor usage of blitting + # but good for testing to make sure that this doesn't create + # excessive draws + ln, = ax.plot([0, 1], [1, 2]) + # render the artist, updating the canvas state, but not the screen + ax.draw_artist(ln) + # copy the image to the GUI state, but screen might not changed yet + fig.canvas.blit(fig.bbox) + # flush any pending GUI events, re-painting the screen if needed + fig.canvas.flush_events() + + # Let the event loop process everything before leaving + plt.pause(0.1) + + +_blit_backends = _get_testable_interactive_backends() +for param in _blit_backends: + backend = param.values[0]["MPLBACKEND"] + if backend == "gtk3cairo": + # copy_from_bbox only works when rendering to an ImageSurface + param.marks.append( + pytest.mark.skip("gtk3cairo does not support blitting")) + elif backend == "wx": + param.marks.append( + pytest.mark.skip("wx does not support blitting")) + + +@pytest.mark.parametrize("env", _blit_backends) +# subprocesses can struggle to get the display, so rerun a few times +@pytest.mark.flaky(reruns=4) +def test_blitting_events(env): + proc = _run_helper(_test_number_of_draws_script, + timeout=_test_timeout, + **env) + + # Count the number of draw_events we got. We could count some initial + # canvas draws (which vary in number by backend), but the critical + # check here is that it isn't 10 draws, which would be called if + # blitting is not properly implemented + ndraws = proc.stdout.count("DrawEvent") + assert 0 < ndraws < 5 diff --git a/src/_macosx.m b/src/_macosx.m index b72d6efd4e0d..7f3b67b40a3d 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -345,14 +345,7 @@ static CGFloat _get_device_scale(CGContextRef cr) } static PyObject* -FigureCanvas_draw(FigureCanvas* self) -{ - [self->view display]; - Py_RETURN_NONE; -} - -static PyObject* -FigureCanvas_draw_idle(FigureCanvas* self) +FigureCanvas_update(FigureCanvas* self) { [self->view setNeedsDisplay: YES]; Py_RETURN_NONE; @@ -361,6 +354,9 @@ static CGFloat _get_device_scale(CGContextRef cr) static PyObject* FigureCanvas_flush_events(FigureCanvas* self) { + // We need to allow the runloop to run very briefly + // to allow the view to be displayed when used in a fast updating animation + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.0]]; [self->view displayIfNeeded]; Py_RETURN_NONE; } @@ -485,12 +481,8 @@ static CGFloat _get_device_scale(CGContextRef cr) .tp_new = (newfunc)FigureCanvas_new, .tp_doc = "A FigureCanvas object wraps a Cocoa NSView object.", .tp_methods = (PyMethodDef[]){ - {"draw", - (PyCFunction)FigureCanvas_draw, - METH_NOARGS, - NULL}, // docstring inherited - {"draw_idle", - (PyCFunction)FigureCanvas_draw_idle, + {"update", + (PyCFunction)FigureCanvas_update, METH_NOARGS, NULL}, // docstring inherited {"flush_events", @@ -1263,7 +1255,7 @@ -(void)drawRect:(NSRect)rect CGContextRef cr = [[NSGraphicsContext currentContext] CGContext]; - if (!(renderer = PyObject_CallMethod(canvas, "_draw", "")) + if (!(renderer = PyObject_CallMethod(canvas, "get_renderer", "")) || !(renderer_buffer = PyObject_GetAttrString(renderer, "_renderer"))) { PyErr_Print(); goto exit;