Skip to content

FIX: Update blitting and drawing on the macosx backend #21790

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 5, 2022
Merged
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
61 changes: 49 additions & 12 deletions lib/matplotlib/backends/backend_macosx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not call draw as draw_idle should always be "cheap" .

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I took this from the qt_backend, which appears to do the same self.draw() call from within draw_idle? I'm not entirely clear where the "pending" of draw_idle() should take place here, whether it should be within the Python context managers, or if perhaps I'm just missing putting this into the Mac-specific event loop similar to the singleShot call from QT?

def _draw_idle(self):
with self._idle_draw_cntx():
if not self._draw_pending:
return
self._draw_pending = False
if self.height() < 0 or self.width() < 0:
return
try:
self.draw()
except Exception:
# Uncaught exceptions are fatal for PyQt5, so catch them.
traceback.print_exc()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the logic with Qt is that every call to canvas.draw_idle set self._draw_pending to True and then schedules a single-shot timer to run canvas._draw_idle. Thus many calls to draw_idle within a block of Python (which runs without letting the event loop spin) will stack up a bunch of single-shot timers. When ever the main loop gets to run (which is the earliest we would be able to paint the screen anyway!) the timers start to expire. The first one actually does the update and the rest short-circuit out.

I suspect that you can do the same single-shot trick with OSX?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the clear explanation! That makes sense to me (stacking a bunch of callbacks in the event loop, and those callbacks depend on whether the draw has already happened or not). Let me know if you think the implementation is wrong here, or if I misunderstood.


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.
Expand Down
69 changes: 69 additions & 0 deletions lib/matplotlib/tests/test_backends_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 7 additions & 15 deletions src/_macosx.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down