Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Migrate to reworked implementation
  • Loading branch information
iccir committed Jul 31, 2026
commit 49cd061cf0ce167b42cb04f795171a4b331f401f
209 changes: 124 additions & 85 deletions lib/matplotlib/backends/backend_macos.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import functools

import matplotlib as mpl
from matplotlib import _api, cbook
Expand All @@ -7,7 +8,8 @@
from .backend_agg import FigureCanvasAgg
from matplotlib.backend_bases import (
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
CloseEvent, ResizeEvent, TimerBase, _allow_interrupt)
CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent,
MouseButton, TimerBase, _allow_interrupt, _Mode)


class TimerMac(_macos.Timer, TimerBase):
Expand All @@ -21,108 +23,133 @@ def _allow_interrupt_macos():
lambda rsock: _macos.wake_on_fd_write(rsock.fileno()), _macos.stop)


class FigureCanvasMac(FigureCanvasAgg, _macos.FigureCanvas, FigureCanvasBase):
# docstring inherited
@functools.lru_cache
def _init_macos():
# This will contain initialization code in a future PR
_macos._init()

# Ideally this class would be `class FCMacAgg(FCAgg, FCMac)`
# (FC=FigureCanvas) where FCMac would be an ObjC-implemented mac-specific
# class also inheriting from FCBase (this is the approach with other GUI
# toolkits). However, writing an extension type inheriting from a Python
# base class is slightly tricky (the extension type must be a heap type),
# and we can just as well lift the FCBase base up one level, keeping it *at
# the end* to have the right method resolution order.

# Events such as button presses, mouse movements, and key presses are
# handled in C and events (MouseEvent, etc.) are triggered from there.
class FigureCanvasMac(_macos.FigureCanvas, FigureCanvasBase):
# docstring inherited

required_interactive_framework = "macosx"
required_interactive_framework = "macos"
_timer_cls = TimerMac
manager_class = _api.classproperty(lambda cls: FigureManagerMac)

def __init__(self, figure):
super().__init__(figure=figure)
self._draw_pending = False
self._is_drawing = False
# Keep track of the timers that are alive
self._timers = set()
_init_macos()
FigureCanvasBase.__init__(self, figure=figure)
width, height = self.get_width_height()
_macos.FigureCanvas.__init__(self, width, height)

def draw(self):
"""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()
"""Render the figure and send the buffer to the macOS CALayer."""
super().draw()
if not self._is_idle_drawing:
self._request_display_layer(False)

def _handle_display_layer(self, needs_draw):
with self._idle_draw_cntx():
if needs_draw:
self.draw()
self._update_layer_contents(self.get_renderer().buffer_rgba())

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"""
def callback_func(callback, timer):
callback()
self._timers.remove(timer)
timer = self.new_timer(interval=0)
timer.single_shot = True
timer.add_callback(callback_func, callback, timer)
self._timers.add(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()
self._request_display_layer(True)

def blit(self, bbox=None):
# docstring inherited
super().blit(bbox)
self.update()
self._request_display_layer(False)

def _handle_view_did_change_backing_properties(self, scale, width, height):
# Size from macOS is physical pixels
if self._set_device_pixel_ratio(scale):
self._handle_resize(width, height)
self.draw_idle()

def resize(self, width, height):
# Size from macOS is logical pixels, dpi is physical.
scale = self.figure.dpi / self.device_pixel_ratio
def _handle_resize(self, width, height):
# Size from macOS is physical pixels
scale = self.figure.dpi
width /= scale
height /= scale
self.figure.set_size_inches(width, height, forward=False)
ResizeEvent("resize_event", self)._process()
self.draw_idle()

def _mpl_buttons(self, buttons):
"""Converts from AppKit pressedMouseButtons to a set of MouseButton"""
mod_table = [
(MouseButton.LEFT, 1 << 0),
(MouseButton.MIDDLE, 1 << 1),
(MouseButton.RIGHT, 1 << 2),
(MouseButton.BACK, 1 << 3),
(MouseButton.FORWARD, 1 << 4),
]
return {name for name, mask in mod_table if buttons & mask}

def _mpl_modifiers(self, modifiers):
"""Converts from AppKit modifierFlags to a list of strings"""
mod_table = [
("ctrl", 1 << 18),
("alt", 1 << 19),
("shift", 1 << 17),
("cmd", 1 << 20),
]
return [name for name, mask in mod_table if modifiers & mask]

def _handle_key(self, is_press, key, x, y):
event_name = "key_press_event" if is_press else "key_release_event"
KeyEvent(event_name, self, key, x, y)._process()

def _handle_mouse_entered(self, x, y, modifiers):
LocationEvent("figure_enter_event", self, x, y,
modifiers=self._mpl_modifiers(modifiers))._process()

def _handle_mouse_exited(self, x, y, modifiers):
LocationEvent("figure_leave_event", self, x, y,
modifiers=self._mpl_modifiers(modifiers))._process()

def _handle_mouse_down(self, x, y, button, modifiers, dblclick):
MouseEvent("button_press_event", self, x, y, button, dblclick=dblclick,
modifiers=self._mpl_modifiers(modifiers))._process()

def _handle_mouse_up(self, x, y, button, modifiers):
MouseEvent("button_release_event", self, x, y, button,
modifiers=self._mpl_modifiers(modifiers))._process()

def _handle_mouse_moved(self, x, y, buttons, modifiers):
MouseEvent("motion_notify_event", self, x, y,
buttons=self._mpl_buttons(buttons),
modifiers=self._mpl_modifiers(modifiers))._process()

def _handle_scroll_wheel(self, x, y, step, modifiers):
MouseEvent("scroll_event", self, x, y, step=step,
modifiers=self._mpl_modifiers(modifiers))._process()

def start_event_loop(self, timeout=0):
# docstring inherited
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
with _allow_interrupt_macos():
self._start_event_loop(timeout=timeout) # Forward to ObjC implementation.


class FigureCanvasMacAgg(FigureCanvasAgg, FigureCanvasMac):
pass


class NavigationToolbar2Mac(_macos.NavigationToolbar2, NavigationToolbar2):

def __init__(self, canvas):
data_path = cbook._get_data_path('images')
_, tooltips, image_names, _ = zip(*NavigationToolbar2.toolitems)
_macos.NavigationToolbar2.__init__(
self, canvas,
tuple(str(data_path / image_name) + ".pdf"
for image_name in image_names if image_name is not None),
tuple(tooltip for tooltip in tooltips if tooltip is not None))
_macos.NavigationToolbar2.__init__(self, canvas)
data_path = cbook._get_data_path("images")
for text, tooltip_text, image_name, callback in self.toolitems:
if text is None:
self.add_separator()
else:
image_path = str(data_path / image_name) + ".pdf"
self.add_item(text, tooltip_text, image_path, callback)
NavigationToolbar2.__init__(self, canvas)

def draw_rubberband(self, event, x0, y0, x1, y1):
Expand All @@ -131,16 +158,33 @@ def draw_rubberband(self, event, x0, y0, x1, y1):
def remove_rubberband(self):
self.canvas.remove_rubberband()

def _update_buttons_checked(self):
mode_names = {_Mode.PAN: "pan", _Mode.ZOOM: "zoom"}
self.update_selected_item(mode_names.get(self.mode, ""))

def set_history_buttons(self):
can_backward = self._nav_stack._pos > 0
can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
self.update_history_items(can_backward, can_forward)

def pan(self, *args):
super().pan(*args)
self._update_buttons_checked()

def zoom(self, *args):
super().zoom(*args)
self._update_buttons_checked()

def save_figure(self, *args):
directory = os.path.expanduser(mpl.rcParams['savefig.directory'])
filename = _macos.choose_save_file('Save the figure',
directory,
self.canvas.get_default_filename())
directory = os.path.expanduser(mpl.rcParams["savefig.directory"])
filename = _macos.choose_save_file("Save the figure",
directory,
self.canvas.get_default_filename())
if filename is None: # Cancel
return
# Save dir for next time, unless empty str (which means use cwd).
if mpl.rcParams['savefig.directory']:
mpl.rcParams['savefig.directory'] = os.path.dirname(filename)
if mpl.rcParams["savefig.directory"]:
mpl.rcParams["savefig.directory"] = os.path.dirname(filename)
self.canvas.figure.savefig(filename)
return filename

Expand All @@ -151,10 +195,9 @@ class FigureManagerMac(_macos.FigureManager, FigureManagerBase):
def __init__(self, canvas, num):
self._shown = False
_macos.FigureManager.__init__(self, canvas)
icon_path = str(cbook._get_data_path('images/matplotlib.pdf'))
_macos.FigureManager.set_icon(icon_path)
FigureManagerBase.__init__(self, canvas, num)
self._set_window_mode(mpl.rcParams["macosx.window_mode"])
self._set_window_appearance(mpl.rcParams["macos.appearance"])
self._set_window_mode(mpl.rcParams["macos.window_mode"])
if self.toolbar is not None:
self.toolbar.update()
if mpl.is_interactive():
Expand All @@ -169,11 +212,7 @@ def _handle_window_should_close(self):
self.canvas.flush_events()

def destroy(self):
# We need to clear any pending timers that never fired, otherwise
# we get a memory leak from the timer callbacks holding a reference
while self.canvas._timers:
timer = self.canvas._timers.pop()
timer.stop()
self._close_and_clear_window()
super().destroy()

@classmethod
Expand All @@ -194,6 +233,6 @@ def show(self):

@_Backend.export
class _BackendMac(_Backend):
FigureCanvas = FigureCanvasMac
FigureCanvas = FigureCanvasMacAgg
FigureManager = FigureManagerMac
mainloop = FigureManagerMac.start_main_loop
2 changes: 1 addition & 1 deletion lib/matplotlib/backends/backend_macosx.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
class _BackendMac(_Backend):
FigureCanvas = _legacymac.FigureCanvasLegacyMac
FigureManager = _legacymac.FigureManagerLegacyMac
mainloop = _legacymac.FigureManagerLegacyMac.start_main_loop
mainloop = _legacymac.FigureManagerLegacyMac.start_main_loop
5 changes: 5 additions & 0 deletions lib/matplotlib/mpl-data/matplotlibrc
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,11 @@
# background by default
#savefig.orientation: portrait # orientation of saved figure, for PostScript output only

### macos backend params
#macos.appearance: system # The appearance of windows and controls (system, light, dark)
#macos.window_mode: system # How to open new figures (system, tab, window)
# 'system' uses 'Prefer tabs...' from System Settings

### macosx backend params
#macosx.window_mode : system # How to open new figures (system, tab, window)
# system uses the MacOS system preferences
Expand Down
20 changes: 19 additions & 1 deletion lib/matplotlib/rcsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1363,7 +1363,6 @@ def _convert_validator_spec(key, conv):
"figure.autolayout": validate_bool,
"figure.max_open_warning": validate_int,
"figure.raise_window": validate_bool,
"macosx.window_mode": ["system", "tab", "window"],

"figure.subplot.left": validate_float,
"figure.subplot.right": validate_float,
Expand Down Expand Up @@ -1393,6 +1392,10 @@ def _convert_validator_spec(key, conv):
"savefig.directory": _validate_pathlike,
"savefig.transparent": validate_bool,

"macos.appearance": ["system", "light", "dark"],
"macos.window_mode": ["system", "tab", "window"],
"macosx.window_mode": ["system", "tab", "window"],

"tk.window_focus": validate_bool, # Maintain shell focus for TkAgg

# Set the papersize/type
Expand Down Expand Up @@ -3133,6 +3136,21 @@ class _Subsection:
validator=["landscape", "portrait"],
description="orientation of saved figure, for PostScript output only"
),
_Subsection("macOS backend parameters"),
_Param(
"macos.appearance",
default="system",
validator=["system", "light", "dark"],
description="Whether windows and controls use a light or dark appearance. "
"'system' uses 'Appearance' from System Settings."
),
_Param(
"macos.window_mode",
default="system",
validator=["system", "tab", "window"],
description="How to open new figures (system, tab, window). "
"'system' uses 'Prefer tabs...' from System Settings."
),
_Subsection("Mac OSX backend parameters"),
_Param(
"macosx.window_mode",
Expand Down
2 changes: 2 additions & 0 deletions lib/matplotlib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,8 @@
"lines.scale_dashes",
"lines.solid_capstyle",
"lines.solid_joinstyle",
"macos.appearance",
"macos.window_mode",
"macosx.window_mode",
"markers.fillstyle",
"mathtext.bf",
Expand Down
5 changes: 5 additions & 0 deletions src/macos/MPLAppDelegate.h
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
#import <AppKit/AppKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface MPLAppDelegate : NSObject <NSApplicationDelegate>

@end

NS_ASSUME_NONNULL_END
Loading
Loading