From 4264233abf9e4c71e6d1cb93bcc36a81bf1fa1aa Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Sun, 28 Nov 2021 16:46:28 -0700 Subject: [PATCH 1/4] FIX: Update blitting and drawing on the macosx backend This inherits some of the drawing and blitting code from the AGG canvas and leverages that to handle when a full AGG re-render should happen. The macosx code always grabs the full AGG buffer when doing the draw to the GUI window. --- lib/matplotlib/backends/backend_macosx.py | 34 +++++++++++++++-------- src/_macosx.m | 19 +++---------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 69643e35fe3f..2a11350b788a 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -30,27 +30,37 @@ 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 (getattr(self, '_draw_pending', False) or + getattr(self, '_is_drawing', False)): + return + self._draw_pending = True + with self._idle_draw_cntx(): + 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/src/_macosx.m b/src/_macosx.m index b72d6efd4e0d..bd497153e195 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; @@ -485,12 +478,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 +1252,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; From 32547c8490979f985eaa090b5b983e736af3ba53 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 26 Nov 2021 21:37:27 -0700 Subject: [PATCH 2/4] FIX: Add a 0-length timer to allow fast animations to redraw on macosx The macosx backend would not update on flush_events calls due to the loop being run too fast for the view to update properly in the NSApp. Fix that by adding an unnoticeable RunLoop timer slowdown that allows the app to see the changes. --- src/_macosx.m | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/_macosx.m b/src/_macosx.m index bd497153e195..7f3b67b40a3d 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -354,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; } From e29b6f24efe50d2d1d9bd4e0b588ae6f75683129 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Sun, 9 Jan 2022 10:48:28 -0700 Subject: [PATCH 3/4] ENH: add singleshot timer to macosx draw_idle This adds a singleshot timer to the draw_idle method. This has a callback that triggers the draw upon firing the timer if the draw has not occurred yet, otherwise it short-circuits out of the draw and removes the timer. --- lib/matplotlib/backends/backend_macosx.py | 33 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 2a11350b788a..70db18067a7d 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -49,11 +49,38 @@ def draw(self): def draw_idle(self): # docstring inherited - if (getattr(self, '_draw_pending', False) or + if not (getattr(self, '_draw_pending', False) or getattr(self, '_is_drawing', False)): - return - self._draw_pending = True + 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() From 5415418765003397bf53a293fc697b5e44a51cfb Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 4 Mar 2022 07:36:34 -0700 Subject: [PATCH 4/4] TST: Test number of draw events from a blitted animation This adds a test that only one draw_event is called when using blitting with animations. There will be more than one draw_event if blitting is not implemented properly and instead calls a full redraw of the canvas each time. --- .../tests/test_backends_interactive.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) 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